@pi-unipi/unipi 3.0.0-alpha.25 → 3.0.0-alpha.26

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 (34) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/package.json +21 -21
  3. package/packages/ask-user/package.json +2 -2
  4. package/packages/autocomplete/package.json +1 -1
  5. package/packages/background-tasks/package.json +2 -2
  6. package/packages/btw/package.json +2 -2
  7. package/packages/compactor/package.json +3 -3
  8. package/packages/core/package.json +1 -1
  9. package/packages/footer/package.json +3 -3
  10. package/packages/fusion/package.json +4 -4
  11. package/packages/fusion/src/index.ts +9 -0
  12. package/packages/info-screen/README.md +2 -2
  13. package/packages/info-screen/core-groups.ts +6 -2
  14. package/packages/info-screen/package.json +2 -2
  15. package/packages/info-screen/pages/inventory.ts +9 -6
  16. package/packages/info-screen/pages/modules.ts +3 -3
  17. package/packages/info-screen/pages/usage.ts +1 -0
  18. package/packages/info-screen/tui/page-kit.ts +7 -6
  19. package/packages/input-shortcuts/package.json +2 -2
  20. package/packages/kanboard/package.json +7 -7
  21. package/packages/long-horizon/index.ts +7 -0
  22. package/packages/long-horizon/package.json +2 -2
  23. package/packages/long-horizon/src/gate.ts +70 -1
  24. package/packages/mcp/package.json +2 -2
  25. package/packages/memory/package.json +3 -3
  26. package/packages/notify/package.json +2 -2
  27. package/packages/skill-registry/package.json +2 -2
  28. package/packages/subagents/package.json +3 -3
  29. package/packages/unipi/bundled.js +84 -30
  30. package/packages/updater/package.json +2 -2
  31. package/packages/utility/package.json +2 -2
  32. package/packages/watchdog/package.json +3 -3
  33. package/packages/web-api/package.json +2 -2
  34. package/packages/workflow/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,15 @@ 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.26] — 2026-10-04
10
+
11
+ ### Fixed
12
+ - `fusion`: the "Non-trivial shell work since last handoff" nudge fires at most once per agent run instead of repeating on every 4th non-trivial shell command; a sidekick handoff still resets the streak but no longer re-arms the nudge, and a new prompt arms it again. (UNI-87)
13
+ - `long-horizon`: mode-control tools are now truly OFF in pi's active tool set outside their mode (previously registered/active and only hidden from the provider payload), so pi's tool snippets and other modules no longer see them. Fresh and resumed sessions start with them off; resolving into a mode (command, judge, or active owner) turns that mode's tools on. (UNI-90)
14
+ - `info-screen`: scope order now reads biggest first — footer legend `g global p project s session`, Compactor savings tiles global → project → session, Memory tiles global → project → share.
15
+ - `info-screen`: Skills page tags each source with its real scope (`g` for user/agents/unipi/package skills, `p` for project skills) instead of implying session scope; the Tools page's tool list is tagged `s` (the active set is per session).
16
+ - `info-screen`: Usage page leaves one blank line between the model share bar and the ranked model rows.
17
+
9
18
  ## [3.0.0-alpha.25] — 2026-10-04
10
19
 
11
20
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -83,26 +83,26 @@
83
83
  "typebox": "^1.1.38"
84
84
  },
85
85
  "dependencies": {
86
- "@pi-unipi/ask-user": "3.0.0-alpha.25",
87
- "@pi-unipi/background-tasks": "3.0.0-alpha.25",
88
- "@pi-unipi/btw": "3.0.0-alpha.25",
89
- "@pi-unipi/command-enchantment": "3.0.0-alpha.25",
90
- "@pi-unipi/compactor": "3.0.0-alpha.25",
91
- "@pi-unipi/core": "3.0.0-alpha.25",
92
- "@pi-unipi/footer": "3.0.0-alpha.25",
93
- "@pi-unipi/fusion": "3.0.0-alpha.25",
94
- "@pi-unipi/info-screen": "3.0.0-alpha.25",
95
- "@pi-unipi/input-shortcuts": "3.0.0-alpha.25",
96
- "@pi-unipi/kanboard": "3.0.0-alpha.25",
97
- "@pi-unipi/mcp": "3.0.0-alpha.25",
98
- "@pi-unipi/memory": "3.0.0-alpha.25",
99
- "@pi-unipi/notify": "3.0.0-alpha.25",
100
- "@pi-unipi/skill-registry": "3.0.0-alpha.25",
101
- "@pi-unipi/subagents": "3.0.0-alpha.25",
102
- "@pi-unipi/updater": "3.0.0-alpha.25",
103
- "@pi-unipi/utility": "3.0.0-alpha.25",
104
- "@pi-unipi/web-api": "3.0.0-alpha.25",
105
- "@pi-unipi/workflow": "3.0.0-alpha.25"
86
+ "@pi-unipi/ask-user": "3.0.0-alpha.26",
87
+ "@pi-unipi/background-tasks": "3.0.0-alpha.26",
88
+ "@pi-unipi/btw": "3.0.0-alpha.26",
89
+ "@pi-unipi/command-enchantment": "3.0.0-alpha.26",
90
+ "@pi-unipi/compactor": "3.0.0-alpha.26",
91
+ "@pi-unipi/core": "3.0.0-alpha.26",
92
+ "@pi-unipi/footer": "3.0.0-alpha.26",
93
+ "@pi-unipi/fusion": "3.0.0-alpha.26",
94
+ "@pi-unipi/info-screen": "3.0.0-alpha.26",
95
+ "@pi-unipi/input-shortcuts": "3.0.0-alpha.26",
96
+ "@pi-unipi/kanboard": "3.0.0-alpha.26",
97
+ "@pi-unipi/mcp": "3.0.0-alpha.26",
98
+ "@pi-unipi/memory": "3.0.0-alpha.26",
99
+ "@pi-unipi/notify": "3.0.0-alpha.26",
100
+ "@pi-unipi/skill-registry": "3.0.0-alpha.26",
101
+ "@pi-unipi/subagents": "3.0.0-alpha.26",
102
+ "@pi-unipi/updater": "3.0.0-alpha.26",
103
+ "@pi-unipi/utility": "3.0.0-alpha.26",
104
+ "@pi-unipi/web-api": "3.0.0-alpha.26",
105
+ "@pi-unipi/workflow": "3.0.0-alpha.26"
106
106
  },
107
107
  "devDependencies": {
108
108
  "@earendil-works/pi-agent-core": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
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",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@pi-unipi/core": "3.0.0-alpha.25"
43
+ "@pi-unipi/core": "3.0.0-alpha.26"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@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.25",
3
+ "version": "3.0.0-alpha.26",
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.25",
3
+ "version": "3.0.0-alpha.26",
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.25",
13
+ "@pi-unipi/core": "3.0.0-alpha.26",
14
14
  "turndown": "^7.2.4"
15
15
  },
16
16
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
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.25"
40
+ "@pi-unipi/core": "3.0.0-alpha.26"
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.25",
3
+ "version": "3.0.0-alpha.26",
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.25",
37
- "@pi-unipi/info-screen": "3.0.0-alpha.25",
36
+ "@pi-unipi/core": "3.0.0-alpha.26",
37
+ "@pi-unipi/info-screen": "3.0.0-alpha.26",
38
38
  "@earendil-works/pi-agent-core": "^0.87.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "3.0.0-alpha.25",
36
- "@pi-unipi/background-tasks": "3.0.0-alpha.25"
35
+ "@pi-unipi/core": "3.0.0-alpha.26",
36
+ "@pi-unipi/background-tasks": "3.0.0-alpha.26"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/fusion",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -32,9 +32,9 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "3.0.0-alpha.25",
36
- "@pi-unipi/subagents": "3.0.0-alpha.25",
37
- "@pi-unipi/utility": "3.0.0-alpha.25"
35
+ "@pi-unipi/core": "3.0.0-alpha.26",
36
+ "@pi-unipi/subagents": "3.0.0-alpha.26",
37
+ "@pi-unipi/utility": "3.0.0-alpha.26"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-ai": "^0.87.1",
@@ -231,6 +231,11 @@ export default function fusionExtension(pi: ExtensionAPI): void {
231
231
  let leadToolCalls = 0;
232
232
  let editNudgedThisTurn = false;
233
233
  let bashStreak = 0;
234
+ // The shell-delegation nudge fires at most ONCE per agent run (per user
235
+ // prompt): pi restarts the handler state on every before_agent_start, and
236
+ // sidekick/read_subagent handoffs keep resetting bashStreak, but neither
237
+ // may re-arm the nudge within the same run.
238
+ let bashNudgedThisRun = false;
234
239
 
235
240
  const wakeLine = createSidekickWakeLine({
236
241
  isBusy: () => runtime?.isBusy() === true,
@@ -378,6 +383,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
378
383
  // replacing `systemPrompt`, which nukes the cached prefix). The section is
379
384
  // present only while Fusion is active.
380
385
  pi.on("before_agent_start", (event, ctx) => {
386
+ bashNudgedThisRun = false;
381
387
  syncFusionTools(); // safety net: re-sync after any module re-juggled tools
382
388
  if (active?.kind === "fusion") {
383
389
  event.systemPromptOptions.sections["fusion-lead-policy"] = leadPolicy(identity(ctx));
@@ -418,8 +424,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
418
424
  if (toolName !== "bash") return;
419
425
  const command = typeof event.input.command === "string" ? event.input.command : "";
420
426
  if (isTrivialShell(command)) return;
427
+ if (bashNudgedThisRun) return;
421
428
  bashStreak += 1;
422
429
  if (bashStreak < BASH_NUDGE_EVERY) return;
430
+ bashNudgedThisRun = true;
423
431
  const annotation = bashNudge(bashStreak);
424
432
  const synopsis = "Non-trivial shell work since last handoff";
425
433
  const content = [...event.content, { type: "text" as const, text: annotation }];
@@ -633,6 +641,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
633
641
  stopRuntime();
634
642
  editNudgedThisTurn = false;
635
643
  bashStreak = 0;
644
+ bashNudgedThisRun = false;
636
645
  modelBykey.clear();
637
646
  active = loadPreset(ctx.cwd ?? process.cwd()).preset.active;
638
647
  mirrorStartup(ctx.cwd ?? process.cwd());
@@ -12,7 +12,7 @@ See what your session costs, what fills its context and what compaction saved yo
12
12
 
13
13
  - **Startup splash.** Unicrab says hello when Pi starts. The splash shows how fast Pi got ready, the session you came back to, today's spend and any update. It does not take your keys. It closes after `bootTimeoutMs`.
14
14
  - **Dashboard.** `/unipi:info` opens a set of pages. The first page is about this session.
15
- - **Scope tags.** A small letter before a number gives its scope: `s` is this session, `p` is this project and `g` is all projects on this machine.
15
+ - **Scope tags.** A small letter before a number gives its scope: `g` is all projects on this machine, `p` is this project and `s` is this session.
16
16
  - **Fast open.** The dashboard opens at once on cached numbers. Then it updates the page that you look at.
17
17
 
18
18
  ## Quick start
@@ -63,7 +63,7 @@ splash waits for that overlay to close.
63
63
  | Tools | Each tool, with a colour for its source. Active tools have a full dot. |
64
64
  | Skills | Each skill, with a colour for where it lives. |
65
65
  | Modules | What each UniPi module adds: tools, commands, settings and keys. Other extensions. |
66
- | Compactor | Tokens and money that compaction saved, for `s`, `p` and `g`. Each compaction in this session. |
66
+ | Compactor | Tokens and money that compaction saved, for `g`, `p` and `s`. Each compaction in this session. |
67
67
  | MCP · Memory · Web · Updates · Keys | The state of each module. MCP servers show `g` or `p` for the config that they come from. |
68
68
 
69
69
  ### The context bucket
@@ -14,6 +14,7 @@ import { collectSession, renderSession, sessionData, type SessionCtxLike } from
14
14
  import { renderUsage, usageData, type UsageRaw } from "./pages/usage.js";
15
15
  import { setCompactorHistorySource } from "./pages/modules.js";
16
16
  import { inventoryData, renderInventory, renderModules, type ModulesRaw, type Named } from "./pages/inventory.js";
17
+ import type { Scope } from "./palette.js";
17
18
 
18
19
  // ─── load tracking ─────────────────────────────────────────────────────────
19
20
 
@@ -222,7 +223,7 @@ export function registerCoreGroups(): void {
222
223
  const activeCount = active ? items.filter((i) => i.active).length : undefined;
223
224
  return inventoryData(items, activeCount);
224
225
  },
225
- render: (pc) => renderInventory(pc, "tools", "no tools registered"),
226
+ render: (pc) => renderInventory(pc, "tools", "no tools registered", "s"),
226
227
  });
227
228
 
228
229
  infoRegistry.registerGroup({
@@ -245,7 +246,10 @@ export function registerCoreGroups(): void {
245
246
  } catch {
246
247
  items = [];
247
248
  }
248
- return inventoryData(items);
249
+ // Skills install per machine except project-local ones — the tags make
250
+ // that explicit (most skills are global, not session-scoped).
251
+ const groupScopes: Record<string, Scope> = { project: "p", user: "g", agents: "g", unipi: "g", package: "g" };
252
+ return inventoryData(items, undefined, groupScopes);
249
253
  },
250
254
  render: (pc) => renderInventory(pc, "skills", "add skills under ~/.pi/agent/skills or .pi/skills"),
251
255
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Unicrab startup splash and the /unipi:info dashboard — this session, context, usage, compaction savings",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "3.0.0-alpha.25"
36
+ "@pi-unipi/core": "3.0.0-alpha.26"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -10,7 +10,8 @@
10
10
  import { compact, fitTo, grid, rightTo, shareBar, type RGB } from "@pi-unipi/core";
11
11
  import { visibleWidth } from "@earendil-works/pi-tui";
12
12
  import type { GroupData, PageContext } from "../types.js";
13
- import { dim, empty, legend, muted, section, tiles } from "../tui/page-kit.js";
13
+ import type { Scope } from "../palette.js";
14
+ import { dim, empty, legend, muted, section, tag, tiles } from "../tui/page-kit.js";
14
15
 
15
16
  export interface Named {
16
17
  name: string;
@@ -22,6 +23,8 @@ export interface InventoryRaw {
22
23
  items: Named[];
23
24
  groups: Array<[string, number]>;
24
25
  activeCount?: number;
26
+ /** Scope per group label, shown as a one-cell tag in the legend. */
27
+ groupScopes?: Record<string, Scope>;
25
28
  }
26
29
 
27
30
  const GROUP_COLORS: RGB[] = [[0, 200, 240], [240, 120, 24], [190, 150, 240], [120, 200, 120], [240, 192, 48], [230, 120, 200], [78, 201, 176], [140, 140, 160]];
@@ -33,7 +36,7 @@ function colorMap(groups: ReadonlyArray<[string, number]>): Map<string, RGB> {
33
36
  return m;
34
37
  }
35
38
 
36
- export function inventoryData(items: Named[], activeCount?: number): GroupData {
39
+ export function inventoryData(items: Named[], activeCount?: number, groupScopes?: Record<string, Scope>): GroupData {
37
40
  const counts = new Map<string, number>();
38
41
  for (const i of items) counts.set(i.group, (counts.get(i.group) ?? 0) + 1);
39
42
  const groups = [...counts.entries()].sort((a, b) => b[1] - a[1]);
@@ -42,7 +45,7 @@ export function inventoryData(items: Named[], activeCount?: number): GroupData {
42
45
  const gb = groups.findIndex(([g]) => g === b.group);
43
46
  return ga - gb || a.name.localeCompare(b.name);
44
47
  });
45
- const raw: InventoryRaw = { items: sorted, groups, activeCount };
48
+ const raw: InventoryRaw = { items: sorted, groups, activeCount, groupScopes };
46
49
  return {
47
50
  total: { value: String(items.length) },
48
51
  groups: { value: groups.map(([g, n]) => `${n} ${g}`).join(", ") },
@@ -50,7 +53,7 @@ export function inventoryData(items: Named[], activeCount?: number): GroupData {
50
53
  };
51
54
  }
52
55
 
53
- export function renderInventory(pc: PageContext, noun: string, emptyHint: string): string[] {
56
+ export function renderInventory(pc: PageContext, noun: string, emptyHint: string, headerScope?: Scope): string[] {
54
57
  const raw = pc.data.raw?.raw as InventoryRaw | undefined;
55
58
  if (!raw || raw.items.length === 0) return empty(pc, `No ${noun} loaded`, emptyHint);
56
59
  const p = pc.paint;
@@ -63,10 +66,10 @@ export function renderInventory(pc: PageContext, noun: string, emptyHint: string
63
66
  out.push(...tiles(pc, headline));
64
67
  out.push("");
65
68
  out.push(fitTo(shareBar(p, raw.groups.map(([g, n]) => ({ value: n, color: colors.get(g)! })), pc.width, "▆"), pc.width));
66
- out.push(...legend(pc, raw.groups.map(([g, n]) => ({ label: `${g} ${n}`, color: colors.get(g)! }))));
69
+ out.push(...legend(pc, raw.groups.map(([g, n]) => ({ label: `${g} ${n}`, color: colors.get(g)!, scope: raw.groupScopes?.[g] }))));
67
70
  out.push("");
68
71
 
69
- out.push(section(pc, `all ${noun}`, dim(p, "● active ○ off")));
72
+ out.push(section(pc, `all ${noun}`, dim(p, "● active ○ off"), headerScope));
70
73
  const items = raw.items.map((i) => {
71
74
  const c = colors.get(i.group) ?? [140, 140, 160];
72
75
  const mark = i.active === false ? p.rgb(c, "○") : p.rgb(c, "●");
@@ -110,9 +110,9 @@ export function renderCompactor(pc: PageContext): string[] {
110
110
  out.push(section(pc, "saved by compaction", dim(p, "tokens not re-sent · ≈ money not spent")));
111
111
  out.push(
112
112
  ...tiles(pc, [
113
- { label: "session", value: moneyShort(sess.dollars), sub: `${compact(sess.avoided)} tok · ${sess.count}×`, scope: "s", stops: [[0, 160, 200], [0, 200, 240], [160, 230, 255]] },
114
- ...(hist.project ? [{ label: "project", value: moneyShort(hist.project.dollars), sub: `${compact(hist.project.avoided)} tok · ${hist.project.count}×`, scope: "p" as const, stops: [[70, 150, 70], [120, 200, 120], [190, 240, 190]] as RGB[] }] : []),
115
113
  ...(hist.global ? [{ label: "global", value: moneyShort(hist.global.dollars), sub: `${compact(hist.global.avoided)} tok · ${hist.global.count}×`, scope: "g" as const, stops: [CRAB.amber, CRAB.gold, CRAB.cream] }] : []),
114
+ ...(hist.project ? [{ label: "project", value: moneyShort(hist.project.dollars), sub: `${compact(hist.project.avoided)} tok · ${hist.project.count}×`, scope: "p" as const, stops: [[70, 150, 70], [120, 200, 120], [190, 240, 190]] as RGB[] }] : []),
115
+ { label: "session", value: moneyShort(sess.dollars), sub: `${compact(sess.avoided)} tok · ${sess.count}×`, scope: "s", stops: [[0, 160, 200], [0, 200, 240], [160, 230, 255]] },
116
116
  ]),
117
117
  );
118
118
 
@@ -287,8 +287,8 @@ export function renderMemory(pc: PageContext): string[] {
287
287
  const out: string[] = [];
288
288
  out.push(
289
289
  ...tiles(pc, [
290
- { label: "project", value: compact(raw.projectCount), sub: raw.project, scope: "p" },
291
290
  { label: "global", value: compact(raw.total), sub: "all projects", scope: "g" },
291
+ { label: "project", value: compact(raw.projectCount), sub: raw.project, scope: "p" },
292
292
  { label: "share", value: `${raw.total ? Math.round((raw.projectCount / raw.total) * 100) : 0}%`, sub: "project / global" },
293
293
  ]),
294
294
  );
@@ -150,6 +150,7 @@ export function renderUsage(pc: PageContext): string[] {
150
150
  out.push(fitTo(`${p.rgb(MODEL_COLORS[0]!, "●")} ${p.bold(m[0])} ${dim(p, "every token this month")} ${p.bold(useCost ? money(m[1]) : compact(m[2]))}`, pc.width));
151
151
  } else {
152
152
  out.push(fitTo(shareBar(p, raw.models.map((m, i) => ({ value: metric(m), color: MODEL_COLORS[i % MODEL_COLORS.length]! })), pc.width, "▆"), pc.width));
153
+ out.push("");
153
154
  // Ranked list: bars scaled to the TOTAL (so they read as shares, not "max = full").
154
155
  const nameW = Math.min(26, Math.max(...raw.models.map((m) => m[0].length)) + 1);
155
156
  const valW = 14;
@@ -25,13 +25,13 @@ import {
25
25
  import type { GroupData, PageContext } from "../types.js";
26
26
  import { accentRamp, shade, SCOPE_COLOR, SCOPE_WORD, type Scope } from "../palette.js";
27
27
 
28
- /** One-cell scope tag: `s` session (cyan) · `p` project (green) · `g` global (amber). */
28
+ /** One-cell scope tag: `g` global (amber) · `p` project (green) · `s` session (cyan). */
29
29
  export function tag(p: Paint, scope: Scope): string {
30
30
  return p.bold(p.rgb(SCOPE_COLOR[scope], scope));
31
31
  }
32
32
 
33
- /** `s session p project g global` — legend for the footer. */
34
- export function scopeLegend(p: Paint, scopes: readonly Scope[] = ["s", "p", "g"]): string {
33
+ /** `g global p project s session` — legend for the footer, biggest scope first. */
34
+ export function scopeLegend(p: Paint, scopes: readonly Scope[] = ["g", "p", "s"]): string {
35
35
  return scopes.map((sc) => `${tag(p, sc)} ${p.fg("dim", SCOPE_WORD[sc])}`).join(" ");
36
36
  }
37
37
 
@@ -138,13 +138,14 @@ export function rankBars(
138
138
  });
139
139
  }
140
140
 
141
- /** `■ input 42% ■ output 9% …` legend, wrapped onto as many rows as needed. */
142
- export function legend(pc: PageContext, items: ReadonlyArray<{ label: string; color: RGB }>): string[] {
141
+ /** `■ input 42% ■ output 9% …` legend, wrapped onto as many rows as needed.
142
+ * An entry may carry a one-cell scope tag: `■ g user 30`. */
143
+ export function legend(pc: PageContext, items: ReadonlyArray<{ label: string; color: RGB; scope?: Scope }>): string[] {
143
144
  const p = pc.paint;
144
145
  const rows: string[] = [];
145
146
  let cur = "";
146
147
  for (const i of items) {
147
- const piece = `${p.rgb(i.color, "■")} ${muted(p, i.label)}`;
148
+ const piece = `${p.rgb(i.color, "■")} ${i.scope ? `${tag(p, i.scope)} ` : ""}${muted(p, i.label)}`;
148
149
  if (cur && visibleWidth(cur) + 3 + visibleWidth(piece) > pc.width) {
149
150
  rows.push(fitTo(cur, pc.width));
150
151
  cur = piece;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/input-shortcuts",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Keyboard shortcuts for stash/restore, undo/redo, clipboard, and thinking toggle — chord-based overlay system",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -33,7 +33,7 @@
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "3.0.0-alpha.25"
36
+ "@pi-unipi/core": "3.0.0-alpha.26"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Kanboard v3 — deferred-work board: CLI/daemon bridge, task runner and skill",
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.25"
34
+ "@pi-unipi/core": "3.0.0-alpha.26"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -48,10 +48,10 @@
48
48
  "themes": []
49
49
  },
50
50
  "optionalDependencies": {
51
- "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.25",
52
- "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.25",
53
- "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.25",
54
- "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.25",
55
- "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.25"
51
+ "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.26",
52
+ "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.26",
53
+ "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.26",
54
+ "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.26",
55
+ "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.26"
56
56
  }
57
57
  }
@@ -78,6 +78,11 @@ export default function longHorizon(pi: ExtensionAPI): void {
78
78
  const owner = new OwnerCoordinator({
79
79
  statePath,
80
80
  onChange: (_snapshot, event: OwnerEvent) => {
81
+ // Owner transitions sync pi's active set (UNI-90): activate/resume/restore
82
+ // outside before_agent_start must expose the owner's mode tools at once.
83
+ // `gate` below is initialized in this same synchronous body before any
84
+ // transition can fire, so the forward reference is safe.
85
+ gate.onOwnerChanged(event);
81
86
  if (ownerEventClearsStash(event)) {
82
87
  stash.take();
83
88
  stashMetaState = { meta: undefined, kickoff: false };
@@ -378,6 +383,8 @@ export default function longHorizon(pi: ExtensionAPI): void {
378
383
  // reattaches the owner's tool surface; the continuation arms a recovery
379
384
  // fragment for the first post-restart turn.
380
385
  pi.on("session_start", () => {
386
+ // Mode tools start OFF; a restored active owner re-syncs them ON below.
387
+ gate.resetModeTools();
381
388
  const restored = owner.restore();
382
389
  machine.restore();
383
390
  // Only a truly drivable goal arms the recovery fragment; a paused goal
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/long-horizon",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Long-horizon execution for Pi — mode-gated /goal /ralph /swarm /graph orchestration with a TypeSafe prompt judge",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -37,7 +37,7 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "3.0.0-alpha.25"
40
+ "@pi-unipi/core": "3.0.0-alpha.26"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -19,7 +19,7 @@
19
19
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
20
  import { emitEvent, isChildProcess, UNIPI_EVENTS, setSharedLongHorizonMode } from "@pi-unipi/core";
21
21
  import { LH_MODES, MODE_REGISTRY, modeForOwnerKind, type LhMode } from "./modes.js";
22
- import type { OwnerCoordinator, OwnerState } from "./owner.js";
22
+ import type { OwnerCoordinator, OwnerEvent, OwnerState } from "./owner.js";
23
23
  import { resolveMode, type ResolutionSource } from "./judge/resolve.js";
24
24
  import type { FetchLike } from "./judge/typesafe.js";
25
25
  import { loadSettings, type LongHorizonSettings } from "./settings.js";
@@ -102,6 +102,28 @@ export function filterPayloadTools<T>(payload: T, mode: LhMode): T {
102
102
  return { ...record, tools: filtered } as T;
103
103
  }
104
104
 
105
+ /**
106
+ * The gate owns mode-tool membership in pi's ACTIVE tool set: the payload
107
+ * filter alone leaves the tools declared in the system prompt, visible to
108
+ * pi's active-set consumers and to every other module. Touches ONLY names in
109
+ * ALL_MODE_TOOLS — adds the mode's controlTools, removes the rest — and
110
+ * preserves the order of every other tool. Skips setActiveTools when
111
+ * membership already matches (prefix-cache and re-entrancy safety). Returns
112
+ * whether the active set changed. (pi ignores unregistered names; harmless.)
113
+ */
114
+ export function syncModeTools(
115
+ pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">,
116
+ mode: LhMode,
117
+ ): boolean {
118
+ const current = pi.getActiveTools();
119
+ const wanted = new Set<string>(MODE_REGISTRY[mode].controlTools);
120
+ if (ALL_MODE_TOOLS.every((name) => current.includes(name) === wanted.has(name))) return false;
121
+ const kept = current.filter((name) => !ALL_MODE_TOOLS.includes(name) || wanted.has(name));
122
+ const additions = [...wanted].filter((name) => !kept.includes(name));
123
+ pi.setActiveTools([...kept, ...additions]);
124
+ return true;
125
+ }
126
+
105
127
  function ownerPresenceLine(owner?: OwnerState, parked?: OwnerState): string {
106
128
  // Presence only — numbers change every settlement and would bust the prefix
107
129
  // cache. Live status rides tail messages (continuation hints), not here.
@@ -152,6 +174,10 @@ export class Gate {
152
174
  setExplicit(mode: LhMode): void {
153
175
  this.pendingExplicit = mode;
154
176
  this.sessionOverride = null;
177
+ // The command's own next turn re-resolves and re-syncs; syncing now covers
178
+ // the gap where the kickoff rides a path that never re-enters
179
+ // before_agent_start (mid-run stash delivery → agent.continue()).
180
+ if (this.pi) syncModeTools(this.pi, mode);
155
181
  }
156
182
 
157
183
  /** Pin the session to a mode (/unipi:regular, goal stop): every later turn resolves to it. */
@@ -160,6 +186,7 @@ export class Gate {
160
186
  this.pendingExplicit = null;
161
187
  setSharedLongHorizonMode(mode);
162
188
  if (this.pi) {
189
+ syncModeTools(this.pi, mode);
163
190
  emitEvent(this.pi, UNIPI_EVENTS.LONG_HORIZON_MODE_RESOLVED, {
164
191
  mode,
165
192
  source: "explicit",
@@ -167,6 +194,43 @@ export class Gate {
167
194
  }
168
195
  }
169
196
 
197
+ /**
198
+ * Owner transitions between turns sync the active set too (UNI-90): an owner
199
+ * activated, resumed, or restored OUTSIDE before_agent_start — slash
200
+ * commands, command runners (/unipi:continue, unipi:goal-start/resume),
201
+ * crash recovery — flips its mode's tools on immediately, so a kickoff
202
+ * delivered via agent.continue() (which never re-enters
203
+ * before_agent_start) still has them. Wired from index's single owner
204
+ * onChange closure; suspend/finish/clear deliberately sync nothing (the
205
+ * next turn's resolution is authoritative, and a suspend-and-switch already
206
+ * synced its new mode via setExplicit). Best-effort: never throws.
207
+ */
208
+ onOwnerChanged(event: OwnerEvent): void {
209
+ try {
210
+ if (!this.pi) return;
211
+ if (event.type !== "activated" && event.type !== "resumed" && event.type !== "restored") return;
212
+ if (isChildProcess() && process.env.UNIPI_LH_ALLOW_CHILD !== "1") return;
213
+ const owner = event.type === "restored" ? event.snapshot.active : event.owner;
214
+ if (!owner) return;
215
+ syncModeTools(this.pi, modeForOwnerKind(owner.kind));
216
+ } catch {
217
+ // An owner transition must never fail because of tool syncing.
218
+ }
219
+ }
220
+
221
+ /** Fresh or resumed sessions start with the mode tools OFF; a restored
222
+ * active owner re-syncs its mode's tools ON right after via
223
+ * onOwnerChanged. Wired from index's single session_start handler so the
224
+ * extension keeps exactly one. Best-effort: never throws. */
225
+ resetModeTools(): void {
226
+ try {
227
+ if (!this.pi) return;
228
+ syncModeTools(this.pi, "none");
229
+ } catch {
230
+ // Session start must never fail because of tool syncing.
231
+ }
232
+ }
233
+
170
234
  current(): GateState | null {
171
235
  return this.turn;
172
236
  }
@@ -221,6 +285,11 @@ export class Gate {
221
285
  // background by the entry renderer registered in index.ts.
222
286
  pi.on("before_agent_start", async (event) => {
223
287
  const state = await this.resolveForTurn(event.prompt);
288
+ // pi's live loadout is authoritative for this turn (agent-session honors
289
+ // setActiveTools from before_agent_start unless a handler edited
290
+ // systemPromptOptions.selectedTools), so flip the mode tools before
291
+ // anything else consumes the resolution.
292
+ syncModeTools(pi, state.mode);
224
293
  // Shared holder the footer pulls each render (timing-independent), plus
225
294
  // the event for the badge/other listeners.
226
295
  setSharedLongHorizonMode(state.mode);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/mcp",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -30,7 +30,7 @@
30
30
  "test": "npx tsx --test tests/**/*.test.ts"
31
31
  },
32
32
  "dependencies": {
33
- "@pi-unipi/core": "3.0.0-alpha.25"
33
+ "@pi-unipi/core": "3.0.0-alpha.26"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Persistent cross-session memory on MemPalace (auto-installed): daemon-routed writes, shared palace with other agents, for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -42,8 +42,8 @@
42
42
  "README.md"
43
43
  ],
44
44
  "dependencies": {
45
- "@pi-unipi/core": "3.0.0-alpha.25",
46
- "@pi-unipi/info-screen": "3.0.0-alpha.25",
45
+ "@pi-unipi/core": "3.0.0-alpha.26",
46
+ "@pi-unipi/info-screen": "3.0.0-alpha.26",
47
47
  "js-yaml": "^4.1.0"
48
48
  },
49
49
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "3.0.0-alpha.25",
37
+ "@pi-unipi/core": "3.0.0-alpha.26",
38
38
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/skill-registry",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Skill registry for pi — per-project skill on/off, a skill vault, and jev-judged skill exposure",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "main": "index.ts",
35
35
  "dependencies": {
36
- "@pi-unipi/core": "3.0.0-alpha.25"
36
+ "@pi-unipi/core": "3.0.0-alpha.26"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/subagents",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Subagents for UniPi — Devin-model independent child agents",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -9,8 +9,8 @@
9
9
  "test": "npx tsx --test src/__tests__/*.test.ts"
10
10
  },
11
11
  "dependencies": {
12
- "@pi-unipi/core": "3.0.0-alpha.25",
13
- "@pi-unipi/utility": "3.0.0-alpha.25",
12
+ "@pi-unipi/core": "3.0.0-alpha.26",
13
+ "@pi-unipi/utility": "3.0.0-alpha.26",
14
14
  "@earendil-works/pi-agent-core": "^0.87.1",
15
15
  "acorn": "8.18.0"
16
16
  },
@@ -3432,7 +3432,7 @@ var init_hub = __esm({
3432
3432
  const rows = [];
3433
3433
  for (const def of defs) {
3434
3434
  const layers = settingsLayers(def.namespace, this.cwd);
3435
- const tag2 = `${layers.global ? "G" : "-"}${layers.project ? "P" : "-"}`;
3435
+ const tag3 = `${layers.global ? "G" : "-"}${layers.project ? "P" : "-"}`;
3436
3436
  const sections = def.schema;
3437
3437
  const advanced = sections.filter((sec) => sec.advanced === true);
3438
3438
  const plain = sections.filter((sec) => sec.advanced !== true);
@@ -3441,7 +3441,7 @@ var init_hub = __esm({
3441
3441
  kind: "header",
3442
3442
  id: `${def.namespace}::${section3.title}`,
3443
3443
  label: `${def.label} \u2014 ${section3.title}`,
3444
- layerTag: tag2,
3444
+ layerTag: tag3,
3445
3445
  namespace: def.namespace
3446
3446
  });
3447
3447
  for (const field of section3.fields) {
@@ -3468,7 +3468,7 @@ var init_hub = __esm({
3468
3468
  kind: "header",
3469
3469
  id: `${def.namespace}::${section3.title}`,
3470
3470
  label: `${def.label} \u2014 ${section3.title}`,
3471
- layerTag: tag2,
3471
+ layerTag: tag3,
3472
3472
  namespace: def.namespace,
3473
3473
  advancedOf: def.namespace
3474
3474
  });
@@ -4107,8 +4107,8 @@ var init_hub = __esm({
4107
4107
  return this.exactRow(dim(` ${row.label}`), inner);
4108
4108
  }
4109
4109
  if (row.kind === "header") {
4110
- const tag2 = row.layerTag ? ` [${row.layerTag}]` : "";
4111
- return hubHeaderBand({ inner, text: `${row.label}${tag2}`, namespace: row.namespace });
4110
+ const tag3 = row.layerTag ? ` [${row.layerTag}]` : "";
4111
+ return hubHeaderBand({ inner, text: `${row.label}${tag3}`, namespace: row.namespace });
4112
4112
  }
4113
4113
  const field = row.field;
4114
4114
  if (field.type === "page") {
@@ -11910,9 +11910,9 @@ function splitCommand(command) {
11910
11910
  const heredoc = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[^\n]*\n/g;
11911
11911
  let at = 0;
11912
11912
  for (let m = heredoc.exec(command); m; m = heredoc.exec(command)) {
11913
- const tag2 = m[2];
11913
+ const tag3 = m[2];
11914
11914
  const bodyStart = m.index + m[0].length;
11915
- const end = new RegExp(`^[\\t ]*${tag2}[\\t ]*$`, "m");
11915
+ const end = new RegExp(`^[\\t ]*${tag3}[\\t ]*$`, "m");
11916
11916
  const rest = command.slice(bodyStart);
11917
11917
  const close = end.exec(rest);
11918
11918
  if (!close) continue;
@@ -16175,6 +16175,15 @@ function filterPayloadTools(payload, mode) {
16175
16175
  if (filtered.length === tools.length) return payload;
16176
16176
  return { ...record, tools: filtered };
16177
16177
  }
16178
+ function syncModeTools(pi, mode) {
16179
+ const current = pi.getActiveTools();
16180
+ const wanted = new Set(MODE_REGISTRY[mode].controlTools);
16181
+ if (ALL_MODE_TOOLS.every((name) => current.includes(name) === wanted.has(name))) return false;
16182
+ const kept = current.filter((name) => !ALL_MODE_TOOLS.includes(name) || wanted.has(name));
16183
+ const additions = [...wanted].filter((name) => !kept.includes(name));
16184
+ pi.setActiveTools([...kept, ...additions]);
16185
+ return true;
16186
+ }
16178
16187
  function ownerPresenceLine(owner, parked) {
16179
16188
  const parts = [];
16180
16189
  if (owner) parts.push(`an active ${owner.kind} owner is driving this session`);
@@ -16216,6 +16225,7 @@ var Gate = class {
16216
16225
  setExplicit(mode) {
16217
16226
  this.pendingExplicit = mode;
16218
16227
  this.sessionOverride = null;
16228
+ if (this.pi) syncModeTools(this.pi, mode);
16219
16229
  }
16220
16230
  /** Pin the session to a mode (/unipi:regular, goal stop): every later turn resolves to it. */
16221
16231
  setSessionMode(mode) {
@@ -16223,12 +16233,46 @@ var Gate = class {
16223
16233
  this.pendingExplicit = null;
16224
16234
  setSharedLongHorizonMode(mode);
16225
16235
  if (this.pi) {
16236
+ syncModeTools(this.pi, mode);
16226
16237
  emitEvent(this.pi, UNIPI_EVENTS.LONG_HORIZON_MODE_RESOLVED, {
16227
16238
  mode,
16228
16239
  source: "explicit"
16229
16240
  });
16230
16241
  }
16231
16242
  }
16243
+ /**
16244
+ * Owner transitions between turns sync the active set too (UNI-90): an owner
16245
+ * activated, resumed, or restored OUTSIDE before_agent_start — slash
16246
+ * commands, command runners (/unipi:continue, unipi:goal-start/resume),
16247
+ * crash recovery — flips its mode's tools on immediately, so a kickoff
16248
+ * delivered via agent.continue() (which never re-enters
16249
+ * before_agent_start) still has them. Wired from index's single owner
16250
+ * onChange closure; suspend/finish/clear deliberately sync nothing (the
16251
+ * next turn's resolution is authoritative, and a suspend-and-switch already
16252
+ * synced its new mode via setExplicit). Best-effort: never throws.
16253
+ */
16254
+ onOwnerChanged(event) {
16255
+ try {
16256
+ if (!this.pi) return;
16257
+ if (event.type !== "activated" && event.type !== "resumed" && event.type !== "restored") return;
16258
+ if (isChildProcess() && process.env.UNIPI_LH_ALLOW_CHILD !== "1") return;
16259
+ const owner = event.type === "restored" ? event.snapshot.active : event.owner;
16260
+ if (!owner) return;
16261
+ syncModeTools(this.pi, modeForOwnerKind(owner.kind));
16262
+ } catch {
16263
+ }
16264
+ }
16265
+ /** Fresh or resumed sessions start with the mode tools OFF; a restored
16266
+ * active owner re-syncs its mode's tools ON right after via
16267
+ * onOwnerChanged. Wired from index's single session_start handler so the
16268
+ * extension keeps exactly one. Best-effort: never throws. */
16269
+ resetModeTools() {
16270
+ try {
16271
+ if (!this.pi) return;
16272
+ syncModeTools(this.pi, "none");
16273
+ } catch {
16274
+ }
16275
+ }
16232
16276
  current() {
16233
16277
  return this.turn;
16234
16278
  }
@@ -16270,6 +16314,7 @@ var Gate = class {
16270
16314
  this.pi = pi;
16271
16315
  pi.on("before_agent_start", async (event) => {
16272
16316
  const state2 = await this.resolveForTurn(event.prompt);
16317
+ syncModeTools(pi, state2.mode);
16273
16318
  setSharedLongHorizonMode(state2.mode);
16274
16319
  emitEvent(pi, UNIPI_EVENTS.LONG_HORIZON_MODE_RESOLVED, {
16275
16320
  mode: state2.mode,
@@ -18431,6 +18476,7 @@ function longHorizon(pi) {
18431
18476
  const owner = new OwnerCoordinator({
18432
18477
  statePath: statePath2,
18433
18478
  onChange: (_snapshot, event) => {
18479
+ gate.onOwnerChanged(event);
18434
18480
  if (ownerEventClearsStash(event)) {
18435
18481
  stash.take();
18436
18482
  stashMetaState = { meta: void 0, kickoff: false };
@@ -18671,6 +18717,7 @@ function longHorizon(pi) {
18671
18717
  goalEstimateOn: () => loadSettings().goalProgress !== "off"
18672
18718
  }, machine, toolset);
18673
18719
  pi.on("session_start", () => {
18720
+ gate.resetModeTools();
18674
18721
  const restored = owner.restore();
18675
18722
  machine.restore();
18676
18723
  const goalStatus = machine.get()?.status;
@@ -20925,11 +20972,11 @@ function mergeDirs(src, dst, conflictsDir) {
20925
20972
  } catch {
20926
20973
  }
20927
20974
  }
20928
- function moveToConflicts(file, tag2) {
20975
+ function moveToConflicts(file, tag3) {
20929
20976
  if (!file) return;
20930
20977
  try {
20931
20978
  fs12.mkdirSync(CONFLICTS_DIR(), { recursive: true });
20932
- const name = `${tag2}-${path14.basename(file)}`;
20979
+ const name = `${tag3}-${path14.basename(file)}`;
20933
20980
  fs12.renameSync(file, path14.join(CONFLICTS_DIR(), name));
20934
20981
  } catch {
20935
20982
  }
@@ -22200,7 +22247,7 @@ import { visibleWidth as visibleWidth13 } from "@earendil-works/pi-tui";
22200
22247
  function tag(p, scope) {
22201
22248
  return p.bold(p.rgb(SCOPE_COLOR[scope], scope));
22202
22249
  }
22203
- function scopeLegend(p, scopes = ["s", "p", "g"]) {
22250
+ function scopeLegend(p, scopes = ["g", "p", "s"]) {
22204
22251
  return scopes.map((sc) => `${tag(p, sc)} ${p.fg("dim", SCOPE_WORD[sc])}`).join(" ");
22205
22252
  }
22206
22253
  function spaced(text) {
@@ -22269,7 +22316,7 @@ function legend(pc, items) {
22269
22316
  const rows = [];
22270
22317
  let cur = "";
22271
22318
  for (const i of items) {
22272
- const piece = `${p.rgb(i.color, "\u25A0")} ${muted(p, i.label)}`;
22319
+ const piece = `${p.rgb(i.color, "\u25A0")} ${i.scope ? `${tag(p, i.scope)} ` : ""}${muted(p, i.label)}`;
22273
22320
  if (cur && visibleWidth13(cur) + 3 + visibleWidth13(piece) > pc.width) {
22274
22321
  rows.push(fitTo(cur, pc.width));
22275
22322
  cur = piece;
@@ -22391,9 +22438,9 @@ function renderCompactor(pc) {
22391
22438
  out.push(section(pc, "saved by compaction", dim2(p, "tokens not re-sent \xB7 \u2248 money not spent")));
22392
22439
  out.push(
22393
22440
  ...tiles(pc, [
22394
- { label: "session", value: moneyShort(sess.dollars), sub: `${compact(sess.avoided)} tok \xB7 ${sess.count}\xD7`, scope: "s", stops: [[0, 160, 200], [0, 200, 240], [160, 230, 255]] },
22441
+ ...hist.global ? [{ label: "global", value: moneyShort(hist.global.dollars), sub: `${compact(hist.global.avoided)} tok \xB7 ${hist.global.count}\xD7`, scope: "g", stops: [CRAB.amber, CRAB.gold, CRAB.cream] }] : [],
22395
22442
  ...hist.project ? [{ label: "project", value: moneyShort(hist.project.dollars), sub: `${compact(hist.project.avoided)} tok \xB7 ${hist.project.count}\xD7`, scope: "p", stops: [[70, 150, 70], [120, 200, 120], [190, 240, 190]] }] : [],
22396
- ...hist.global ? [{ label: "global", value: moneyShort(hist.global.dollars), sub: `${compact(hist.global.avoided)} tok \xB7 ${hist.global.count}\xD7`, scope: "g", stops: [CRAB.amber, CRAB.gold, CRAB.cream] }] : []
22443
+ { label: "session", value: moneyShort(sess.dollars), sub: `${compact(sess.avoided)} tok \xB7 ${sess.count}\xD7`, scope: "s", stops: [[0, 160, 200], [0, 200, 240], [160, 230, 255]] }
22397
22444
  ])
22398
22445
  );
22399
22446
  if (best.avoided > 0) {
@@ -22522,8 +22569,8 @@ function renderMemory(pc) {
22522
22569
  const out = [];
22523
22570
  out.push(
22524
22571
  ...tiles(pc, [
22525
- { label: "project", value: compact(raw.projectCount), sub: raw.project, scope: "p" },
22526
22572
  { label: "global", value: compact(raw.total), sub: "all projects", scope: "g" },
22573
+ { label: "project", value: compact(raw.projectCount), sub: raw.project, scope: "p" },
22527
22574
  { label: "share", value: `${raw.total ? Math.round(raw.projectCount / raw.total * 100) : 0}%`, sub: "project / global" }
22528
22575
  ])
22529
22576
  );
@@ -23695,6 +23742,7 @@ function renderUsage(pc) {
23695
23742
  out.push(fitTo(`${p.rgb(MODEL_COLORS[0], "\u25CF")} ${p.bold(m[0])} ${dim2(p, "every token this month")} ${p.bold(useCost ? money(m[1]) : compact(m[2]))}`, pc.width));
23696
23743
  } else {
23697
23744
  out.push(fitTo(shareBar(p, raw.models.map((m, i) => ({ value: metric(m), color: MODEL_COLORS[i % MODEL_COLORS.length] })), pc.width, "\u2586"), pc.width));
23745
+ out.push("");
23698
23746
  const nameW = Math.min(26, Math.max(...raw.models.map((m) => m[0].length)) + 1);
23699
23747
  const valW = 14;
23700
23748
  const barW = Math.max(6, pc.width - nameW - valW - 5);
@@ -23721,7 +23769,7 @@ function colorMap(groups) {
23721
23769
  groups.forEach(([g], i) => m.set(g, GROUP_COLORS[i % GROUP_COLORS.length]));
23722
23770
  return m;
23723
23771
  }
23724
- function inventoryData(items, activeCount) {
23772
+ function inventoryData(items, activeCount, groupScopes) {
23725
23773
  const counts = /* @__PURE__ */ new Map();
23726
23774
  for (const i of items) counts.set(i.group, (counts.get(i.group) ?? 0) + 1);
23727
23775
  const groups = [...counts.entries()].sort((a, b) => b[1] - a[1]);
@@ -23730,14 +23778,14 @@ function inventoryData(items, activeCount) {
23730
23778
  const gb = groups.findIndex(([g]) => g === b.group);
23731
23779
  return ga - gb || a.name.localeCompare(b.name);
23732
23780
  });
23733
- const raw = { items: sorted, groups, activeCount };
23781
+ const raw = { items: sorted, groups, activeCount, groupScopes };
23734
23782
  return {
23735
23783
  total: { value: String(items.length) },
23736
23784
  groups: { value: groups.map(([g, n]) => `${n} ${g}`).join(", ") },
23737
23785
  raw: { value: "", raw }
23738
23786
  };
23739
23787
  }
23740
- function renderInventory(pc, noun, emptyHint) {
23788
+ function renderInventory(pc, noun, emptyHint, headerScope) {
23741
23789
  const raw = pc.data.raw?.raw;
23742
23790
  if (!raw || raw.items.length === 0) return empty(pc, `No ${noun} loaded`, emptyHint);
23743
23791
  const p = pc.paint;
@@ -23749,9 +23797,9 @@ function renderInventory(pc, noun, emptyHint) {
23749
23797
  out.push(...tiles(pc, headline));
23750
23798
  out.push("");
23751
23799
  out.push(fitTo(shareBar(p, raw.groups.map(([g2, n]) => ({ value: n, color: colors.get(g2) })), pc.width, "\u2586"), pc.width));
23752
- out.push(...legend(pc, raw.groups.map(([g2, n]) => ({ label: `${g2} ${n}`, color: colors.get(g2) }))));
23800
+ out.push(...legend(pc, raw.groups.map(([g2, n]) => ({ label: `${g2} ${n}`, color: colors.get(g2), scope: raw.groupScopes?.[g2] }))));
23753
23801
  out.push("");
23754
- out.push(section(pc, `all ${noun}`, dim2(p, "\u25CF active \u25CB off")));
23802
+ out.push(section(pc, `all ${noun}`, dim2(p, "\u25CF active \u25CB off"), headerScope));
23755
23803
  const items = raw.items.map((i) => {
23756
23804
  const c2 = colors.get(i.group) ?? [140, 140, 160];
23757
23805
  const mark = i.active === false ? p.rgb(c2, "\u25CB") : p.rgb(c2, "\u25CF");
@@ -23983,7 +24031,7 @@ function registerCoreGroups() {
23983
24031
  const activeCount = active ? items.filter((i) => i.active).length : void 0;
23984
24032
  return inventoryData(items, activeCount);
23985
24033
  },
23986
- render: (pc) => renderInventory(pc, "tools", "no tools registered")
24034
+ render: (pc) => renderInventory(pc, "tools", "no tools registered", "s")
23987
24035
  });
23988
24036
  infoRegistry.registerGroup({
23989
24037
  id: "skills",
@@ -24003,7 +24051,8 @@ function registerCoreGroups() {
24003
24051
  } catch {
24004
24052
  items = [];
24005
24053
  }
24006
- return inventoryData(items);
24054
+ const groupScopes = { project: "p", user: "g", agents: "g", unipi: "g", package: "g" };
24055
+ return inventoryData(items, void 0, groupScopes);
24007
24056
  },
24008
24057
  render: (pc) => renderInventory(pc, "skills", "add skills under ~/.pi/agent/skills or .pi/skills")
24009
24058
  });
@@ -28424,8 +28473,8 @@ function normalizeContextUsage(value) {
28424
28473
  return { tokens, contextWindow, percent };
28425
28474
  }
28426
28475
  function parseContextUsageXml(xml) {
28427
- const readNumber = (tag2) => {
28428
- const match = new RegExp(`<${tag2}>(.*?)</${tag2}>`, "i").exec(xml);
28476
+ const readNumber = (tag3) => {
28477
+ const match = new RegExp(`<${tag3}>(.*?)</${tag3}>`, "i").exec(xml);
28429
28478
  if (!match) return void 0;
28430
28479
  const raw = match[1]?.trim();
28431
28480
  if (raw === "null" || raw === "?") return null;
@@ -31194,8 +31243,8 @@ function elementToMarkdown(element) {
31194
31243
  }
31195
31244
  } else if (node.nodeType === 1) {
31196
31245
  const el = node;
31197
- const tag2 = el.tagName?.toLowerCase();
31198
- switch (tag2) {
31246
+ const tag3 = el.tagName?.toLowerCase();
31247
+ switch (tag3) {
31199
31248
  case "h1":
31200
31249
  lines3.push("");
31201
31250
  lines3.push(`# ${extractTextContent(el)}`);
@@ -31215,7 +31264,7 @@ function elementToMarkdown(element) {
31215
31264
  case "h5":
31216
31265
  case "h6":
31217
31266
  lines3.push("");
31218
- lines3.push(`${"#".repeat(parseInt(tag2[1]))} ${extractTextContent(el)}`);
31267
+ lines3.push(`${"#".repeat(parseInt(tag3[1]))} ${extractTextContent(el)}`);
31219
31268
  lines3.push("");
31220
31269
  break;
31221
31270
  case "p":
@@ -42098,11 +42147,11 @@ function getEnhancedUnipiItems(prefix, descriptionOverrides = /* @__PURE__ */ ne
42098
42147
  const color2 = PACKAGE_COLORS[pkg] ?? "";
42099
42148
  const label = PACKAGE_LABELS[pkg] ?? pkg;
42100
42149
  const desc = descriptionOverrides.get(cmd) ?? COMMAND_DESCRIPTIONS[cmd] ?? "";
42101
- const tag2 = colorize(color2, `[${label}]`);
42150
+ const tag3 = colorize(color2, `[${label}]`);
42102
42151
  return {
42103
42152
  value: cmd,
42104
42153
  label: cmd,
42105
- description: desc ? `${tag2} ${desc}` : tag2
42154
+ description: desc ? `${tag3} ${desc}` : tag3
42106
42155
  };
42107
42156
  });
42108
42157
  }
@@ -51936,8 +51985,8 @@ var PresetEditor = class {
51936
51985
  if (m.key === active.lead) tags.push("active lead");
51937
51986
  if (m.key === active.sidekick) tags.push("active sidekick");
51938
51987
  }
51939
- const tag2 = tags.length > 0 ? ` ${t.fg("dim", `(${tags.join(", ")})`)}` : "";
51940
- lines3.push(truncateToWidth39(` ${ptr} ${l} ${s} ${name}${tag2}`, Math.max(1, width - 1)));
51988
+ const tag3 = tags.length > 0 ? ` ${t.fg("dim", `(${tags.join(", ")})`)}` : "";
51989
+ lines3.push(truncateToWidth39(` ${ptr} ${l} ${s} ${name}${tag3}`, Math.max(1, width - 1)));
51941
51990
  }
51942
51991
  if (list.length > win) lines3.push(t.fg("dim", ` ${String(start + 1)}-${String(end)} of ${String(list.length)}`));
51943
51992
  lines3.push("");
@@ -52409,6 +52458,7 @@ function fusionExtension(pi) {
52409
52458
  let leadToolCalls = 0;
52410
52459
  let editNudgedThisTurn = false;
52411
52460
  let bashStreak = 0;
52461
+ let bashNudgedThisRun = false;
52412
52462
  const wakeLine = createSidekickWakeLine({
52413
52463
  isBusy: () => runtime?.isBusy() === true,
52414
52464
  progress: () => runtime?.progress(),
@@ -52531,6 +52581,7 @@ Handoffs: ${String(runtime.reports.size)} \xB7 runtime alive: ${String(runtime.i
52531
52581
  handler: async (_args, ctx) => ctx.ui.notify(savingsStats(ctx), "info")
52532
52582
  });
52533
52583
  pi.on("before_agent_start", (event, ctx) => {
52584
+ bashNudgedThisRun = false;
52534
52585
  syncFusionTools();
52535
52586
  if (active?.kind === "fusion") {
52536
52587
  event.systemPromptOptions.sections["fusion-lead-policy"] = leadPolicy(identity(ctx));
@@ -52569,8 +52620,10 @@ Handoffs: ${String(runtime.reports.size)} \xB7 runtime alive: ${String(runtime.i
52569
52620
  if (toolName !== "bash") return;
52570
52621
  const command = typeof event.input.command === "string" ? event.input.command : "";
52571
52622
  if (isTrivialShell(command)) return;
52623
+ if (bashNudgedThisRun) return;
52572
52624
  bashStreak += 1;
52573
52625
  if (bashStreak < BASH_NUDGE_EVERY) return;
52626
+ bashNudgedThisRun = true;
52574
52627
  const annotation = bashNudge(bashStreak);
52575
52628
  const synopsis = "Non-trivial shell work since last handoff";
52576
52629
  const content = [...event.content, { type: "text", text: annotation }];
@@ -52753,6 +52806,7 @@ ${String(result2.curation.lead.length)} lead \xB7 ${String(result2.curation.side
52753
52806
  stopRuntime();
52754
52807
  editNudgedThisTurn = false;
52755
52808
  bashStreak = 0;
52809
+ bashNudgedThisRun = false;
52756
52810
  modelBykey.clear();
52757
52811
  active = loadPreset(ctx.cwd ?? process.cwd()).preset.active;
52758
52812
  mirrorStartup(ctx.cwd ?? process.cwd());
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/updater",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Auto-updater, changelog browser, and readme browser for Unipi — checks npm registry, renders CHANGELOG.md and README.md files in TUI overlays",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "3.0.0-alpha.25"
37
+ "@pi-unipi/core": "3.0.0-alpha.26"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/utility",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Utility commands and tools for Pi coding agent — lifecycle, diagnostics, cache, analytics, display, batch execution",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -36,7 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@pi-unipi/core": "3.0.0-alpha.25"
39
+ "@pi-unipi/core": "3.0.0-alpha.26"
40
40
  },
41
41
  "devDependencies": {},
42
42
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/watchdog",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Jev watchdog for long-running tool calls and background tasks — judges stuck work via TypeSafe System One and kills or warns",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/background-tasks": "3.0.0-alpha.25",
36
- "@pi-unipi/core": "3.0.0-alpha.25"
35
+ "@pi-unipi/background-tasks": "3.0.0-alpha.26",
36
+ "@pi-unipi/core": "3.0.0-alpha.26"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/web-api",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Web search, read, and summarize tools with provider-based backend selection for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -31,7 +31,7 @@
31
31
  "README.md"
32
32
  ],
33
33
  "dependencies": {
34
- "@pi-unipi/core": "3.0.0-alpha.25",
34
+ "@pi-unipi/core": "3.0.0-alpha.26",
35
35
  "defuddle": "^0.18.1",
36
36
  "linkedom": "^0.18.12",
37
37
  "wreq-js": "^2.3.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/workflow",
3
- "version": "3.0.0-alpha.25",
3
+ "version": "3.0.0-alpha.26",
4
4
  "description": "Plan mode and permission modes for the Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -27,7 +27,7 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@pi-unipi/core": "3.0.0-alpha.25"
30
+ "@pi-unipi/core": "3.0.0-alpha.26"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@earendil-works/pi-ai": "^0.87.1",