@dieulc/pi-office-bridge 0.1.0 → 0.2.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/README.md +120 -97
- package/package.json +1 -1
- package/src/bridge-server.ts +206 -15
- package/src/index.ts +56 -1
package/README.md
CHANGED
|
@@ -1,97 +1,120 @@
|
|
|
1
|
-
# @dieulc/pi-office-bridge
|
|
2
|
-
|
|
3
|
-
Native Pi extension that lets a local Pi process drive **Excel**, **Word**, and
|
|
4
|
-
**PowerPoint** through the [pi-for-office](../add-in/README.md) task-pane
|
|
5
|
-
add-in.
|
|
6
|
-
|
|
7
|
-
Pure extension — it only uses Pi's public extension API, so **Pi core is never
|
|
8
|
-
touched** and Pi can be updated freely.
|
|
9
|
-
|
|
10
|
-
## How it works
|
|
11
|
-
|
|
12
|
-
```
|
|
13
|
-
Excel / Word / PowerPoint (pi-for-office task pane)
|
|
14
|
-
│ WebSocket ws://127.0.0.1:38617
|
|
15
|
-
▼
|
|
16
|
-
local Pi process (this extension)
|
|
17
|
-
• registers office_<host>_<op> tools
|
|
18
|
-
• proxies Office.js calls back to the pane
|
|
19
|
-
• injects pane prompts into the Pi session
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Two flows:
|
|
23
|
-
|
|
24
|
-
1. **Tool proxy (Pi → pane):** the Pi agent calls `office_excel_read_range`,
|
|
25
|
-
`office_word_insert_text`, …; the extension forwards a `tool_call` to the
|
|
26
|
-
attached pane; the pane runs the Office.js op and answers with a
|
|
27
|
-
`tool_result`. The LLM then sees the document content **and** has Pi's full
|
|
28
|
-
system tools (bash, git, files).
|
|
29
|
-
|
|
30
|
-
2. **Pane-driven chat (pane → Pi):** the user types in the add-in sidebar; the
|
|
31
|
-
pane forwards a `user_message`; the extension injects it into the Pi session
|
|
32
|
-
and streams the assistant's final reply back to the pane.
|
|
33
|
-
|
|
34
|
-
## Install
|
|
35
|
-
|
|
36
|
-
```bash
|
|
37
|
-
pi install npm:@dieulc/pi-office-bridge
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Or from the monorepo (development):
|
|
41
|
-
|
|
42
|
-
```bash
|
|
43
|
-
cd packages/bridge-extension
|
|
44
|
-
npm install
|
|
45
|
-
# then load it in pi for a quick test:
|
|
46
|
-
pi -e ./src/index.ts
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
##
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
|
72
|
-
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
1
|
+
# @dieulc/pi-office-bridge
|
|
2
|
+
|
|
3
|
+
Native Pi extension that lets a local Pi process drive **Excel**, **Word**, and
|
|
4
|
+
**PowerPoint** through the [pi-for-office](../add-in/README.md) task-pane
|
|
5
|
+
add-in.
|
|
6
|
+
|
|
7
|
+
Pure extension — it only uses Pi's public extension API, so **Pi core is never
|
|
8
|
+
touched** and Pi can be updated freely.
|
|
9
|
+
|
|
10
|
+
## How it works
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
Excel / Word / PowerPoint (pi-for-office task pane)
|
|
14
|
+
│ WebSocket ws://127.0.0.1:38617
|
|
15
|
+
▼
|
|
16
|
+
local Pi process (this extension)
|
|
17
|
+
• registers office_<host>_<op> tools
|
|
18
|
+
• proxies Office.js calls back to the pane
|
|
19
|
+
• injects pane prompts into the Pi session
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Two flows:
|
|
23
|
+
|
|
24
|
+
1. **Tool proxy (Pi → pane):** the Pi agent calls `office_excel_read_range`,
|
|
25
|
+
`office_word_insert_text`, …; the extension forwards a `tool_call` to the
|
|
26
|
+
attached pane; the pane runs the Office.js op and answers with a
|
|
27
|
+
`tool_result`. The LLM then sees the document content **and** has Pi's full
|
|
28
|
+
system tools (bash, git, files).
|
|
29
|
+
|
|
30
|
+
2. **Pane-driven chat (pane → Pi):** the user types in the add-in sidebar; the
|
|
31
|
+
pane forwards a `user_message`; the extension injects it into the Pi session
|
|
32
|
+
and streams the assistant's final reply back to the pane.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pi install npm:@dieulc/pi-office-bridge
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or from the monorepo (development):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
cd packages/bridge-extension
|
|
44
|
+
npm install
|
|
45
|
+
# then load it in pi for a quick test:
|
|
46
|
+
pi -e ./src/index.ts
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Enable in the add-in
|
|
50
|
+
|
|
51
|
+
1. Install/run the bridge so a Pi process with this extension is listening
|
|
52
|
+
(see above). Keep that Pi process running in the background.
|
|
53
|
+
2. Open pi-for-office in Excel / Word / PowerPoint.
|
|
54
|
+
3. Go to **Settings → Connections → Local Pi agent (advanced)** and flip the
|
|
55
|
+
**Enable local Pi agent** toggle on. The card shows the live connection
|
|
56
|
+
state (Connecting… → Connected); no taskpane reload is needed.
|
|
57
|
+
4. Verify with the card's **Test connection** button, or from a terminal:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
curl http://127.0.0.1:38617/health
|
|
61
|
+
# → { "ok": true, "service": "pi-office-bridge", "panes": [ … ] }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`/health` lists the attached pane(s) and their host app (excel / word /
|
|
65
|
+
powerpoint), so it doubles as a quick host-detection check.
|
|
66
|
+
|
|
67
|
+
## Commands
|
|
68
|
+
|
|
69
|
+
| Command | Description |
|
|
70
|
+
|---------|-------------|
|
|
71
|
+
| `/office` | Show bridge status: port + attached apps (Excel/Word/PowerPoint) |
|
|
72
|
+
| `/office-tools` | List every `office_*` tool registered |
|
|
73
|
+
|
|
74
|
+
## Configuration
|
|
75
|
+
|
|
76
|
+
- **Port** — flag `--office-bridge-port <port>` or env `PI_OFFICE_BRIDGE_PORT`
|
|
77
|
+
(default `38617`). The add-in connects to the same default; change both if you
|
|
78
|
+
override it.
|
|
79
|
+
- **Allowed origins** — env `PI_OFFICE_BRIDGE_ALLOWED_ORIGINS` (comma-separated)
|
|
80
|
+
extends the browser origins allowed to read `GET /health`. Defaults cover the
|
|
81
|
+
dev Vite server (`https://localhost:3141`) and the hosted GitHub Pages add-in
|
|
82
|
+
(`https://dieuluucanh.github.io`). The pane's WebSocket connection is
|
|
83
|
+
loopback-only and is not restricted by this list.
|
|
84
|
+
|
|
85
|
+
## Office tools
|
|
86
|
+
|
|
87
|
+
The extension registers a `office_<host>_<op>` tool per op in the shared
|
|
88
|
+
catalog. The catalog lives in
|
|
89
|
+
[`src/office-tools.ts`](./src/office-tools.ts); op ids are namespaced by host:
|
|
90
|
+
|
|
91
|
+
| Host | Ops |
|
|
92
|
+
|------|-----|
|
|
93
|
+
| Excel | `get_overview`, `read_range`, `write_cells`, `fill_formula`, `search_workbook` |
|
|
94
|
+
| Word | `get_overview`, `read_document`, `insert_text`, `replace_text` |
|
|
95
|
+
| PowerPoint | `get_overview`, `read_slide`, `add_slide`, `add_text_box` |
|
|
96
|
+
|
|
97
|
+
The pane-side executors are the counterpart contract — see
|
|
98
|
+
`packages/add-in/src/bridge/` (same repo). **When adding an op, update both
|
|
99
|
+
sides** (see "Bridge contract" in the add-in README).
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
npm run typecheck # typecheck against @earendil-works/pi-coding-agent 0.85.x
|
|
105
|
+
npm run build # emit dist/ (for the node smoke tests)
|
|
106
|
+
npm test # smoke test + end-to-end interop test (real client ↔ real server)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`tests/pane-interop.mjs` wires the **real** add-in `PaneBridgeClient` to the
|
|
110
|
+
**real** bridge server through the shared `@dieulc/pi-office-protocol` package —
|
|
111
|
+
the strongest proof the two halves agree on the wire format.
|
|
112
|
+
|
|
113
|
+
## Protocol
|
|
114
|
+
|
|
115
|
+
The wire protocol is shared in `@dieulc/pi-office-protocol`
|
|
116
|
+
(`packages/protocol`). Bump `BRIDGE_PROTOCOL_VERSION` on breaking changes.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dieulc/pi-office-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Native Pi extension — WebSocket bridge + Office tool proxy so Pi can drive Excel, Word, and PowerPoint through the pi-for-office add-in.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/bridge-server.ts
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { WebSocketServer, WebSocket } from "ws";
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
IncomingMessage,
|
|
13
|
+
Server as HttpServer,
|
|
14
|
+
ServerResponse,
|
|
15
|
+
} from "node:http";
|
|
12
16
|
import { createServer } from "node:http";
|
|
13
17
|
import type { AddressInfo } from "node:net";
|
|
14
18
|
|
|
@@ -16,6 +20,7 @@ import {
|
|
|
16
20
|
BRIDGE_PROTOCOL_VERSION,
|
|
17
21
|
nextCallId,
|
|
18
22
|
parseClientMessage,
|
|
23
|
+
type BridgeCapability,
|
|
19
24
|
type ClientMessage,
|
|
20
25
|
type OfficeHostApp,
|
|
21
26
|
type ServerMessage,
|
|
@@ -36,6 +41,11 @@ export interface AttachedPane {
|
|
|
36
41
|
export interface BridgeServerHandlers {
|
|
37
42
|
/** A user typed a prompt in the add-in sidebar. */
|
|
38
43
|
onUserMessage(text: string, pane: AttachedPane): void;
|
|
44
|
+
/**
|
|
45
|
+
* A pane attached or detached. Lets the host (Pi) refresh its status line
|
|
46
|
+
* immediately instead of waiting for the next session event.
|
|
47
|
+
*/
|
|
48
|
+
onPanesChanged?(panes: readonly AttachedPane[]): void;
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
export interface CallOfficeToolResult {
|
|
@@ -49,6 +59,16 @@ const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
|
49
59
|
const MAX_TEXT_CHARS = 50_000;
|
|
50
60
|
const MAX_DETAILS_BYTES = 1_000_000;
|
|
51
61
|
|
|
62
|
+
/** Extra browser origins for the /health CORS gate, from env (comma-separated). */
|
|
63
|
+
function parseExtraOrigins(): string[] {
|
|
64
|
+
const raw = process.env.PI_OFFICE_BRIDGE_ALLOWED_ORIGINS;
|
|
65
|
+
if (!raw) return [];
|
|
66
|
+
return raw
|
|
67
|
+
.split(/[,\s]+/u)
|
|
68
|
+
.map((s) => s.trim())
|
|
69
|
+
.filter((s) => s.length > 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
52
72
|
interface PendingCall {
|
|
53
73
|
resolve(result: CallOfficeToolResult): void;
|
|
54
74
|
reject(error: Error): void;
|
|
@@ -56,10 +76,15 @@ interface PendingCall {
|
|
|
56
76
|
}
|
|
57
77
|
|
|
58
78
|
export class OfficeBridgeServer {
|
|
79
|
+
/** Capabilities advertised in `welcome` and `GET /health`. */
|
|
80
|
+
static readonly CAPABILITIES: readonly BridgeCapability[] = ["http-health"];
|
|
81
|
+
|
|
59
82
|
private readonly port: number;
|
|
60
83
|
private readonly handlers: BridgeServerHandlers;
|
|
61
84
|
private readonly serverName: string;
|
|
85
|
+
private readonly serverVersion: string;
|
|
62
86
|
private readonly piVersion: string | null;
|
|
87
|
+
private readonly startedAt = Date.now();
|
|
63
88
|
|
|
64
89
|
private httpServer: HttpServer | null = null;
|
|
65
90
|
private wss: WebSocketServer | null = null;
|
|
@@ -71,11 +96,13 @@ export class OfficeBridgeServer {
|
|
|
71
96
|
constructor(options: {
|
|
72
97
|
port: number;
|
|
73
98
|
serverName?: string;
|
|
99
|
+
serverVersion?: string;
|
|
74
100
|
piVersion?: string | null;
|
|
75
101
|
handlers: BridgeServerHandlers;
|
|
76
102
|
}) {
|
|
77
103
|
this.port = options.port;
|
|
78
104
|
this.serverName = options.serverName ?? "pi-office-bridge";
|
|
105
|
+
this.serverVersion = options.serverVersion ?? "unknown";
|
|
79
106
|
this.piVersion = options.piVersion ?? null;
|
|
80
107
|
this.handlers = options.handlers;
|
|
81
108
|
}
|
|
@@ -86,7 +113,9 @@ export class OfficeBridgeServer {
|
|
|
86
113
|
|
|
87
114
|
get actualPort(): number | null {
|
|
88
115
|
const addr = this.httpServer?.address();
|
|
89
|
-
return typeof addr === "object" && addr !== null
|
|
116
|
+
return typeof addr === "object" && addr !== null
|
|
117
|
+
? (addr as AddressInfo).port
|
|
118
|
+
: null;
|
|
90
119
|
}
|
|
91
120
|
|
|
92
121
|
/** Pane list copy (ordered by most recent connection first). */
|
|
@@ -98,8 +127,13 @@ export class OfficeBridgeServer {
|
|
|
98
127
|
start(): Promise<void> {
|
|
99
128
|
if (this.wss) return Promise.resolve();
|
|
100
129
|
|
|
101
|
-
const httpServer = createServer()
|
|
102
|
-
|
|
130
|
+
const httpServer = createServer((req, res) =>
|
|
131
|
+
this.handleHttpRequest(req, res),
|
|
132
|
+
);
|
|
133
|
+
const wss = new WebSocketServer({
|
|
134
|
+
server: httpServer,
|
|
135
|
+
maxPayload: 16 * 1024 * 1024,
|
|
136
|
+
});
|
|
103
137
|
|
|
104
138
|
this.httpServer = httpServer;
|
|
105
139
|
this.wss = wss;
|
|
@@ -153,6 +187,7 @@ export class OfficeBridgeServer {
|
|
|
153
187
|
pane.ws.close(1001, "bridge shutting down");
|
|
154
188
|
}
|
|
155
189
|
this.panes.length = 0;
|
|
190
|
+
this.notifyPanesChanged();
|
|
156
191
|
|
|
157
192
|
return new Promise((resolve) => {
|
|
158
193
|
wss.close(() => resolve());
|
|
@@ -195,7 +230,11 @@ export class OfficeBridgeServer {
|
|
|
195
230
|
return new Promise<CallOfficeToolResult>((resolve, reject) => {
|
|
196
231
|
const timer = setTimeout(() => {
|
|
197
232
|
this.pending.delete(id);
|
|
198
|
-
reject(
|
|
233
|
+
reject(
|
|
234
|
+
new Error(
|
|
235
|
+
`office-bridge: ${op} timed out after ${timeoutMs / 1000}s`,
|
|
236
|
+
),
|
|
237
|
+
);
|
|
199
238
|
}, timeoutMs);
|
|
200
239
|
|
|
201
240
|
this.pending.set(id, { resolve, reject, timer });
|
|
@@ -217,7 +256,11 @@ export class OfficeBridgeServer {
|
|
|
217
256
|
if (!this.sendToPane(pane, message)) {
|
|
218
257
|
clearTimeout(timer);
|
|
219
258
|
this.pending.delete(id);
|
|
220
|
-
reject(
|
|
259
|
+
reject(
|
|
260
|
+
new Error(
|
|
261
|
+
"office-bridge: pane disconnected before the tool call was sent",
|
|
262
|
+
),
|
|
263
|
+
);
|
|
221
264
|
}
|
|
222
265
|
});
|
|
223
266
|
}
|
|
@@ -229,10 +272,127 @@ export class OfficeBridgeServer {
|
|
|
229
272
|
}
|
|
230
273
|
}
|
|
231
274
|
|
|
275
|
+
/* ── HTTP (health/diagnostics, loopback-only) ─────────────────────── */
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Browser origins allowed to read this loopback HTTP surface. The add-in
|
|
279
|
+
* task pane runs at these origins (dev Vite server + hosted GitHub Pages).
|
|
280
|
+
* Extend with the PI_OFFICE_BRIDGE_ALLOWED_ORIGINS env var
|
|
281
|
+
* (comma-separated) when the add-in is hosted elsewhere.
|
|
282
|
+
*/
|
|
283
|
+
private static readonly ALLOWED_ORIGINS: ReadonlySet<string> = new Set([
|
|
284
|
+
"https://localhost:3141",
|
|
285
|
+
"https://pi-excel.localhost",
|
|
286
|
+
"https://dieuluucanh.github.io",
|
|
287
|
+
...parseExtraOrigins(),
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
private static resolveAllowOrigin(req: IncomingMessage): string | null {
|
|
291
|
+
const origin = req.headers.origin;
|
|
292
|
+
if (typeof origin !== "string" || origin.trim().length === 0) {
|
|
293
|
+
// Non-browser client (curl / tests / server tooling) — no CORS gate.
|
|
294
|
+
return "*";
|
|
295
|
+
}
|
|
296
|
+
if (OfficeBridgeServer.ALLOWED_ORIGINS.has(origin)) return origin;
|
|
297
|
+
return null; // Unknown browser origin → omit header; browser blocks.
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Minimal loopback HTTP surface used by the add-in's "Test connection"
|
|
302
|
+
* probe and by curl. WebSocket upgrades are handled by `ws` at the server
|
|
303
|
+
* level; ordinary requests (GET /health, OPTIONS preflight) land here.
|
|
304
|
+
* The endpoint is unauthenticated but exposes only bridge metadata.
|
|
305
|
+
*/
|
|
306
|
+
private handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
307
|
+
const urlRaw = req.url ?? "/";
|
|
308
|
+
let url: URL;
|
|
309
|
+
try {
|
|
310
|
+
url = new URL(urlRaw, "http://127.0.0.1");
|
|
311
|
+
} catch {
|
|
312
|
+
this.writeHttp(res, 400, { ok: false, error: "bad_request" }, req);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const allowOrigin = OfficeBridgeServer.resolveAllowOrigin(req);
|
|
316
|
+
|
|
317
|
+
if (req.method === "OPTIONS") {
|
|
318
|
+
this.writeHttp(res, 204, null, req, allowOrigin);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
323
|
+
const panes = this.attachedPanes().map((p) => ({
|
|
324
|
+
host: p.host,
|
|
325
|
+
paneId: p.paneId,
|
|
326
|
+
clientName: p.clientName,
|
|
327
|
+
connectedAt: p.connectedAt,
|
|
328
|
+
lastSeen: p.lastSeen,
|
|
329
|
+
model: p.model,
|
|
330
|
+
provider: p.provider,
|
|
331
|
+
}));
|
|
332
|
+
|
|
333
|
+
this.writeHttp(
|
|
334
|
+
res,
|
|
335
|
+
200,
|
|
336
|
+
{
|
|
337
|
+
ok: true,
|
|
338
|
+
service: this.serverName,
|
|
339
|
+
serverVersion: this.serverVersion,
|
|
340
|
+
capabilities: OfficeBridgeServer.CAPABILITIES,
|
|
341
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
342
|
+
piVersion: this.piVersion,
|
|
343
|
+
port: this.actualPort,
|
|
344
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
345
|
+
panes,
|
|
346
|
+
},
|
|
347
|
+
req,
|
|
348
|
+
allowOrigin,
|
|
349
|
+
);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
this.writeHttp(
|
|
354
|
+
res,
|
|
355
|
+
404,
|
|
356
|
+
{ ok: false, error: "not_found" },
|
|
357
|
+
req,
|
|
358
|
+
allowOrigin,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private writeHttp(
|
|
363
|
+
res: ServerResponse,
|
|
364
|
+
status: number,
|
|
365
|
+
body: unknown,
|
|
366
|
+
_req?: IncomingMessage,
|
|
367
|
+
allowOrigin: string | null = "*",
|
|
368
|
+
): void {
|
|
369
|
+
const headers: Record<string, string> = {
|
|
370
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
371
|
+
"Cache-Control": "no-store",
|
|
372
|
+
// Loopback-only server. The Private-Network header keeps the probe
|
|
373
|
+
// working from the hosted GitHub Pages origin (public → localhost).
|
|
374
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
375
|
+
"Access-Control-Allow-Headers": "*",
|
|
376
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
377
|
+
};
|
|
378
|
+
if (allowOrigin !== null) {
|
|
379
|
+
headers["Access-Control-Allow-Origin"] = allowOrigin;
|
|
380
|
+
headers["Vary"] = "Origin";
|
|
381
|
+
}
|
|
382
|
+
res.writeHead(status, headers);
|
|
383
|
+
if (status === 204 || body === null) {
|
|
384
|
+
res.end();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
res.end(JSON.stringify(body));
|
|
388
|
+
}
|
|
389
|
+
|
|
232
390
|
/* ── Internals ─────────────────────────────────────────────────────── */
|
|
233
391
|
|
|
234
392
|
private findPane(host: OfficeHostApp): AttachedPane | null {
|
|
235
|
-
const sorted = [...this.panes].sort(
|
|
393
|
+
const sorted = [...this.panes].sort(
|
|
394
|
+
(a, b) => b.connectedAt - a.connectedAt,
|
|
395
|
+
);
|
|
236
396
|
return sorted.find((p) => p.host === host) ?? null;
|
|
237
397
|
}
|
|
238
398
|
|
|
@@ -286,7 +446,10 @@ export class OfficeBridgeServer {
|
|
|
286
446
|
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
287
447
|
piVersion: this.piVersion,
|
|
288
448
|
serverName: this.serverName,
|
|
449
|
+
serverVersion: this.serverVersion,
|
|
450
|
+
capabilities: [...OfficeBridgeServer.CAPABILITIES],
|
|
289
451
|
});
|
|
452
|
+
this.notifyPanesChanged();
|
|
290
453
|
break;
|
|
291
454
|
}
|
|
292
455
|
case "ping": {
|
|
@@ -333,7 +496,9 @@ export class OfficeBridgeServer {
|
|
|
333
496
|
// Only the pane that received the call may answer it.
|
|
334
497
|
const pane = this.panes.find((p) => p.ws === ws);
|
|
335
498
|
if (!pane) {
|
|
336
|
-
call.reject(
|
|
499
|
+
call.reject(
|
|
500
|
+
new Error("office-bridge: pane disconnected before answering"),
|
|
501
|
+
);
|
|
337
502
|
return;
|
|
338
503
|
}
|
|
339
504
|
|
|
@@ -341,25 +506,42 @@ export class OfficeBridgeServer {
|
|
|
341
506
|
this.pending.delete(msg.id);
|
|
342
507
|
|
|
343
508
|
if (!msg.ok) {
|
|
344
|
-
call.reject(
|
|
509
|
+
call.reject(
|
|
510
|
+
new Error(`office-bridge: ${msg.error ?? "office tool failed"}`),
|
|
511
|
+
);
|
|
345
512
|
return;
|
|
346
513
|
}
|
|
347
514
|
|
|
348
|
-
const text =
|
|
349
|
-
|
|
350
|
-
|
|
515
|
+
const text =
|
|
516
|
+
msg.text.length > MAX_TEXT_CHARS
|
|
517
|
+
? `${msg.text.slice(0, MAX_TEXT_CHARS)}\n…[truncated: ${msg.text.length - MAX_TEXT_CHARS} chars]`
|
|
518
|
+
: msg.text;
|
|
351
519
|
|
|
352
520
|
let details: unknown = msg.details;
|
|
353
521
|
if (details !== undefined) {
|
|
354
522
|
const bytes = Buffer.byteLength(JSON.stringify(details));
|
|
355
523
|
if (bytes > MAX_DETAILS_BYTES) {
|
|
356
|
-
details = {
|
|
524
|
+
details = {
|
|
525
|
+
truncated: true,
|
|
526
|
+
note: `details exceeded ${MAX_DETAILS_BYTES} bytes`,
|
|
527
|
+
};
|
|
357
528
|
}
|
|
358
529
|
}
|
|
359
530
|
|
|
360
531
|
call.resolve({ text, details });
|
|
361
532
|
}
|
|
362
533
|
|
|
534
|
+
/** Notify the host that the attached-pane set changed. Never throws. */
|
|
535
|
+
private notifyPanesChanged(): void {
|
|
536
|
+
try {
|
|
537
|
+
this.handlers.onPanesChanged?.(this.attachedPanes());
|
|
538
|
+
} catch (error) {
|
|
539
|
+
console.error(
|
|
540
|
+
`[office-bridge] onPanesChanged handler failed: ${String(error)}`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
363
545
|
private detachPane(pane: AttachedPane): void {
|
|
364
546
|
const idx = this.panes.findIndex((p) => p === pane);
|
|
365
547
|
if (idx >= 0) this.panes.splice(idx, 1);
|
|
@@ -368,9 +550,15 @@ export class OfficeBridgeServer {
|
|
|
368
550
|
// reject them — tracked separately so we sweep all on disconnect).
|
|
369
551
|
for (const [id, call] of this.pending) {
|
|
370
552
|
clearTimeout(call.timer);
|
|
371
|
-
call.reject(
|
|
553
|
+
call.reject(
|
|
554
|
+
new Error(
|
|
555
|
+
"office-bridge: pane disconnected while the tool was running",
|
|
556
|
+
),
|
|
557
|
+
);
|
|
372
558
|
this.pending.delete(id);
|
|
373
559
|
}
|
|
560
|
+
|
|
561
|
+
this.notifyPanesChanged();
|
|
374
562
|
}
|
|
375
563
|
|
|
376
564
|
private startHeartbeat(): void {
|
|
@@ -387,7 +575,10 @@ export class OfficeBridgeServer {
|
|
|
387
575
|
}, HEARTBEAT_INTERVAL_MS);
|
|
388
576
|
}
|
|
389
577
|
|
|
390
|
-
private sendToPane(
|
|
578
|
+
private sendToPane(
|
|
579
|
+
pane: WebSocket | AttachedPane,
|
|
580
|
+
message: ServerMessage,
|
|
581
|
+
): boolean {
|
|
391
582
|
const ws = pane instanceof WebSocket ? pane : pane.ws;
|
|
392
583
|
if (ws.readyState !== WebSocket.OPEN) return false;
|
|
393
584
|
try {
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,9 @@ import type {
|
|
|
21
21
|
RegisteredCommand,
|
|
22
22
|
} from "@earendil-works/pi-coding-agent";
|
|
23
23
|
import { Type, type Static } from "typebox";
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { dirname, resolve } from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
24
27
|
|
|
25
28
|
import { BRIDGE_DEFAULT_PORT } from "./protocol.js";
|
|
26
29
|
import type { AttachedPane } from "./bridge-server.js";
|
|
@@ -46,6 +49,24 @@ function piVersion(): string | null {
|
|
|
46
49
|
}
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The bridge extension's own package version, read from the `package.json`
|
|
54
|
+
* that ships next to this module. Falls back to `"unknown"` so a bundled or
|
|
55
|
+
* relocated install never breaks the `welcome` frame.
|
|
56
|
+
*/
|
|
57
|
+
function serverVersion(): string {
|
|
58
|
+
try {
|
|
59
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
const raw = readFileSync(resolve(here, "..", "package.json"), "utf8");
|
|
61
|
+
const pkg = JSON.parse(raw) as { version?: unknown };
|
|
62
|
+
return typeof pkg.version === "string" && pkg.version.length > 0
|
|
63
|
+
? pkg.version
|
|
64
|
+
: "unknown";
|
|
65
|
+
} catch {
|
|
66
|
+
return "unknown";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
49
70
|
export default function (pi: ExtensionAPI): void {
|
|
50
71
|
let server: OfficeBridgeServer | null = null;
|
|
51
72
|
let currentCtx: ExtensionContext | null = null;
|
|
@@ -89,6 +110,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
89
110
|
ui.setStatus("office-bridge", `office: ${labels} attached (bridge :${port})`);
|
|
90
111
|
}
|
|
91
112
|
|
|
113
|
+
let panesChangedTimer: ReturnType<typeof setTimeout> | null = null;
|
|
114
|
+
|
|
115
|
+
/** Debounced status refresh triggered by pane attach/detach (~100 ms). */
|
|
116
|
+
function scheduleStatusUpdate(): void {
|
|
117
|
+
if (panesChangedTimer !== null) clearTimeout(panesChangedTimer);
|
|
118
|
+
panesChangedTimer = setTimeout(() => {
|
|
119
|
+
panesChangedTimer = null;
|
|
120
|
+
updateStatus();
|
|
121
|
+
}, 100);
|
|
122
|
+
}
|
|
123
|
+
|
|
92
124
|
/** Extract display text from an assistant message content payload. */
|
|
93
125
|
function flattenAssistantText(content: unknown): string {
|
|
94
126
|
if (typeof content === "string") return content;
|
|
@@ -120,6 +152,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
120
152
|
_onUpdate,
|
|
121
153
|
_ctx,
|
|
122
154
|
): Promise<AgentToolResult<unknown>> {
|
|
155
|
+
// SAFETY: `Params` is the TypeBox-derived shape of
|
|
156
|
+
// `descriptor.parameters` (always an object); the bridge forwards it
|
|
157
|
+
// verbatim as the Office.js args record, so this widening is sound.
|
|
123
158
|
const args = params as unknown as Record<string, unknown>;
|
|
124
159
|
const active = server;
|
|
125
160
|
if (!active?.isRunning) {
|
|
@@ -154,6 +189,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
154
189
|
const bridge = new OfficeBridgeServer({
|
|
155
190
|
port,
|
|
156
191
|
serverName: "pi-office-bridge",
|
|
192
|
+
serverVersion: serverVersion(),
|
|
157
193
|
piVersion: piVersion(),
|
|
158
194
|
handlers: {
|
|
159
195
|
onUserMessage: (text, pane) => {
|
|
@@ -163,6 +199,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
163
199
|
// triggers a turn; if a turn is running it is queued until it settles.
|
|
164
200
|
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
165
201
|
},
|
|
202
|
+
// Keep the TUI status line live: flip to "Excel attached" the moment
|
|
203
|
+
// the pane connects instead of waiting for the next message.
|
|
204
|
+
onPanesChanged: () => scheduleStatusUpdate(),
|
|
166
205
|
},
|
|
167
206
|
});
|
|
168
207
|
|
|
@@ -172,7 +211,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
172
211
|
ctx.ui.notify(`Office bridge listening on ws://127.0.0.1:${port}`, "info");
|
|
173
212
|
} catch (error) {
|
|
174
213
|
const message = error instanceof Error ? error.message : String(error);
|
|
175
|
-
|
|
214
|
+
const code = (error as NodeJS.ErrnoException | null)?.code;
|
|
215
|
+
if (code === "EADDRINUSE") {
|
|
216
|
+
ctx.ui.notify(
|
|
217
|
+
`Office bridge: port ${port} is already in use — another Pi process ` +
|
|
218
|
+
`is running the bridge. Start this one on a free port with ` +
|
|
219
|
+
`--${FLAG_PORT} <port> or PI_OFFICE_BRIDGE_PORT=<port>.`,
|
|
220
|
+
"error",
|
|
221
|
+
);
|
|
222
|
+
} else {
|
|
223
|
+
ctx.ui.notify(`Office bridge failed to start: ${message}`, "error");
|
|
224
|
+
}
|
|
176
225
|
console.error(`[office-bridge] start failed: ${message}`);
|
|
177
226
|
}
|
|
178
227
|
updateStatus();
|
|
@@ -181,6 +230,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
181
230
|
pi.on("session_shutdown", async () => {
|
|
182
231
|
currentCtx = null;
|
|
183
232
|
pendingReplyTargets.length = 0;
|
|
233
|
+
if (panesChangedTimer !== null) {
|
|
234
|
+
clearTimeout(panesChangedTimer);
|
|
235
|
+
panesChangedTimer = null;
|
|
236
|
+
}
|
|
184
237
|
const active = server;
|
|
185
238
|
server = null;
|
|
186
239
|
if (active) {
|
|
@@ -199,6 +252,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
199
252
|
|
|
200
253
|
const text = flattenAssistantText(event.message.content);
|
|
201
254
|
if (!text) return;
|
|
255
|
+
// SAFETY: assistant messages may carry an id that the Pi event type does
|
|
256
|
+
// not surface; read it through a narrow shape check before using it.
|
|
202
257
|
const maybeId = (event.message as unknown as { id?: unknown }).id;
|
|
203
258
|
server.broadcast({
|
|
204
259
|
type: "agent_message",
|