@pi-unipi/unipi 3.0.0-alpha.34 → 3.0.0-alpha.35

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,11 @@ 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.35] — 2026-10-08
10
+
11
+ ### Added
12
+ - `notify`: **end-to-end encrypted phone push.** With the per-pairing key the UniPi host creates (`~/.unipi/app-host/notify-key`), app pushes are sealed with AES-256-GCM (`unipi1:` envelope): the ntfy server — now self-hosted — only sees a neutral "UniPi" title and ciphertext; the deep link carries no content. Without a key nothing changes. Pushes also go to the phone's registered UnifiedPush endpoints. (UNI-161)
13
+
9
14
  ## [3.0.0-alpha.34] — 2026-10-08
10
15
 
11
16
  Everything the UniPi phone app (unipi-app 0.3.0) needs from pi.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "3.0.0-alpha.34",
3
+ "version": "3.0.0-alpha.35",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -83,27 +83,27 @@
83
83
  "typebox": "^1.1.38"
84
84
  },
85
85
  "dependencies": {
86
- "@pi-unipi/app-bridge": "3.0.0-alpha.34",
87
- "@pi-unipi/ask-user": "3.0.0-alpha.34",
88
- "@pi-unipi/background-tasks": "3.0.0-alpha.34",
89
- "@pi-unipi/btw": "3.0.0-alpha.34",
90
- "@pi-unipi/command-enchantment": "3.0.0-alpha.34",
91
- "@pi-unipi/compactor": "3.0.0-alpha.34",
92
- "@pi-unipi/core": "3.0.0-alpha.34",
93
- "@pi-unipi/footer": "3.0.0-alpha.34",
94
- "@pi-unipi/fusion": "3.0.0-alpha.34",
95
- "@pi-unipi/info-screen": "3.0.0-alpha.34",
96
- "@pi-unipi/input-shortcuts": "3.0.0-alpha.34",
97
- "@pi-unipi/kanboard": "3.0.0-alpha.34",
98
- "@pi-unipi/mcp": "3.0.0-alpha.34",
99
- "@pi-unipi/memory": "3.0.0-alpha.34",
100
- "@pi-unipi/notify": "3.0.0-alpha.34",
101
- "@pi-unipi/skill-registry": "3.0.0-alpha.34",
102
- "@pi-unipi/subagents": "3.0.0-alpha.34",
103
- "@pi-unipi/updater": "3.0.0-alpha.34",
104
- "@pi-unipi/utility": "3.0.0-alpha.34",
105
- "@pi-unipi/web-api": "3.0.0-alpha.34",
106
- "@pi-unipi/workflow": "3.0.0-alpha.34"
86
+ "@pi-unipi/app-bridge": "3.0.0-alpha.35",
87
+ "@pi-unipi/ask-user": "3.0.0-alpha.35",
88
+ "@pi-unipi/background-tasks": "3.0.0-alpha.35",
89
+ "@pi-unipi/btw": "3.0.0-alpha.35",
90
+ "@pi-unipi/command-enchantment": "3.0.0-alpha.35",
91
+ "@pi-unipi/compactor": "3.0.0-alpha.35",
92
+ "@pi-unipi/core": "3.0.0-alpha.35",
93
+ "@pi-unipi/footer": "3.0.0-alpha.35",
94
+ "@pi-unipi/fusion": "3.0.0-alpha.35",
95
+ "@pi-unipi/info-screen": "3.0.0-alpha.35",
96
+ "@pi-unipi/input-shortcuts": "3.0.0-alpha.35",
97
+ "@pi-unipi/kanboard": "3.0.0-alpha.35",
98
+ "@pi-unipi/mcp": "3.0.0-alpha.35",
99
+ "@pi-unipi/memory": "3.0.0-alpha.35",
100
+ "@pi-unipi/notify": "3.0.0-alpha.35",
101
+ "@pi-unipi/skill-registry": "3.0.0-alpha.35",
102
+ "@pi-unipi/subagents": "3.0.0-alpha.35",
103
+ "@pi-unipi/updater": "3.0.0-alpha.35",
104
+ "@pi-unipi/utility": "3.0.0-alpha.35",
105
+ "@pi-unipi/web-api": "3.0.0-alpha.35",
106
+ "@pi-unipi/workflow": "3.0.0-alpha.35"
107
107
  },
108
108
  "devDependencies": {
109
109
  "@earendil-works/pi-agent-core": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/app-bridge",
3
- "version": "3.0.0-alpha.34",
3
+ "version": "3.0.0-alpha.35",
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.34"
34
+ "@pi-unipi/core": "3.0.0-alpha.35"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "3.0.0-alpha.34",
3
+ "version": "3.0.0-alpha.35",
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.34"
43
+ "@pi-unipi/core": "3.0.0-alpha.35"
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.34",
3
+ "version": "3.0.0-alpha.35",
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.34",
3
+ "version": "3.0.0-alpha.35",
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.34",
13
+ "@pi-unipi/core": "3.0.0-alpha.35",
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.34",
3
+ "version": "3.0.0-alpha.35",
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.34"
40
+ "@pi-unipi/core": "3.0.0-alpha.35"
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.34",
3
+ "version": "3.0.0-alpha.35",
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.34",
37
- "@pi-unipi/info-screen": "3.0.0-alpha.34",
36
+ "@pi-unipi/core": "3.0.0-alpha.35",
37
+ "@pi-unipi/info-screen": "3.0.0-alpha.35",
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.34",
3
+ "version": "3.0.0-alpha.35",
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.34",
3
+ "version": "3.0.0-alpha.35",
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.34",
36
- "@pi-unipi/background-tasks": "3.0.0-alpha.34"
35
+ "@pi-unipi/core": "3.0.0-alpha.35",
36
+ "@pi-unipi/background-tasks": "3.0.0-alpha.35"
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.34",
3
+ "version": "3.0.0-alpha.35",
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.34",
36
- "@pi-unipi/subagents": "3.0.0-alpha.34",
37
- "@pi-unipi/utility": "3.0.0-alpha.34"
35
+ "@pi-unipi/core": "3.0.0-alpha.35",
36
+ "@pi-unipi/subagents": "3.0.0-alpha.35",
37
+ "@pi-unipi/utility": "3.0.0-alpha.35"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@earendil-works/pi-ai": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "3.0.0-alpha.34",
3
+ "version": "3.0.0-alpha.35",
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.34"
36
+ "@pi-unipi/core": "3.0.0-alpha.35"
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/input-shortcuts",
3
- "version": "3.0.0-alpha.34",
3
+ "version": "3.0.0-alpha.35",
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.34"
36
+ "@pi-unipi/core": "3.0.0-alpha.35"
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.34",
3
+ "version": "3.0.0-alpha.35",
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.34"
34
+ "@pi-unipi/core": "3.0.0-alpha.35"
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.34",
52
- "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.34",
53
- "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.34",
54
- "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.34",
55
- "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.34"
51
+ "@pi-unipi/kanboard-linux-x64": "3.0.0-alpha.35",
52
+ "@pi-unipi/kanboard-linux-arm64": "3.0.0-alpha.35",
53
+ "@pi-unipi/kanboard-darwin-x64": "3.0.0-alpha.35",
54
+ "@pi-unipi/kanboard-darwin-arm64": "3.0.0-alpha.35",
55
+ "@pi-unipi/kanboard-win32-x64": "3.0.0-alpha.35"
56
56
  }
57
57
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/long-horizon",
3
- "version": "3.0.0-alpha.34",
3
+ "version": "3.0.0-alpha.35",
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.34"
40
+ "@pi-unipi/core": "3.0.0-alpha.35"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/mcp",
3
- "version": "3.0.0-alpha.34",
3
+ "version": "3.0.0-alpha.35",
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.34"
33
+ "@pi-unipi/core": "3.0.0-alpha.35"
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.34",
3
+ "version": "3.0.0-alpha.35",
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",
@@ -43,8 +43,8 @@
43
43
  "README.md"
44
44
  ],
45
45
  "dependencies": {
46
- "@pi-unipi/core": "3.0.0-alpha.34",
47
- "@pi-unipi/info-screen": "3.0.0-alpha.34",
46
+ "@pi-unipi/core": "3.0.0-alpha.35",
47
+ "@pi-unipi/info-screen": "3.0.0-alpha.35",
48
48
  "js-yaml": "^4.1.0"
49
49
  },
50
50
  "peerDependencies": {
@@ -90,6 +90,7 @@ Open `/unipi:settings` → **Notify**. The file is `~/.unipi/config/notify/confi
90
90
  | `gotify.serverUrl`, `gotify.appToken`, `gotify.priority` | priority `5` | Gotify server, token and priority (0–10). |
91
91
  | `telegram.botToken`, `telegram.chatId` | unset | Telegram bot and chat. |
92
92
  | `ntfy.serverUrl`, `ntfy.topic`, `ntfy.token`, `ntfy.priority` | `https://ntfy.sh`, priority `3` | ntfy server, topic, token and priority (1–5). |
93
+ | `ntfy.appDetail` | `"minimal"` | `"minimal"` sends a generic "Tap to open in UniPi" body (just the deep link); `"full"` sends the real question text. Controls what goes *inside* the encrypted payload too (see below) — never changes whether encryption happens. |
93
94
  | `silenceAfterInput.enabled` | `false` | Stops notifications for a time after you press a key. |
94
95
  | `silenceAfterInput.windowMs` | `10000` | Length of that quiet time, in milliseconds. |
95
96
  | `silenceAfterInput.platforms` | `["native"]` | Platforms to keep quiet. |
@@ -118,6 +119,32 @@ Native notifications use [node-notifier](https://github.com/mikaelbr/node-notifi
118
119
 
119
120
  Each sent notification emits a `NOTIFICATION_SENT` event on the [event bus](../../docs/architecture/event-bus.md).
120
121
 
122
+ ## UniPi app push (end-to-end encrypted)
123
+
124
+ When the UniPi phone app is paired with this machine, ntfy notifications
125
+ also carry a deep link so a tap opens the right chat/dialog, and — once a
126
+ key exists — the whole payload is encrypted so the ntfy server (even a
127
+ public one) only ever sees ciphertext:
128
+
129
+ - **Key**: `unipi-host` generates a random 256-bit key at
130
+ `$UNIPI_HOST_DIR/notify-key` (default `~/.unipi/app-host/notify-key`) the
131
+ first time the app asks for it (`notify_config`). This package reads the
132
+ same file (`notify-key.ts`) — if present, `platforms/ntfy.ts` encrypts
133
+ the ntfy `message` with AES-256-GCM and pins `title` to a fixed neutral
134
+ `"UniPi"`; `click` still carries the deep link (`host`/`pid`/`dialog`
135
+ query params only, never question text). No key yet → unchanged
136
+ plaintext/`appDetail` behaviour.
137
+ - **Wire format**: `unipi1:` + base64(12-byte IV + AES-256-GCM ciphertext +
138
+ 16-byte tag) of a small JSON object (`{title, body, kind, host, pid,
139
+ session, dialogId, options?}`). Full format and the host/app sides are
140
+ documented in `unipi-app`'s `docs/m6/PROTOCOL.md`.
141
+ - **Fan-out**: the UniPi app can also register a
142
+ [UnifiedPush](https://unifiedpush.org) endpoint (`notify_register` on the
143
+ host) so notifications reach it even when it has no ntfy topic/token of
144
+ its own — `app-endpoints.ts` persists the list at `~/.unipi/config/
145
+ notify/app-endpoints.json`, and every send fans out to each registered
146
+ endpoint in addition to the configured topic.
147
+
121
148
  ## See also
122
149
 
123
150
  - [Ask User](../ask-user/README.md)
@@ -14,6 +14,7 @@ import { sendGotifyNotification } from "./platforms/gotify.js";
14
14
  import { sendTelegramNotification } from "./platforms/telegram.js";
15
15
  import { sendNtfyNotification, publishToEndpoint } from "./platforms/ntfy.js";
16
16
  import { loadAppEndpoints } from "./app-endpoints.js";
17
+ import { loadNotifyKey } from "./notify-key.js";
17
18
  import { hostname } from "node:os";
18
19
  import { buildAskUserPromptMessage } from "./ask-user-prompt-message.js";
19
20
  import { buildPermissionPromptMessage } from "./permission-prompt-message.js";
@@ -515,7 +516,7 @@ async function sendToPlatform(
515
516
  // paired host). appDetail controls whether the prompt text itself
516
517
  // rides along or just a generic "tap to open" message.
517
518
  const route = { host: hostnameForRoute(), kind: eventType };
518
- const options = { route, appDetail: ntfyConfig.appDetail ?? "minimal" } as const;
519
+ const options = { route, appDetail: ntfyConfig.appDetail ?? "minimal", encryptKey: loadNotifyKey() };
519
520
  await sendNtfyNotification(ntfyConfig.serverUrl, ntfyConfig.topic, title, message, effective, ntfyConfig.token, options);
520
521
  // Fan out to every phone that registered a UnifiedPush endpoint
521
522
  // (notify_register) — best-effort, never blocks/fails the primary send.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @pi-unipi/notify — the per-pairing E2E key (UNI-161 §1).
3
+ *
4
+ * The `unipi-host` daemon generates a random 32-byte key at first use and
5
+ * persists it at `$UNIPI_HOST_DIR/notify-key` (default
6
+ * `~/.unipi/app-host/notify-key`, mode 0600 — see
7
+ * `crates/host/src/notify.rs::notify_key_or_create`). The phone receives
8
+ * the same key (base64) over the `notify_config` request's response.
9
+ *
10
+ * This module reads that same file from the pi side, so the ntfy platform
11
+ * can encrypt notification payloads before they ever leave this machine —
12
+ * the ntfy server (self-hosted or not) only ever sees ciphertext.
13
+ */
14
+ import { readFileSync } from "fs";
15
+ import { join } from "path";
16
+ import { homedir } from "os";
17
+
18
+ /** `$UNIPI_HOST_DIR/notify-key`, or `~/.unipi/app-host/notify-key` if unset. */
19
+ export function notifyKeyPath(): string {
20
+ const dir = process.env.UNIPI_HOST_DIR;
21
+ if (dir && dir.trim() !== "") return join(dir, "notify-key");
22
+ return join(homedir(), ".unipi", "app-host", "notify-key");
23
+ }
24
+
25
+ /** Reads the 32-byte key if present; `undefined` if the host hasn't
26
+ * generated one yet (first `notify_config` request on the phone creates
27
+ * it) or the file is malformed. Never throws. */
28
+ export function loadNotifyKey(): Buffer | undefined {
29
+ try {
30
+ const bytes = readFileSync(notifyKeyPath());
31
+ if (bytes.length !== 32) return undefined;
32
+ return bytes;
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @pi-unipi/notify — app payload encryption (UNI-161 §1).
3
+ *
4
+ * When the per-pairing key (notify-key.ts) is present, the ntfy platform
5
+ * encrypts the whole app payload before it's handed to ntfy: the ntfy
6
+ * `title` stays a fixed neutral "UniPi", `message` becomes the ciphertext
7
+ * envelope below, and `click` carries only `host`/`pid`/`dialog` — never
8
+ * prompt text. Without a key: unchanged legacy behaviour (plaintext/minimal
9
+ * message, same as before UNI-161 §1).
10
+ *
11
+ * Wire format: `unipi1:` + base64(iv(12) || AES-256-GCM(ciphertext || tag))
12
+ * of the JSON-encoded `NotifyPayload`. The IV is random per message. This
13
+ * exact format is mirrored by the app (Kotlin `javax.crypto` / WebCrypto)
14
+ * — see `apps/mobile/src-tauri/gen/android/.../NotifyCrypto.kt` and
15
+ * `apps/mobile/src/lib/notifyCrypto.ts` on the unipi-app side, and
16
+ * documented in `docs/m6/PROTOCOL.md`.
17
+ */
18
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
19
+
20
+ export const ENVELOPE_PREFIX = "unipi1:";
21
+
22
+ /** The plaintext JSON object encrypted into the ntfy `message` field. */
23
+ export interface NotifyPayload {
24
+ title: string;
25
+ body: string;
26
+ kind?: string;
27
+ host?: string;
28
+ pid?: number;
29
+ session?: string;
30
+ dialogId?: number;
31
+ options?: string[];
32
+ }
33
+
34
+ /** AES-256-GCM-encrypts `payload` with `key` (32 raw bytes), returning the
35
+ * full `unipi1:...` envelope string. */
36
+ export function encryptNotifyPayload(key: Buffer, payload: NotifyPayload): string {
37
+ if (key.length !== 32) throw new Error(`notify key must be 32 bytes, got ${key.length}`);
38
+ const iv = randomBytes(12);
39
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
40
+ const plaintext = Buffer.from(JSON.stringify(payload), "utf-8");
41
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
42
+ const tag = cipher.getAuthTag();
43
+ const combined = Buffer.concat([iv, ciphertext, tag]);
44
+ return `${ENVELOPE_PREFIX}${combined.toString("base64")}`;
45
+ }
46
+
47
+ /** Decrypts one `unipi1:...` envelope. Throws on a bad prefix, length,
48
+ * key, or authentication tag — callers should treat any throw as "drop
49
+ * this notification" rather than show anything. Exported mainly for the
50
+ * Node-side test fixture (`__tests__/ntfy-crypto.test.ts`) that proves the
51
+ * format round-trips; the app does the real decrypt with WebCrypto/
52
+ * javax.crypto so no plaintext secret ever needs Node again. */
53
+ export function decryptNotifyPayload(key: Buffer, envelope: string): NotifyPayload {
54
+ if (!envelope.startsWith(ENVELOPE_PREFIX)) throw new Error("missing unipi1: prefix");
55
+ const combined = Buffer.from(envelope.slice(ENVELOPE_PREFIX.length), "base64");
56
+ if (combined.length < 12 + 16) throw new Error("envelope too short");
57
+ const iv = combined.subarray(0, 12);
58
+ const tag = combined.subarray(combined.length - 16);
59
+ const ciphertext = combined.subarray(12, combined.length - 16);
60
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
61
+ decipher.setAuthTag(tag);
62
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
63
+ return JSON.parse(plaintext.toString("utf-8")) as NotifyPayload;
64
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "3.0.0-alpha.34",
3
+ "version": "3.0.0-alpha.35",
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.34",
37
+ "@pi-unipi/core": "3.0.0-alpha.35",
38
38
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "peerDependencies": {
@@ -10,7 +10,15 @@
10
10
  * `appDetail` to control how much of the prompt text ntfy ever carries (the
11
11
  * server-side notify config has no app secrets, but "minimal" keeps the
12
12
  * question text itself off a third-party ntfy.sh server by default).
13
+ *
14
+ * UNI-161 §1: when `encryptKey` is set (notify-key.ts), the whole payload
15
+ * is AES-256-GCM-encrypted (ntfy-crypto.ts) into the ntfy `message`; the
16
+ * ntfy `title` is pinned to a fixed neutral "UniPi" and `click` carries no
17
+ * content beyond host/pid/dialog (deep-link routing only, never prompt
18
+ * text) — so a self-hosted *or* public ntfy server only ever sees
19
+ * ciphertext. Without a key: unchanged legacy minimal/full behaviour.
13
20
  */
21
+ import { encryptNotifyPayload } from "../ntfy-crypto.js";
14
22
 
15
23
  /** What the phone needs to open the right chat (UNI-161 §4): the deep link's
16
24
  * query params. `host`: the paired host's id, or (when unknown on the pi
@@ -28,8 +36,17 @@ export interface NtfyPublishOptions {
28
36
  route?: NtfyAppRoute;
29
37
  /** "minimal" (default): generic title/message, no prompt text — only the
30
38
  * route data. "full": the message argument is sent as-is. Controlled by
31
- * the user's `notify.ntfy.appDetail` setting. */
39
+ * the user's `notify.ntfy.appDetail` setting. Also governs whether the
40
+ * encrypted payload (when `encryptKey` is set) includes the message
41
+ * text or just a generic body. */
32
42
  appDetail?: "minimal" | "full";
43
+ /** UNI-161 §1: the per-pairing E2E key. When present, the payload is
44
+ * encrypted end to end and `appDetail`/`route` only ever affect what
45
+ * goes *inside* the ciphertext, never the ntfy title/message/click. */
46
+ encryptKey?: Buffer;
47
+ /** Folded into the encrypted payload (ignored without `encryptKey`). */
48
+ session?: string;
49
+ dialogId?: number;
33
50
  }
34
51
 
35
52
  /** `unipi://chat?host=<id>&pid=<pid>&dialog=<id>` — the app's existing
@@ -42,21 +59,50 @@ export function buildDeepLink(route: NtfyAppRoute): string {
42
59
  return `unipi://chat?${params.toString()}`;
43
60
  }
44
61
 
62
+ /** The fixed neutral ntfy title used once a payload is encrypted — the
63
+ * server must never see a hint of the real title. */
64
+ const ENCRYPTED_TITLE = "UniPi";
65
+
45
66
  /** Builds the ntfy JSON publish body shared by the topic send and every
46
- * registered app-endpoint send (UNI-161 §4b: notify_register{endpoint}). */
67
+ * registered app-endpoint send (UNI-161 §4b: notify_register{endpoint}).
68
+ * `topicField` lets `publishToEndpoint` omit the `topic` key (the endpoint
69
+ * URL already names it) while sharing the same body-building logic. */
47
70
  export function buildNtfyBody(
48
- topic: string,
71
+ topicOrNull: string | null,
49
72
  title: string,
50
73
  message: string,
51
74
  priority: number,
52
75
  options?: NtfyPublishOptions,
53
76
  ): Record<string, unknown> {
77
+ const clamped = Math.max(1, Math.min(5, priority));
78
+ if (options?.encryptKey) {
79
+ const route = options.route;
80
+ const envelope = encryptNotifyPayload(options.encryptKey, {
81
+ title,
82
+ body: options.appDetail === "full" || !route ? message : "Tap to open in UniPi",
83
+ kind: route?.kind,
84
+ host: route?.host,
85
+ pid: route?.pid,
86
+ session: options.session,
87
+ dialogId: options.dialogId,
88
+ });
89
+ const body: Record<string, unknown> = {
90
+ title: ENCRYPTED_TITLE,
91
+ message: envelope,
92
+ priority: clamped,
93
+ };
94
+ if (topicOrNull !== null) body.topic = topicOrNull;
95
+ // The click target itself must stay content-free: host/pid/dialog
96
+ // only, same shape as the unencrypted route, never prompt text.
97
+ if (route) body.click = buildDeepLink(route);
98
+ return body;
99
+ }
54
100
  const body: Record<string, unknown> = {
55
- topic,
56
101
  title,
57
102
  message: options?.appDetail === "full" || !options?.route ? message : "Tap to open in UniPi",
58
- priority: Math.max(1, Math.min(5, priority)),
103
+ priority: clamped,
59
104
  };
105
+ if (topicOrNull !== null) body.topic = topicOrNull;
60
106
  if (options?.route) {
61
107
  body.click = buildDeepLink(options.route);
62
108
  if (options.route.kind) body.tags = [options.route.kind];
@@ -110,15 +156,7 @@ export async function sendNtfyNotification(
110
156
  * Best-effort: endpoint failures never block the primary ntfy send.
111
157
  */
112
158
  export async function publishToEndpoint(endpoint: string, title: string, message: string, priority: number, options?: NtfyPublishOptions): Promise<void> {
113
- const body: Record<string, unknown> = {
114
- title,
115
- message: options?.appDetail === "full" || !options?.route ? message : "Tap to open in UniPi",
116
- priority: Math.max(1, Math.min(5, priority)),
117
- };
118
- if (options?.route) {
119
- body.click = buildDeepLink(options.route);
120
- if (options.route.kind) body.tags = [options.route.kind];
121
- }
159
+ const body = buildNtfyBody(null, title, message, priority, options);
122
160
  const response = await fetch(endpoint, {
123
161
  method: "POST",
124
162
  headers: { "Content-Type": "application/json" },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/skill-registry",
3
- "version": "3.0.0-alpha.34",
3
+ "version": "3.0.0-alpha.35",
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.34"
36
+ "@pi-unipi/core": "3.0.0-alpha.35"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",