@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 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
- ## Commands
50
-
51
- | Command | Description |
52
- |---------|-------------|
53
- | `/office` | Show bridge status: port + attached apps (Excel/Word/PowerPoint) |
54
- | `/office-tools` | List every `office_*` tool registered |
55
-
56
- ## Configuration
57
-
58
- - **Port** — flag `--office-bridge-port <port>` or env `PI_OFFICE_BRIDGE_PORT`
59
- (default `38617`). The add-in connects to the same default; change both if you
60
- override it.
61
-
62
- ## Office tools
63
-
64
- The extension registers a `office_<host>_<op>` tool per op in the shared
65
- catalog. The catalog lives in
66
- [`src/office-tools.ts`](./src/office-tools.ts); op ids are namespaced by host:
67
-
68
- | Host | Ops |
69
- |------|-----|
70
- | Excel | `get_overview`, `read_range`, `write_cells`, `fill_formula`, `search_workbook` |
71
- | Word | `get_overview`, `read_document`, `insert_text`, `replace_text` |
72
- | PowerPoint | `get_overview`, `read_slide`, `add_slide`, `add_text_box` |
73
-
74
- The pane-side executors are the counterpart contract — see
75
- `packages/add-in/src/bridge/` (same repo). **When adding an op, update both
76
- sides** (see "Bridge contract" in the add-in README).
77
-
78
- ## Development
79
-
80
- ```bash
81
- npm run typecheck # typecheck against @earendil-works/pi-coding-agent 0.85.x
82
- npm run build # emit dist/ (for the node smoke tests)
83
- npm test # smoke test + end-to-end interop test (real client real server)
84
- ```
85
-
86
- `tests/pane-interop.mjs` wires the **real** add-in `PaneBridgeClient` to the
87
- **real** bridge server through the shared `@dieulc/pi-office-protocol` package
88
- the strongest proof the two halves agree on the wire format.
89
-
90
- ## Protocol
91
-
92
- The wire protocol is shared in `@dieulc/pi-office-protocol`
93
- (`packages/protocol`). Bump `BRIDGE_PROTOCOL_VERSION` on breaking changes.
94
-
95
- ## License
96
-
97
- MIT
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.1.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",
@@ -8,7 +8,11 @@
8
8
  */
9
9
 
10
10
  import { WebSocketServer, WebSocket } from "ws";
11
- import type { Server as HttpServer } from "node:http";
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 ? (addr as AddressInfo).port : 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
- const wss = new WebSocketServer({ server: httpServer, maxPayload: 16 * 1024 * 1024 });
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(new Error(`office-bridge: ${op} timed out after ${timeoutMs / 1000}s`));
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(new Error("office-bridge: pane disconnected before the tool call was sent"));
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((a, b) => b.connectedAt - a.connectedAt);
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(new Error("office-bridge: pane disconnected before answering"));
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(new Error(`office-bridge: ${msg.error ?? "office tool failed"}`));
509
+ call.reject(
510
+ new Error(`office-bridge: ${msg.error ?? "office tool failed"}`),
511
+ );
345
512
  return;
346
513
  }
347
514
 
348
- const text = msg.text.length > MAX_TEXT_CHARS
349
- ? `${msg.text.slice(0, MAX_TEXT_CHARS)}\n…[truncated: ${msg.text.length - MAX_TEXT_CHARS} chars]`
350
- : msg.text;
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 = { truncated: true, note: `details exceeded ${MAX_DETAILS_BYTES} bytes` };
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(new Error("office-bridge: pane disconnected while the tool was running"));
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(pane: WebSocket | AttachedPane, message: ServerMessage): boolean {
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
- ctx.ui.notify(`Office bridge failed to start: ${message}`, "error");
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",