@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 +21 -0
- package/README.md +119 -0
- package/extensions/pi-bridge.mjs +314 -0
- package/lib/core.mjs +107 -0
- package/package.json +45 -0
- package/test/core.test.mjs +67 -0
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
|
+
});
|