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

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 (45) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/package.json +26 -24
  3. package/packages/app-bridge/package.json +2 -2
  4. package/packages/app-bridge/src/bridge.ts +75 -6
  5. package/packages/app-bridge/src/snapshot.ts +26 -0
  6. package/packages/app-bridge/src/wire.ts +14 -2
  7. package/packages/ask-user/package.json +2 -2
  8. package/packages/autocomplete/package.json +1 -1
  9. package/packages/autocomplete/src/constants.ts +2 -0
  10. package/packages/background-tasks/package.json +2 -2
  11. package/packages/btw/package.json +2 -2
  12. package/packages/compactor/package.json +3 -3
  13. package/packages/core/command-echo.ts +47 -1
  14. package/packages/core/constants.ts +1 -0
  15. package/packages/core/index.ts +1 -0
  16. package/packages/core/package.json +1 -1
  17. package/packages/core/src/skill-conditions.ts +68 -0
  18. package/packages/core/src/work/tray.ts +70 -9
  19. package/packages/dream/package.json +2 -2
  20. package/packages/footer/package.json +3 -3
  21. package/packages/footer/src/index.ts +1 -1
  22. package/packages/fusion/package.json +4 -4
  23. package/packages/info-screen/package.json +2 -2
  24. package/packages/input-shortcuts/package.json +2 -2
  25. package/packages/kanboard/package.json +7 -7
  26. package/packages/long-horizon/package.json +2 -2
  27. package/packages/mcp/package.json +2 -2
  28. package/packages/memory/package.json +3 -3
  29. package/packages/notify/package.json +2 -2
  30. package/packages/skill-registry/index.ts +16 -4
  31. package/packages/skill-registry/package.json +2 -2
  32. package/packages/subagents/package.json +3 -3
  33. package/packages/subagents/src/index.ts +1 -1
  34. package/packages/unipi/bundled.js +1268 -588
  35. package/packages/updater/package.json +2 -2
  36. package/packages/utility/package.json +2 -2
  37. package/packages/utility/skills/browser/SKILL.md +73 -0
  38. package/packages/utility/skills/cron/SKILL.md +131 -0
  39. package/packages/utility/src/browser/state.ts +218 -0
  40. package/packages/utility/src/cron/index.ts +323 -0
  41. package/packages/utility/src/cron/settings.ts +185 -0
  42. package/packages/utility/src/index.ts +41 -0
  43. package/packages/watchdog/package.json +3 -3
  44. package/packages/web-api/package.json +2 -2
  45. package/packages/workflow/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ 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.43] — 2026-10-16
10
+
11
+ ### Added
12
+ - Cron: scheduled, unattended pi runs on your PC, run by the UniPi host (needs UniPi host 0.5.1). `skill:cron` sets jobs up from a plain request ("every weekday at 18:30 summarize my inbox"), with plain-language schedules, per-job notifications (ntfy, Gotify, Telegram) and an optional wake-up message to the session that set it. `/unipi:cron` lists jobs, their runs and results, and runs, stops, enables or pauses them. Cron settings are in `/unipi:settings`. (UNI-288, UNI-314–318)
13
+ - `/skill:craft-skill` and `/skill:web` are listed as normal skills. (UNI-307)
14
+
15
+ ### Fixed
16
+ - The UniPi app now gets argument suggestions for every extension's commands (e.g. `/usage `), not only UniPi's own. (UNI-304)
17
+ - The app's model list stayed partial (only openrouter) when a provider such as OmniRoute registered after the app connected; the list is now pushed again when it changes.
18
+ - Removing a steered message from the app no longer stops the run. (UNI-305)
19
+ - `/reload` from the app reloads pi instead of being sent as a message. (UNI-308)
20
+
21
+ ## [3.0.0-alpha.42] — 2026-10-16
22
+
23
+ ### Added
24
+ - `skill:browser`: the agent uses the PC's browser, the one you see in the UniPi app, with `agent-browser`. While the browser is on, UniPi points agent-browser at it (`AGENT_BROWSER_CDP`), so `agent-browser open <url>` just works, with no start or connect step. The browser is CloakBrowser, a stealthy Chromium shown only in the app; the agent's clicks and typing look human, and you can take over. Needs UniPi host 0.5.0. (UNI-295, UNI-303)
25
+ - The agent sees `skill:browser` and a one-line note only while the PC's browser is on. Turning it off hides both from the next prompt, and `/skill:browser` explains how to turn it on. Any skill can now be shown only when a condition holds (`registerSkillCondition`). (UNI-300)
26
+ - The work tray has a hint again: while it has items, one dim line under the input reads, for example, "Background tasks 1 running · Subagents 2 · ↓ open". (UNI-291)
27
+ - App: "Start from here" on one of your messages puts that message, with its images, back in the app's input, like `/tree` in the terminal. (UNI-290)
28
+
9
29
  ## [3.0.0-alpha.41] — 2026-10-13
10
30
 
11
31
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.43",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -70,7 +70,9 @@
70
70
  "packages/notify/skills",
71
71
  "packages/kanboard/skills",
72
72
  "packages/updater/skills",
73
- "packages/subagents/skills"
73
+ "packages/subagents/skills",
74
+ "packages/web-api/skills",
75
+ "packages/dream/skills"
74
76
  ]
75
77
  },
76
78
  "peerDependencies": {
@@ -80,28 +82,28 @@
80
82
  "typebox": "^1.1.38"
81
83
  },
82
84
  "dependencies": {
83
- "@pi-unipi/app-bridge": "3.0.0-alpha.41",
84
- "@pi-unipi/ask-user": "3.0.0-alpha.41",
85
- "@pi-unipi/background-tasks": "3.0.0-alpha.41",
86
- "@pi-unipi/btw": "3.0.0-alpha.41",
87
- "@pi-unipi/command-enchantment": "3.0.0-alpha.41",
88
- "@pi-unipi/compactor": "3.0.0-alpha.41",
89
- "@pi-unipi/core": "3.0.0-alpha.41",
90
- "@pi-unipi/dream": "3.0.0-alpha.41",
91
- "@pi-unipi/footer": "3.0.0-alpha.41",
92
- "@pi-unipi/fusion": "3.0.0-alpha.41",
93
- "@pi-unipi/info-screen": "3.0.0-alpha.41",
94
- "@pi-unipi/input-shortcuts": "3.0.0-alpha.41",
95
- "@pi-unipi/kanboard": "3.0.0-alpha.41",
96
- "@pi-unipi/mcp": "3.0.0-alpha.41",
97
- "@pi-unipi/memory": "3.0.0-alpha.41",
98
- "@pi-unipi/notify": "3.0.0-alpha.41",
99
- "@pi-unipi/skill-registry": "3.0.0-alpha.41",
100
- "@pi-unipi/subagents": "3.0.0-alpha.41",
101
- "@pi-unipi/updater": "3.0.0-alpha.41",
102
- "@pi-unipi/utility": "3.0.0-alpha.41",
103
- "@pi-unipi/web-api": "3.0.0-alpha.41",
104
- "@pi-unipi/workflow": "3.0.0-alpha.41",
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",
105
107
  "@resvg/resvg-wasm": "^2.6.2",
106
108
  "beautiful-mermaid": "^1.1.3"
107
109
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/app-bridge",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.43",
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.41"
34
+ "@pi-unipi/core": "3.0.0-alpha.43"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -14,7 +14,7 @@ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync
14
14
  import { homedir } from "node:os";
15
15
  import { join, relative, resolve as resolvePath } from "node:path";
16
16
  import { DialogHub, wrapUi } from "./dialogs.js";
17
- import { ENTRIES_BUDGET, HELLO_ENTRIES_BUDGET, LINE_BUDGET, fitLine, historyPage, jsonBytes, phoneSafe, snapshotEntries, clipText, editedAway, wantedEntry } from "./snapshot.js";
17
+ import { ENTRIES_BUDGET, HELLO_ENTRIES_BUDGET, LINE_BUDGET, fitLine, historyPage, jsonBytes, phoneSafe, snapshotEntries, clipText, editedAway, wantedEntry, treeGoRestore, type TreeGoRestore } from "./snapshot.js";
18
18
  import { searchSession } from "./search.js";
19
19
  import {
20
20
  BRIDGE_PROTOCOL,
@@ -658,6 +658,23 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
658
658
  }
659
659
  };
660
660
 
661
+ // The model list changes after `hello`: providers that register late
662
+ // (pi-omniroute-bridge refreshes its provider in session_start, after this
663
+ // bridge's hello) or a /model in the TUI that loads more. A phone that
664
+ // connected early kept the first, partial list (only openrouter) until
665
+ // something forced a new hello. Push a `models` line whenever the list
666
+ // differs from the last one sent, and check a few times after start.
667
+ let lastModelsKey = "";
668
+ const modelsKey = (list: ModelInfo[]) => list.map((m) => `${m.provider}/${m.id}`).join("\n");
669
+ const pushModelsIfChanged = () => {
670
+ if (!ctx || clients.size === 0) return;
671
+ const list = models();
672
+ const key = modelsKey(list);
673
+ if (key === lastModelsKey) return;
674
+ lastModelsKey = key;
675
+ send({ t: "models", models: list } as OutMsg);
676
+ };
677
+
661
678
  // UNI-222: long-horizon progress (sticky LH_PROGRESS on the core bus).
662
679
  const lhProgress = (): LhProgressInfo | undefined => bus.get(UNIPI_EVENTS.LH_PROGRESS) as LhProgressInfo | undefined;
663
680
  // UNI-269: the working mode (sticky LH_STATE — the footer's "Graph Mode").
@@ -675,7 +692,11 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
675
692
  streaming: streaming ? { id: streaming.id, role: "assistant" as const, content: phoneSafe(streaming.content) as unknown[] } : undefined,
676
693
  tools: [...tools.values()].map((t) => ({ ...t, text: t.text ? clipText(t.text, 8 * 1024) : t.text })),
677
694
  commands: commands().map((x) => ({ ...x, description: x.description ? clipText(x.description, 160) : x.description })),
678
- models: models(),
695
+ models: (() => {
696
+ const list = models();
697
+ lastModelsKey = modelsKey(list);
698
+ return list;
699
+ })(),
679
700
  dialogs: hub.list(),
680
701
  queue: queueView(),
681
702
  fusion: fusionPreset(),
@@ -1121,12 +1142,32 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1121
1142
  if (!c) return fail("pi is gone.");
1122
1143
  const i = tuiQueue.findIndex((q) => q.id === id);
1123
1144
  if (i < 0) return fail("That queued message is gone.");
1124
- const [target] = tuiQueue.splice(i, 1);
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.
1155
+ if (tools.size > 0) {
1156
+ const deadline = Date.now() + 4_000;
1157
+ while (tools.size > 0 && Date.now() < deadline) await sleep(100);
1158
+ if (tools.size > 0) return fail("That message was already handed to pi — it can't be taken back while a tool is running.");
1159
+ }
1160
+ // The wait above may have let pi drain this very item into a real turn
1161
+ // (the message_start handler below removes it from tuiQueue once pi
1162
+ // actually sends it) — re-check instead of operating on a stale index.
1163
+ const liveIndex = tuiQueue.findIndex((q) => q.id === id);
1164
+ 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);
1125
1166
  const action = apply(target!) ?? undefined;
1126
1167
  // Keep the edited/kept target in its original slot (promote: first).
1127
1168
  const rest = tuiQueue.splice(0, tuiQueue.length);
1128
1169
  if (action === "promote") rest.unshift(target!);
1129
- else if (action !== "drop") rest.splice(i, 0, target!);
1170
+ else if (action !== "drop") rest.splice(liveIndex, 0, target!);
1130
1171
  if (!c.isIdle()) {
1131
1172
  await abortKeepingEditor(c);
1132
1173
  const deadline = Date.now() + 10_000;
@@ -1507,14 +1548,36 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1507
1548
  }
1508
1549
  return;
1509
1550
  }
1551
+ case "reload": {
1552
+ // UNI-308: `/reload` must work from the app, not fall through to a plain
1553
+ // prompt. `ctx.reload()` (ExtensionCommandContext) is the same call the
1554
+ // terminal's built-in `/reload` makes.
1555
+ if (!(await ensureIdle(msg.force))) return write(sock, { t: "error", message: "pi is busy. Stop the current run first.", code: "busy", ref: msg.ref });
1556
+ try {
1557
+ await runCommandOp(async (cctx) => {
1558
+ await cctx.reload();
1559
+ });
1560
+ ack();
1561
+ } catch (error) {
1562
+ fail(error instanceof Error ? error.message : String(error));
1563
+ }
1564
+ return;
1565
+ }
1510
1566
  case "tree_go": {
1511
1567
  if (!(await ensureIdle(msg.force))) return write(sock, { t: "error", message: "pi is busy. Stop the current run first.", code: "busy", ref: msg.ref });
1512
1568
  try {
1569
+ let restore: TreeGoRestore | undefined;
1513
1570
  await runCommandOp(async (cctx) => {
1571
+ // Read before navigating: the target is a user message → pi moves
1572
+ // the leaf to just before it, and (like /tree) its text goes back
1573
+ // to the composer (UNI-290).
1574
+ const target = cctx.sessionManager.getEntry?.(msg.id);
1514
1575
  const result = await cctx.navigateTree(msg.id, { summarize: msg.summarize });
1515
1576
  if (result.cancelled) throw new Error("Cancelled");
1577
+ const leaf = cctx.sessionManager.getLeafId?.() ?? null;
1578
+ restore = treeGoRestore(target, (result as { editorText?: unknown }).editorText, leaf);
1516
1579
  });
1517
- ack();
1580
+ write(sock, { t: "ack", ref: msg.ref, ...(restore ?? {}) });
1518
1581
  } catch (error) {
1519
1582
  fail(error instanceof Error ? error.message : String(error));
1520
1583
  }
@@ -1815,6 +1878,8 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1815
1878
  setRemoteDialogs(hub);
1816
1879
  listen();
1817
1880
  writeRecord();
1881
+ // Late providers (registered after this hello): re-check the model list.
1882
+ for (const ms of [1500, 5000, 15000]) setTimeout(() => safe(pushModelsIfChanged)(), ms).unref?.();
1818
1883
  if (event?.reason && event.reason !== "startup") {
1819
1884
  streaming = undefined;
1820
1885
  tools.clear();
@@ -1981,6 +2046,7 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
1981
2046
  });
1982
2047
 
1983
2048
  on("agent_start", safe(() => {
2049
+ pushModelsIfChanged();
1984
2050
  // A fresh run after a phone Stop: the queue delivers after THIS one.
1985
2051
  releaseHold();
1986
2052
  running = true;
@@ -2148,7 +2214,10 @@ export function createBridge(pi: ExtensionAPI, deps: BridgeDeps = defaultDeps) {
2148
2214
 
2149
2215
  // The preset's `active` changes with the model (Fusion leaves fusion mode
2150
2216
  // on a /model switch), so it rides along (UNI-212).
2151
- on("model_select", safe(() => stateWithFusion()));
2217
+ on("model_select", safe(() => {
2218
+ stateWithFusion();
2219
+ pushModelsIfChanged();
2220
+ }));
2152
2221
  on("thinking_level_select", safe(() => send({ t: "state", ...runState() })));
2153
2222
  on("session_compact", safe(() => {
2154
2223
  if (!ctx) return;
@@ -273,3 +273,29 @@ export function historyPage(branch: readonly unknown[], before: string, budget =
273
273
  }
274
274
  return { entries: out.reverse(), more };
275
275
  }
276
+
277
+ /** UNI-290: what goes back into the app's composer after `tree_go` to a user
278
+ * message — its text (pi's own `editorText` when the runtime passes it on,
279
+ * else read off the entry, joined the same way pi does) and its images as
280
+ * the usual phone-safe refs (inline thumbnail or a `mediaRef`). */
281
+ export type TreeGoRestore = { restore: { text: string; images?: Array<{ mime: string; data?: string; mediaRef?: string; bytes?: number }> } };
282
+
283
+ /** The ack extras for a `tree_go` that landed: only when `target` is a user
284
+ * message and pi really moved the leaf off it (to its parent) — a no-op
285
+ * navigation (already there) restores nothing. */
286
+ export function treeGoRestore(target: unknown, editorText: unknown, leafAfter: string | null): TreeGoRestore | undefined {
287
+ const e = target as { id?: string; type?: string; message?: { role?: string; content?: unknown } } | null | undefined;
288
+ if (!e || e.type !== "message" || e.message?.role !== "user") return undefined;
289
+ if (leafAfter !== null && leafAfter === e.id) return undefined;
290
+ const content = e.message.content;
291
+ const parts = Array.isArray(content) ? (content as Array<{ type?: string; text?: unknown }>) : [];
292
+ const fromEntry = typeof content === "string" ? content : parts.filter((p) => p?.type === "text" && typeof p.text === "string").map((p) => p.text as string).join("");
293
+ const text = typeof editorText === "string" ? editorText : fromEntry;
294
+ const images = parts
295
+ .filter((p) => p?.type === "image")
296
+ .map((p) => phoneSafe(p) as { mime?: string; data?: string; mediaRef?: string; bytes?: number } | null)
297
+ .filter((p): p is { mime?: string; data?: string; mediaRef?: string; bytes?: number } => !!p && (typeof p.data === "string" || typeof p.mediaRef === "string"))
298
+ .map((p) => ({ mime: p.mime ?? "image/*", ...(p.data ? { data: p.data } : {}), ...(p.mediaRef ? { mediaRef: p.mediaRef } : {}), ...(typeof p.bytes === "number" ? { bytes: p.bytes } : {}) }));
299
+ if (!text && !images.length) return undefined;
300
+ return { restore: { text: clipText(text, TEXT_CLIP), ...(images.length ? { images } : {}) } };
301
+ }
@@ -251,6 +251,8 @@ export type OutMsg =
251
251
  dream?: DreamStatusInfo;
252
252
  }
253
253
  | ({ t: "state" } & Partial<RunState>)
254
+ /** The model list changed after `hello` (a late provider, a /model in the TUI). */
255
+ | { t: "models"; models: ModelInfo[] }
254
256
  | ({ t: "session"; reason: string } & SessionInfo)
255
257
  | { t: "entry"; entry: unknown }
256
258
  | { t: "msg_start"; id: string; role: string }
@@ -275,8 +277,11 @@ export type OutMsg =
275
277
  | { t: "btw_list"; pages: BtwListPage[]; complete?: true; ref?: string }
276
278
  /** `code: "busy"`: new/resume/fork/tree_go while pi is running — the phone may retry with `force:true`. */
277
279
  | { t: "error"; message: string; ref?: string; code?: "busy" }
278
- /** `as: "command"`: the prompt ran an extension command (no chat message follows). */
279
- | { t: "ack"; ref?: string; as?: "command" }
280
+ /** `as: "command"`: the prompt ran an extension command (no chat message follows).
281
+ * `restore` (UNI-290, `tree_go` only): the target was a user message, so pi
282
+ * moved the leaf to just before it — its text (and images, phone-safe refs)
283
+ * go back into the phone's composer, like /tree puts it in the TUI editor. */
284
+ | { t: "ack"; ref?: string; as?: "command"; restore?: { text: string; images?: Array<{ mime: string; data?: string; mediaRef?: string; bytes?: number }> } }
280
285
  /** The session is being replaced (new/resume/fork/reload): reconnect to get the new session's hello. */
281
286
  | { t: "reconnect"; reason: string }
282
287
  /** A page of older entries (oldest first) ending right before `before`. */
@@ -410,6 +415,10 @@ export type InMsg =
410
415
  | { t: "tree"; ref?: string }
411
416
  /** Navigate to a different point in the tree. `force`: abort the current run first. */
412
417
  | { t: "tree_go"; id: string; summarize?: boolean; force?: boolean; ref?: string }
418
+ /** UNI-308: `/reload` sent from the app — re-reads extensions, skills, prompts, context files
419
+ * via `ctx.reload()` (the same thing the terminal's `/reload` built-in does). `force`: abort
420
+ * the current run first, like the other session ops. */
421
+ | { t: "reload"; force?: boolean; ref?: string }
413
422
  /** Fetches the full bytes behind an image `mediaRef` the bridge sent in a
414
423
  * `media`-omitted placeholder (see `phoneSafe`): replies with one or more
415
424
  * `media_chunk` (≤ 700 KB of base64 per chunk) ending in `done:true`, or a
@@ -498,6 +507,7 @@ const IN_TYPES = new Set([
498
507
  "session_fork",
499
508
  "tree",
500
509
  "tree_go",
510
+ "reload",
501
511
  "session_rename",
502
512
  "media",
503
513
  "file_share",
@@ -591,6 +601,8 @@ export function parseIn(line: string): InMsg | { bad: string; ref?: string } | u
591
601
  case "tree_go":
592
602
  if (typeof m.id !== "string" || !m.id) return { bad: "tree_go.id must be a string", ref };
593
603
  return { t: "tree_go", id: m.id, summarize: m.summarize === true, force: m.force === true, ref };
604
+ case "reload":
605
+ return { t: "reload", force: m.force === true, ref };
594
606
  case "session_rename":
595
607
  if (typeof m.name !== "string") return { bad: "session_rename.name must be a string", ref };
596
608
  return { t: "session_rename", name: m.name.slice(0, 200), ref };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.43",
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.41"
45
+ "@pi-unipi/core": "3.0.0-alpha.43"
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.41",
3
+ "version": "3.0.0-alpha.43",
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",
@@ -77,6 +77,7 @@ export const COMMAND_REGISTRY: Record<string, string> = {
77
77
  "unipi:dream": "utility",
78
78
  "unipi:answer": "utility",
79
79
  "unipi:summarize": "utility",
80
+ "unipi:cron": "utility",
80
81
  "unipi:move": "utility",
81
82
 
82
83
  // skill-registry
@@ -165,6 +166,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
165
166
  "unipi:doctor": "Check UniPi's runtime: config, model cache, Decision Model, skills",
166
167
  "unipi:answer": "Answer the last reply — fixed input under the scrollable reply, per question, or web form",
167
168
  "unipi:summarize": "Summarize your last reply again (answer first, then findings, open items, questions). Add text to ask about it",
169
+ "unipi:cron": "Scheduled pi runs on this PC — jobs, next runs, results; run, stop, enable, pause",
168
170
  "unipi:skills": "Manage skills — on/off per project, listed or not, the skill vault",
169
171
  "unipi:move": "Re-link sessions, trust, worktrees, boards and memories after moving this project",
170
172
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/background-tasks",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.43",
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.41",
13
+ "@pi-unipi/core": "3.0.0-alpha.43",
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.41",
3
+ "version": "3.0.0-alpha.43",
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.41"
40
+ "@pi-unipi/core": "3.0.0-alpha.43"
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.41",
3
+ "version": "3.0.0-alpha.43",
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.41",
37
- "@pi-unipi/info-screen": "3.0.0-alpha.41",
36
+ "@pi-unipi/core": "3.0.0-alpha.43",
37
+ "@pi-unipi/info-screen": "3.0.0-alpha.43",
38
38
  "@earendil-works/pi-agent-core": "^0.87.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -12,7 +12,7 @@
12
12
  * failures never block the command itself.
13
13
  */
14
14
 
15
- import type { ExtensionAPI, ExtensionCommandContext, RegisteredCommand } from "@earendil-works/pi-coding-agent";
15
+ import { ExtensionRunner, type ExtensionAPI, type ExtensionCommandContext, type RegisteredCommand } from "@earendil-works/pi-coding-agent";
16
16
  import { Text } from "@earendil-works/pi-tui";
17
17
 
18
18
  export const COMMAND_ECHO_TYPE = "unipi-command-echo";
@@ -96,6 +96,48 @@ export function commandArgCompleters(): Map<string, ArgCompleter> {
96
96
  return (g[COMPLETERS_KEY] ??= new Map<string, ArgCompleter>());
97
97
  }
98
98
 
99
+ /**
100
+ * UNI-304: `commandArgCompleters()` used to only ever hold the commands
101
+ * this module's own `withCommandEcho` wrapper saw (UniPi's commands). Other
102
+ * extensions' commands — pi-omniroute-bridge's `/usage`, a future `/skill:*`
103
+ * completer, anything not registered through `withCommandEcho` — never got
104
+ * an entry, so the app (which can only ask the bridge, never pi's live
105
+ * in-memory command registry) got nothing for them.
106
+ *
107
+ * `ExtensionRunner.resolveRegisteredCommands()` is where pi itself resolves
108
+ * every extension's registered command (with its real `getArgumentCompletions`,
109
+ * see `RegisteredCommand`) into the de-duplicated list the TUI's own
110
+ * autocomplete reads (`interactive-mode.js` calls `getRegisteredCommands()`,
111
+ * which calls this). It runs on every keystroke in a command's argument area,
112
+ * so it is already a safe, repeatedly-called hook. Patching it here — once,
113
+ * idempotently, on the shared class — is the least invasive way to see every
114
+ * extension's completer: no extension author needs to go through
115
+ * `withCommandEcho`, and pi needs no API change. UniPi's `unipi/index.ts`
116
+ * calls `withCommandEcho` (which calls this) before any other module loads,
117
+ * so the patch is in place before any extension — ours or a third party's —
118
+ * registers a single command.
119
+ */
120
+ let runnerPatched = false;
121
+ export function patchExtensionRunnerCommandCompleters(): void {
122
+ if (runnerPatched) return;
123
+ runnerPatched = true;
124
+ const proto = ExtensionRunner.prototype as unknown as {
125
+ resolveRegisteredCommands: () => Array<{ invocationName: string; name: string; getArgumentCompletions?: ArgCompleter }>;
126
+ };
127
+ const original = proto.resolveRegisteredCommands;
128
+ if (typeof original !== "function") return; // Unknown/future pi build: no-op rather than throw.
129
+ proto.resolveRegisteredCommands = function (this: unknown, ...args: unknown[]) {
130
+ const resolved = original.apply(this, args as []);
131
+ const map = commandArgCompleters();
132
+ for (const command of resolved) {
133
+ if (typeof command.getArgumentCompletions === "function") {
134
+ map.set(command.invocationName, command.getArgumentCompletions.bind(command));
135
+ }
136
+ }
137
+ return resolved;
138
+ } as typeof original;
139
+ }
140
+
99
141
  /** Never echo `/name` (internal, programmatic commands). Call before or after registering. */
100
142
  export function silenceCommandEcho(name: string): void {
101
143
  silentSet().add(name.replace(/^\//, ""));
@@ -114,6 +156,10 @@ export function isSilentEcho(text: unknown): boolean {
114
156
  }
115
157
 
116
158
  export function withCommandEcho(pi: ExtensionAPI): ExtensionAPI {
159
+ // UNI-304: patch once, before any command is registered, so every
160
+ // extension's own completer (not just ones that happen to go through this
161
+ // wrapper) ends up in commandArgCompleters().
162
+ patchExtensionRunnerCommandCompleters();
117
163
  try {
118
164
  pi.registerEntryRenderer<{ text?: string }>(
119
165
  COMMAND_ECHO_TYPE,
@@ -110,6 +110,7 @@ export const UTILITY_COMMANDS = {
110
110
  ANSWER: "answer",
111
111
  SUMMARIZE: "summarize",
112
112
  MOVE: "move",
113
+ CRON: "cron",
113
114
  } as const;
114
115
 
115
116
  /** Ask-user tool names */
@@ -54,3 +54,4 @@ export * from "./src/hints/store.js";
54
54
  export { CRAB_14_LINES_256, CRAB_14_LINES_TRUECOLOR, CRAB_22_LINES_256, CRAB_22_LINES_TRUECOLOR } from "./src/hints/crab-data.js";
55
55
  export * from "./harness-messages.js";
56
56
  export * from "./remote-dialogs.js";
57
+ export * from "./src/skill-conditions.js";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "3.0.0-alpha.41",
3
+ "version": "3.0.0-alpha.43",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @pi-unipi/core — skill visibility conditions (UNI-300)
3
+ *
4
+ * A generic, tiny gate any module can use to hide one of its bundled
5
+ * skills from the model when some runtime fact says it can't be used
6
+ * right now (e.g. the PC's browser is off). Skill-registry (and anything
7
+ * else shaping `options.skills`) calls `skillConditionsPass` per skill
8
+ * name; a module that owns the fact calls `registerSkillCondition` once
9
+ * at load time with a cheap, synchronous predicate.
10
+ *
11
+ * Deliberately process-global like the rest of core's small registries
12
+ * (command-runner, settings): modules load once per process, and this
13
+ * needs no session/ctx threading. A condition must never throw or block;
14
+ * callers treat a throwing condition as "false" (hidden) defensively but
15
+ * conditions should fail open themselves when unsure.
16
+ */
17
+
18
+ export type SkillCondition = () => boolean;
19
+
20
+ const conditions = new Map<string, SkillCondition>();
21
+
22
+ /** Registers (or replaces) the visibility condition for a skill name. */
23
+ export function registerSkillCondition(skillName: string, condition: SkillCondition): void {
24
+ conditions.set(skillName, condition);
25
+ }
26
+
27
+ export function unregisterSkillCondition(skillName: string): void {
28
+ conditions.delete(skillName);
29
+ }
30
+
31
+ /** True when the skill has no registered condition, or its condition returns true. A throwing
32
+ * condition counts as false (hidden) — safer than crashing the turn to show a broken skill. */
33
+ export function skillConditionPasses(skillName: string): boolean {
34
+ const condition = conditions.get(skillName);
35
+ if (!condition) return true;
36
+ try {
37
+ return condition();
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /** Reason a skill is currently hidden (for a clear `/skill:name` refusal), or undefined when
44
+ * it isn't gated by a condition, or its condition is registered but no reason was given. */
45
+ export type SkillConditionReason = () => string | undefined;
46
+
47
+ const reasons = new Map<string, SkillConditionReason>();
48
+
49
+ /** Optional: a human reason shown when `/skill:name` is blocked because the condition is false. */
50
+ export function registerSkillConditionReason(skillName: string, reason: SkillConditionReason): void {
51
+ reasons.set(skillName, reason);
52
+ }
53
+
54
+ export function skillConditionReason(skillName: string): string | undefined {
55
+ const fn = reasons.get(skillName);
56
+ if (!fn) return undefined;
57
+ try {
58
+ return fn();
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ /** Test hook. */
65
+ export function resetSkillConditionsForTests(): void {
66
+ conditions.clear();
67
+ reasons.clear();
68
+ }