@jademind/pi-bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jademind
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # @jademind/pi-bridge
2
+
3
+ Minimal secure inbox bridge for Pi sessions.
4
+
5
+ `@jademind/pi-bridge` is designed for status bar and mobile clients that must send messages reliably to running Pi agents, including plain terminal sessions where tty injection is unreliable.
6
+
7
+ ## What it does
8
+
9
+ - Watches a per-PID inbox directory
10
+ - Validates signed/structured message envelopes
11
+ - Delivers to Pi using user-message semantics
12
+ - queued mode -> `followUp` when busy
13
+ - interrupt mode -> `steer` when busy
14
+ - Writes delivery acknowledgements per message
15
+ - Publishes lightweight per-session registry heartbeat
16
+ - Enforces size limits, TTL, path safety, idempotency, and rate limits
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pi install npm:@jademind/pi-bridge
22
+ ```
23
+
24
+ ## Filesystem layout
25
+
26
+ Default base directory:
27
+
28
+ ```text
29
+ ~/.pi/agent/statusbridge/
30
+ registry/<pid>.json
31
+ inbox/<pid>/<message-id>.json
32
+ processing/<pid>/*.processing
33
+ acks/<pid>/<message-id>.json
34
+ ```
35
+
36
+ Override base with:
37
+
38
+ - `PI_BRIDGE_DIR`
39
+
40
+ ## Envelope (`send-v1`)
41
+
42
+ ```json
43
+ {
44
+ "v": 1,
45
+ "id": "4a4c5295-d3e4-4f91-b562-8f0f4cc6f413",
46
+ "pid": 12345,
47
+ "text": "Please summarize current progress and blockers.",
48
+ "source": "statusbar",
49
+ "createdAt": "2026-02-24T15:50:00Z",
50
+ "expiresAt": "2026-02-24T15:51:00Z",
51
+ "delivery": {
52
+ "mode": "queued"
53
+ },
54
+ "meta": {
55
+ "requestId": "ios-123"
56
+ }
57
+ }
58
+ ```
59
+
60
+ `delivery.mode` values:
61
+
62
+ - `queued` (default): queue politely if busy
63
+ - `interrupt`: steering interrupt if busy
64
+
65
+ ## Ack (`ack-v1`)
66
+
67
+ ```json
68
+ {
69
+ "v": 1,
70
+ "id": "4a4c5295-d3e4-4f91-b562-8f0f4cc6f413",
71
+ "pid": 12345,
72
+ "status": "delivered",
73
+ "at": 1771948234000,
74
+ "resolvedMode": "queued"
75
+ }
76
+ ```
77
+
78
+ Possible statuses:
79
+
80
+ - `delivered`
81
+ - `failed`
82
+ - `duplicate`
83
+
84
+ ## Security defaults
85
+
86
+ - file size cap: 32 KB
87
+ - message length cap: 4000 chars
88
+ - strict PID matching
89
+ - TTL expiry enforcement
90
+ - symlink and path traversal rejection
91
+ - bounded queue depth
92
+ - separate normal/interrupt rate limiters
93
+
94
+ ## Runtime config
95
+
96
+ - `PI_BRIDGE_MAX_TEXT` (default `4000`)
97
+ - `PI_BRIDGE_MAX_SKEW_MS` (default `120000`)
98
+ - `PI_BRIDGE_HEARTBEAT_MS` (default `2000`)
99
+ - `PI_BRIDGE_SCAN_MS` (default `750`)
100
+ - `PI_BRIDGE_QUEUE_DEPTH` (default `64`)
101
+ - `PI_BRIDGE_RATE_PER_MIN` (default `12`)
102
+ - `PI_BRIDGE_RATE_BURST` (default `4`)
103
+ - `PI_BRIDGE_INTERRUPT_RATE_PER_MIN` (default `4`)
104
+ - `PI_BRIDGE_INTERRUPT_RATE_BURST` (default `2`)
105
+
106
+ ## Command
107
+
108
+ - `/pi-bridge-status`
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ npm test
114
+ npm pack --dry-run
115
+ ```
116
+
117
+ ## License
118
+
119
+ MIT
@@ -0,0 +1,314 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import { makeTokenBucket, normalizeDelivery, safeChildPath, validateEnvelope } from "../lib/core.mjs";
6
+
7
+ const MAX_FILE_BYTES = 32 * 1024;
8
+ const PROCESSED_CACHE_MAX = 1024;
9
+
10
+ function envNumber(name, fallback, min = 1) {
11
+ const parsed = Number(process.env[name] ?? "");
12
+ if (!Number.isFinite(parsed)) return fallback;
13
+ return Math.max(min, Math.floor(parsed));
14
+ }
15
+
16
+ function ensureDirSecure(dir) {
17
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
18
+ }
19
+
20
+ function atomicWriteJson(filePath, data) {
21
+ ensureDirSecure(path.dirname(filePath));
22
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
23
+ fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
24
+ fs.renameSync(tmp, filePath);
25
+ }
26
+
27
+ function safeUnlink(filePath) {
28
+ try {
29
+ fs.unlinkSync(filePath);
30
+ } catch {
31
+ // ignore
32
+ }
33
+ }
34
+
35
+ function randomId() {
36
+ return crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
37
+ }
38
+
39
+ export default function (pi) {
40
+ const baseDir = process.env.PI_BRIDGE_DIR?.trim() || path.join(os.homedir(), ".pi", "agent", "statusbridge");
41
+ const registryDir = path.join(baseDir, "registry");
42
+ const inboxRoot = path.join(baseDir, "inbox");
43
+ const ackRoot = path.join(baseDir, "acks");
44
+ const processingRoot = path.join(baseDir, "processing");
45
+
46
+ const pidStr = String(process.pid);
47
+ const inboxDir = path.join(inboxRoot, pidStr);
48
+ const ackDir = path.join(ackRoot, pidStr);
49
+ const processingDir = path.join(processingRoot, pidStr);
50
+ const registryFile = path.join(registryDir, `${pidStr}.json`);
51
+
52
+ const maxTextLength = envNumber("PI_BRIDGE_MAX_TEXT", 4000, 32);
53
+ const maxSkewMs = envNumber("PI_BRIDGE_MAX_SKEW_MS", 120000, 1000);
54
+ const heartbeatMs = envNumber("PI_BRIDGE_HEARTBEAT_MS", 2000, 250);
55
+ const scanMs = envNumber("PI_BRIDGE_SCAN_MS", 750, 100);
56
+ const refillPerMinute = envNumber("PI_BRIDGE_RATE_PER_MIN", 12, 1);
57
+ const burst = envNumber("PI_BRIDGE_RATE_BURST", 4, 1);
58
+ const refillInterruptPerMinute = envNumber("PI_BRIDGE_INTERRUPT_RATE_PER_MIN", 4, 1);
59
+ const burstInterrupt = envNumber("PI_BRIDGE_INTERRUPT_RATE_BURST", 2, 1);
60
+ const queueDepthLimit = envNumber("PI_BRIDGE_QUEUE_DEPTH", 64, 1);
61
+
62
+ ensureDirSecure(baseDir);
63
+ ensureDirSecure(registryDir);
64
+ ensureDirSecure(inboxRoot);
65
+ ensureDirSecure(ackRoot);
66
+ ensureDirSecure(processingRoot);
67
+ ensureDirSecure(inboxDir);
68
+ ensureDirSecure(ackDir);
69
+ ensureDirSecure(processingDir);
70
+
71
+ const normalLimiter = makeTokenBucket({ refillPerMinute, burst });
72
+ const interruptLimiter = makeTokenBucket({ refillPerMinute: refillInterruptPerMinute, burst: burstInterrupt });
73
+ const processed = new Map();
74
+
75
+ let heartbeat = undefined;
76
+ let scanner = undefined;
77
+ let watcher = undefined;
78
+ let isDraining = false;
79
+ let sessionId = "";
80
+ let lastCtx = undefined;
81
+
82
+ function rememberProcessed(id) {
83
+ processed.set(id, Date.now());
84
+ if (processed.size <= PROCESSED_CACHE_MAX) return;
85
+ const first = processed.keys().next().value;
86
+ if (first) processed.delete(first);
87
+ }
88
+
89
+ function writeAck(status, envelope, extra = {}) {
90
+ const ack = {
91
+ v: 1,
92
+ id: envelope?.id || randomId(),
93
+ pid: process.pid,
94
+ status,
95
+ at: Date.now(),
96
+ messagePid: envelope?.pid,
97
+ source: envelope?.source,
98
+ delivery: envelope?.delivery,
99
+ ...extra,
100
+ };
101
+ const ackFile = path.join(ackDir, `${ack.id}.json`);
102
+ atomicWriteJson(ackFile, ack);
103
+ }
104
+
105
+ function publishRegistry() {
106
+ const payload = {
107
+ v: 1,
108
+ pid: process.pid,
109
+ ppid: process.ppid,
110
+ startedAt: process.uptime ? Math.floor(Date.now() - process.uptime() * 1000) : Date.now(),
111
+ updatedAt: Date.now(),
112
+ sessionId,
113
+ cwd: process.cwd(),
114
+ capabilities: {
115
+ queueing: true,
116
+ steering: true,
117
+ },
118
+ };
119
+ atomicWriteJson(registryFile, payload);
120
+ }
121
+
122
+ function moveToProcessing(fileName) {
123
+ const src = path.join(inboxDir, fileName);
124
+ const dst = path.join(processingDir, `${fileName}.${Date.now()}.processing`);
125
+ if (!safeChildPath(inboxDir, src) || !safeChildPath(processingDir, dst)) return null;
126
+ try {
127
+ const st = fs.lstatSync(src);
128
+ if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_FILE_BYTES) {
129
+ safeUnlink(src);
130
+ return null;
131
+ }
132
+ fs.renameSync(src, dst);
133
+ return dst;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ function readEnvelope(filePath) {
140
+ try {
141
+ if (!safeChildPath(processingDir, filePath)) return { ok: false, error: "invalid_path" };
142
+ const text = fs.readFileSync(filePath, "utf8");
143
+ return { ok: true, raw: JSON.parse(text) };
144
+ } catch {
145
+ return { ok: false, error: "invalid_json" };
146
+ }
147
+ }
148
+
149
+ function sendToAgent(ctx, envelope) {
150
+ const isIdle = Boolean(ctx?.isIdle?.());
151
+ const delivery = normalizeDelivery(envelope.delivery, isIdle);
152
+
153
+ const limiter = delivery.mode === "interrupt" ? interruptLimiter : normalLimiter;
154
+ if (!limiter.allow(1)) {
155
+ return { ok: false, error: "rate_limited", delivery };
156
+ }
157
+
158
+ try {
159
+ if (delivery.sendUserMessageOptions) {
160
+ pi.sendUserMessage(envelope.text, delivery.sendUserMessageOptions);
161
+ } else {
162
+ pi.sendUserMessage(envelope.text);
163
+ }
164
+ return { ok: true, delivery };
165
+ } catch (error) {
166
+ return { ok: false, error: error instanceof Error ? error.message : "send_failed", delivery };
167
+ }
168
+ }
169
+
170
+ function processOneFile(fileName, ctx) {
171
+ const processingFile = moveToProcessing(fileName);
172
+ if (!processingFile) return;
173
+
174
+ const parsed = readEnvelope(processingFile);
175
+ if (!parsed.ok) {
176
+ writeAck("failed", { id: fileName, pid: process.pid, source: "unknown" }, { error: parsed.error });
177
+ safeUnlink(processingFile);
178
+ return;
179
+ }
180
+
181
+ const validated = validateEnvelope(parsed.raw, {
182
+ maxTextLength,
183
+ maxSkewMs,
184
+ expectedPid: process.pid,
185
+ });
186
+
187
+ if (!validated.ok) {
188
+ writeAck("failed", { id: parsed.raw?.id ?? fileName, pid: process.pid, source: parsed.raw?.source }, { error: validated.error });
189
+ safeUnlink(processingFile);
190
+ return;
191
+ }
192
+
193
+ const envelope = validated.value;
194
+ if (processed.has(envelope.id)) {
195
+ writeAck("duplicate", envelope, { error: "duplicate_id" });
196
+ safeUnlink(processingFile);
197
+ return;
198
+ }
199
+
200
+ const result = sendToAgent(ctx, envelope);
201
+ if (!result.ok) {
202
+ writeAck("failed", envelope, { error: result.error, resolvedMode: result.delivery?.mode });
203
+ safeUnlink(processingFile);
204
+ return;
205
+ }
206
+
207
+ rememberProcessed(envelope.id);
208
+ writeAck("delivered", envelope, { resolvedMode: result.delivery.mode });
209
+ safeUnlink(processingFile);
210
+ }
211
+
212
+ function drainInbox(ctx) {
213
+ if (isDraining) return;
214
+ isDraining = true;
215
+ try {
216
+ if (!ctx) return;
217
+ const entries = fs.readdirSync(inboxDir, { withFileTypes: true })
218
+ .filter((d) => d.isFile() && d.name.endsWith(".json"))
219
+ .map((d) => d.name)
220
+ .sort();
221
+
222
+ if (entries.length > queueDepthLimit) {
223
+ const dropCount = entries.length - queueDepthLimit;
224
+ for (const name of entries.slice(0, dropCount)) {
225
+ const full = path.join(inboxDir, name);
226
+ safeUnlink(full);
227
+ }
228
+ }
229
+
230
+ for (const name of entries.slice(-queueDepthLimit)) {
231
+ processOneFile(name, ctx);
232
+ }
233
+ } finally {
234
+ isDraining = false;
235
+ }
236
+ }
237
+
238
+ function startBackground() {
239
+ publishRegistry();
240
+
241
+ if (heartbeat) clearInterval(heartbeat);
242
+ heartbeat = setInterval(() => publishRegistry(), heartbeatMs);
243
+ heartbeat.unref?.();
244
+
245
+ if (scanner) clearInterval(scanner);
246
+ scanner = setInterval(() => drainInbox(lastCtx), scanMs);
247
+ scanner.unref?.();
248
+
249
+ try {
250
+ if (watcher) watcher.close();
251
+ watcher = fs.watch(inboxDir, { persistent: false }, () => drainInbox(lastCtx));
252
+ } catch {
253
+ watcher = undefined;
254
+ }
255
+ }
256
+
257
+ function stopBackground() {
258
+ if (heartbeat) clearInterval(heartbeat);
259
+ if (scanner) clearInterval(scanner);
260
+ heartbeat = undefined;
261
+ scanner = undefined;
262
+ if (watcher) {
263
+ watcher.close();
264
+ watcher = undefined;
265
+ }
266
+ }
267
+
268
+ pi.registerCommand("pi-bridge-status", {
269
+ description: "Show pi-bridge directories and runtime settings",
270
+ handler: async (_args, ctx) => {
271
+ lastCtx = ctx;
272
+ const msg = [
273
+ `pi-bridge pid=${process.pid}`,
274
+ `base=${baseDir}`,
275
+ `inbox=${inboxDir}`,
276
+ `acks=${ackDir}`,
277
+ `maxText=${maxTextLength}`,
278
+ `queueDepthLimit=${queueDepthLimit}`,
279
+ `rate/min=${refillPerMinute} burst=${burst}`,
280
+ `interruptRate/min=${refillInterruptPerMinute} burst=${burstInterrupt}`,
281
+ ].join("\n");
282
+
283
+ pi.sendMessage({ customType: "pi-bridge", content: msg, display: true });
284
+ if (ctx.hasUI) ctx.ui.notify("pi-bridge status emitted", "info");
285
+ },
286
+ });
287
+
288
+ pi.on("session_start", async (_event, ctx) => {
289
+ lastCtx = ctx;
290
+ sessionId = ctx.sessionManager.getSessionId();
291
+ startBackground();
292
+ drainInbox(ctx);
293
+ });
294
+
295
+ pi.on("turn_start", async (_event, ctx) => {
296
+ lastCtx = ctx;
297
+ drainInbox(ctx);
298
+ });
299
+
300
+ pi.on("turn_end", async (_event, ctx) => {
301
+ lastCtx = ctx;
302
+ drainInbox(ctx);
303
+ });
304
+
305
+ pi.on("agent_end", async (_event, ctx) => {
306
+ lastCtx = ctx;
307
+ drainInbox(ctx);
308
+ });
309
+
310
+ pi.on("session_shutdown", async () => {
311
+ stopBackground();
312
+ safeUnlink(registryFile);
313
+ });
314
+ }
package/lib/core.mjs ADDED
@@ -0,0 +1,107 @@
1
+ import path from "node:path";
2
+
3
+ export const VALID_MODES = new Set(["queued", "interrupt"]);
4
+
5
+ export function parseTime(input) {
6
+ if (typeof input === "number" && Number.isFinite(input)) return input;
7
+ if (typeof input === "string" && input.trim()) {
8
+ const n = Number(input);
9
+ if (Number.isFinite(n)) return n;
10
+ const d = Date.parse(input);
11
+ if (Number.isFinite(d)) return d;
12
+ }
13
+ return NaN;
14
+ }
15
+
16
+ export function nowMs() {
17
+ return Date.now();
18
+ }
19
+
20
+ export function safeChildPath(root, target) {
21
+ const resolvedRoot = path.resolve(root);
22
+ const resolvedTarget = path.resolve(target);
23
+ return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(`${resolvedRoot}${path.sep}`);
24
+ }
25
+
26
+ export function normalizeDelivery(delivery, isIdle) {
27
+ const mode = delivery?.mode === "interrupt" ? "interrupt" : "queued";
28
+
29
+ if (isIdle) {
30
+ return { mode, sendUserMessageOptions: undefined };
31
+ }
32
+
33
+ if (mode === "interrupt") {
34
+ return { mode, sendUserMessageOptions: { deliverAs: "steer" } };
35
+ }
36
+
37
+ return { mode, sendUserMessageOptions: { deliverAs: "followUp" } };
38
+ }
39
+
40
+ export function validateEnvelope(raw, opts = {}) {
41
+ const maxTextLength = Number.isFinite(opts.maxTextLength) ? opts.maxTextLength : 4000;
42
+ const maxSkewMs = Number.isFinite(opts.maxSkewMs) ? opts.maxSkewMs : 60_000;
43
+ const now = Number.isFinite(opts.now) ? opts.now : nowMs();
44
+ const expectedPid = Number.isFinite(opts.expectedPid) ? opts.expectedPid : undefined;
45
+
46
+ if (!raw || typeof raw !== "object") return { ok: false, error: "invalid_json" };
47
+
48
+ const v = raw.v;
49
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
50
+ const pid = Number(raw.pid);
51
+ const text = typeof raw.text === "string" ? raw.text.replace(/\r\n?/g, "\n").trim() : "";
52
+ const source = typeof raw.source === "string" ? raw.source.trim() : "unknown";
53
+ const createdAt = parseTime(raw.createdAt);
54
+ const expiresAt = raw.expiresAt == null ? createdAt + maxSkewMs : parseTime(raw.expiresAt);
55
+
56
+ if (v !== 1) return { ok: false, error: "unsupported_version" };
57
+ if (!id || id.length > 128) return { ok: false, error: "invalid_id" };
58
+ if (!Number.isFinite(pid) || pid <= 0) return { ok: false, error: "invalid_pid" };
59
+ if (expectedPid != null && pid !== expectedPid) return { ok: false, error: "pid_mismatch" };
60
+ if (!text) return { ok: false, error: "empty_text" };
61
+ if (text.length > maxTextLength) return { ok: false, error: "text_too_long" };
62
+ if (!Number.isFinite(createdAt)) return { ok: false, error: "invalid_created_at" };
63
+ if (!Number.isFinite(expiresAt)) return { ok: false, error: "invalid_expires_at" };
64
+ if (expiresAt < now) return { ok: false, error: "expired" };
65
+
66
+ const rawMode = raw.delivery?.mode;
67
+ const mode = VALID_MODES.has(rawMode) ? rawMode : "queued";
68
+
69
+ return {
70
+ ok: true,
71
+ value: {
72
+ v,
73
+ id,
74
+ pid,
75
+ text,
76
+ source: source || "unknown",
77
+ createdAt,
78
+ expiresAt,
79
+ delivery: {
80
+ mode,
81
+ channel: "steer",
82
+ triggerTurn: true,
83
+ },
84
+ meta: raw.meta && typeof raw.meta === "object" ? raw.meta : undefined,
85
+ },
86
+ };
87
+ }
88
+
89
+ export function makeTokenBucket({ refillPerMinute = 12, burst = 4 } = {}) {
90
+ const ratePerMs = Math.max(1, refillPerMinute) / 60_000;
91
+ const capacity = Math.max(1, burst);
92
+ let tokens = capacity;
93
+ let last = nowMs();
94
+
95
+ return {
96
+ allow(cost = 1, now = nowMs()) {
97
+ const delta = Math.max(0, now - last);
98
+ last = now;
99
+ tokens = Math.min(capacity, tokens + delta * ratePerMs);
100
+ if (tokens >= cost) {
101
+ tokens -= cost;
102
+ return true;
103
+ }
104
+ return false;
105
+ },
106
+ };
107
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@jademind/pi-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Minimal secure inbox bridge for Pi: reliable queued/steering message delivery to running sessions.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "pi-coding-agent",
9
+ "bridge",
10
+ "messaging",
11
+ "statusbar",
12
+ "ios"
13
+ ],
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/jademind/pi-bridge.git"
19
+ },
20
+ "homepage": "https://github.com/jademind/pi-bridge#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/jademind/pi-bridge/issues"
23
+ },
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "files": [
28
+ "extensions",
29
+ "lib",
30
+ "test",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "test": "node --test"
36
+ },
37
+ "pi": {
38
+ "extensions": [
39
+ "./extensions"
40
+ ]
41
+ },
42
+ "peerDependencies": {
43
+ "@mariozechner/pi-coding-agent": "*"
44
+ }
45
+ }
@@ -0,0 +1,67 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { makeTokenBucket, normalizeDelivery, safeChildPath, validateEnvelope } from "../lib/core.mjs";
4
+
5
+ test("validateEnvelope accepts valid payload", () => {
6
+ const now = Date.now();
7
+ const res = validateEnvelope(
8
+ {
9
+ v: 1,
10
+ id: "abc",
11
+ pid: 123,
12
+ text: "hello",
13
+ source: "statusbar",
14
+ createdAt: now,
15
+ expiresAt: now + 5000,
16
+ delivery: { mode: "interrupt" },
17
+ },
18
+ { expectedPid: 123, now },
19
+ );
20
+
21
+ assert.equal(res.ok, true);
22
+ assert.equal(res.value.delivery.mode, "interrupt");
23
+ });
24
+
25
+ test("validateEnvelope rejects wrong pid", () => {
26
+ const now = Date.now();
27
+ const res = validateEnvelope(
28
+ {
29
+ v: 1,
30
+ id: "abc",
31
+ pid: 999,
32
+ text: "hello",
33
+ createdAt: now,
34
+ expiresAt: now + 5000,
35
+ },
36
+ { expectedPid: 123, now },
37
+ );
38
+
39
+ assert.equal(res.ok, false);
40
+ assert.equal(res.error, "pid_mismatch");
41
+ });
42
+
43
+ test("normalizeDelivery maps queued to followUp while busy", () => {
44
+ const d = normalizeDelivery({ mode: "queued" }, false);
45
+ assert.equal(d.mode, "queued");
46
+ assert.deepEqual(d.sendUserMessageOptions, { deliverAs: "followUp" });
47
+ });
48
+
49
+ test("normalizeDelivery maps interrupt to steer while busy", () => {
50
+ const d = normalizeDelivery({ mode: "interrupt" }, false);
51
+ assert.equal(d.mode, "interrupt");
52
+ assert.deepEqual(d.sendUserMessageOptions, { deliverAs: "steer" });
53
+ });
54
+
55
+ test("safeChildPath rejects traversal", () => {
56
+ assert.equal(safeChildPath("/tmp/a", "/tmp/a/b/c"), true);
57
+ assert.equal(safeChildPath("/tmp/a", "/tmp/other"), false);
58
+ });
59
+
60
+ test("token bucket throttles", () => {
61
+ const bucket = makeTokenBucket({ refillPerMinute: 60, burst: 2 });
62
+ const t = 1000;
63
+ assert.equal(bucket.allow(1, t), true);
64
+ assert.equal(bucket.allow(1, t), true);
65
+ assert.equal(bucket.allow(1, t), false);
66
+ assert.equal(bucket.allow(1, t + 1000), true);
67
+ });