@yunazgr/pi-companion 0.2.2 → 0.2.3
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 +43 -20
- package/package.json +1 -1
- package/src/ask.ts +6 -0
- package/src/bridge.ts +49 -9
- package/src/index.ts +31 -5
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ The UI is a static SvelteKit application. There is no Node runtime in production
|
|
|
13
13
|
<img src="docs/mobile.webp" alt="Session detail on a phone" width="28%" />
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
|
+
Screenshots use demo sessions, not private conversation data. See the [session menu](docs/session-menu.webp), [desktop session rows](docs/session-list-desktop.webp) and [mobile session list](docs/session-list.webp), plus [attachment previews](docs/attachments.webp), [file-grouped changes](docs/changes.webp) and [connection recovery](docs/connection-error.webp).
|
|
17
|
+
|
|
16
18
|
## Install
|
|
17
19
|
|
|
18
20
|
pi install npm:@yunazgr/pi-companion
|
|
@@ -21,7 +23,7 @@ or straight from git:
|
|
|
21
23
|
|
|
22
24
|
pi install git:github.com/ygrip/pi-companion
|
|
23
25
|
|
|
24
|
-
Then start Pi as usual. Installing the Pi extension is enough for normal use:
|
|
26
|
+
Then start Pi as usual. Installing the Pi extension is enough for normal use: the extension does not probe, connect to, download, or start a daemon until you run `/companion` in that session. On first use it downloads the matching sha256-verified daemon binary from GitHub Releases, caches it under `~/.pi/agent/pi-companion/bin/<version>/`, and reuses the same daemon across Pi sessions.
|
|
25
27
|
|
|
26
28
|
Use `/companion` in each session you want to share with paired devices. It enables remote access for that session and prints the local dashboard address. `/companion off` stops sharing that session.
|
|
27
29
|
|
|
@@ -60,22 +62,30 @@ You do **not** need to run `pi-companion-server` manually for a normal npm insta
|
|
|
60
62
|
|
|
61
63
|
The local administration surface and paired-device surface are separate listeners. If you expose Pi Companion through a tunnel, target only port 43722. Never expose port 43721.
|
|
62
64
|
|
|
65
|
+
## Install the web app on your phone
|
|
66
|
+
|
|
67
|
+
Open the daemon’s **HTTPS device URL** and pair the phone first. On Android/Chromium, choose **Install app** (or **Add to Home screen**) in the browser menu. On iPhone/iPad, open Safari and choose **Share → Add to Home Screen**; enable **Open as Web App** if offered. Installation does not grant additional session permissions.
|
|
68
|
+
|
|
69
|
+
Service workers require HTTPS; `http://localhost` is permitted for desktop development, but ordinary LAN HTTP addresses are not. An insecure-origin banner explains this in the UI. Install prompts vary by browser and may not appear inside in-app browsers.
|
|
70
|
+
|
|
71
|
+
After an online visit, the public app shell and visited immutable assets are cached. This is not an offline agent: sending, live activity, pairing, uploads and changes require the daemon connection. No credentials, API responses or session files are stored in the service-worker cache. Reopen online after an update to load the new build.
|
|
72
|
+
|
|
63
73
|
## How the daemon runs
|
|
64
74
|
|
|
65
75
|
For a normal install:
|
|
66
76
|
|
|
67
77
|
pi install npm:@yunazgr/pi-companion
|
|
68
78
|
|
|
69
|
-
That is enough. Start Pi normally
|
|
79
|
+
That is enough. Start Pi normally, then run `/companion` in the session you want to share. Only then does the extension connect or start the daemon on demand and keep its daemon version aligned with the installed extension. Updating the Pi package is therefore enough to update both pieces. You do not need a separate terminal, launch agent, system service, or manual `pi-companion-server` process.
|
|
70
80
|
|
|
71
81
|
The lifecycle is:
|
|
72
82
|
|
|
73
|
-
1. A Pi session loads the extension.
|
|
74
|
-
2.
|
|
75
|
-
3. If the daemon is already running,
|
|
83
|
+
1. A Pi session loads the extension without daemon network or startup work.
|
|
84
|
+
2. You run `/companion` in that session; the extension probes `http://127.0.0.1:43721`.
|
|
85
|
+
3. If the daemon is already running, this opted-in session connects to it.
|
|
76
86
|
4. If not, the extension resolves the daemon binary, downloading the matching GitHub Release on first use if necessary.
|
|
77
87
|
5. It starts the daemon detached in the background.
|
|
78
|
-
6. Other Pi sessions reuse the same daemon.
|
|
88
|
+
6. Other Pi sessions reuse the same daemon only after their own `/companion` command. New sessions start disconnected, even when another session has enabled Companion.
|
|
79
89
|
7. On extension upgrades, the extension compares its package version with the running daemon. If they differ, it stops the old local daemon, resolves/downloads the matching release, and starts the new daemon automatically.
|
|
80
90
|
8. The daemon keeps running independently until it is stopped or the machine restarts.
|
|
81
91
|
|
|
@@ -130,7 +140,7 @@ Inside any Pi session:
|
|
|
130
140
|
/companion off # stop sharing this session
|
|
131
141
|
/remote-control # toggle sharing on/off
|
|
132
142
|
|
|
133
|
-
A paired device cannot access sessions that have not explicitly enabled remote control.
|
|
143
|
+
A paired device cannot access sessions that have not explicitly enabled remote control. Turning `/remote-control` off or running `/companion off` closes this session’s daemon channel: it disappears from paired devices and becomes **Ended** on the local dashboard. Pi continues locally; local Pi history is untouched. Sharing does not reconnect itself while disabled. After the initial opt-in, `/remote-control` can enable sharing again; `/companion` can also resume it. Temporary uploads follow the usual disconnected-session cleanup below.
|
|
134
144
|
|
|
135
145
|
## Questions from Pi
|
|
136
146
|
|
|
@@ -179,7 +189,7 @@ Each session has an isolated temporary directory beneath the operating system te
|
|
|
179
189
|
|
|
180
190
|
On Unix the daemon attempts to set the Pi Companion and session directories to mode 0700.
|
|
181
191
|
|
|
182
|
-
The browser can upload a file from the
|
|
192
|
+
The browser can upload a file using the composer’s attachment button, or from **Shared files** in the session menu. Composer attachments show previews, file names, sizes and upload status; validation and upload failures appear beside the file. Image thumbnails are limited to safe raster image types. Removing a preview removes that attachment from the draft; deleting a shared upload remains an explicit action. The current daemon upload policy is checked before selection is uploaded, and the daemon remains authoritative. Current constraints:
|
|
183
193
|
|
|
184
194
|
- 25 MiB maximum per upload by default (Settings); a rejected or interrupted upload leaves no partial file
|
|
185
195
|
- optional MIME allowlist: the type guessed from the file name and any specific declared Content-Type must both be allowed
|
|
@@ -203,6 +213,18 @@ which destroys one temporary file by opaque id.
|
|
|
203
213
|
|
|
204
214
|
This means the agent can consume uploaded artifacts using its normal file capabilities while Pi Companion retains ownership of upload placement and cleanup.
|
|
205
215
|
|
|
216
|
+
## Workspace and tunnel connection errors
|
|
217
|
+
|
|
218
|
+
If a tunnel closes, the workspace stops, or your device loses its network, Companion shows an actionable **Workspace connection interrupted** notice with **Retry now** and reconnect instructions. On initial connection failure, it shows **Workspace unavailable**, not a misleading empty session list or “session not found.” Gateway errors (including HTTP 502/503/504 and tunnel-provider errors) are translated into readable messages rather than raw HTML or JSON parse errors. A browser cannot always distinguish a closed tunnel from a daemon, DNS or network failure, so these messages describe possible causes rather than claiming certainty.
|
|
219
|
+
|
|
220
|
+
- Ordinary API requests and live-connection handshakes time out after 10 seconds; uploads allow 60 seconds.
|
|
221
|
+
- Reconnection uses bounded backoff and retries when the device returns online. **Retry now** is available immediately.
|
|
222
|
+
- Previously loaded activity may be stale. Failed sends keep your draft; messages and uploads are never automatically resent.
|
|
223
|
+
- Network/tunnel errors preserve pairing. Explicit computer disconnects still require manual reconnect; revoked credentials still require pairing again.
|
|
224
|
+
- Restart the tunnel, keep the workspace running, and run `/companion` in Pi if the daemon is not running. If the tunnel URL changed, open the new HTTPS device URL; a different hostname/origin may require pairing again in that browser.
|
|
225
|
+
|
|
226
|
+
A previously cached PWA shell can also explain gateway failures on reload. If the app has never loaded successfully on that device and no shell is cached, the browser/tunnel provider owns the initial error page; Companion cannot display its own UI until its assets are reachable.
|
|
227
|
+
|
|
206
228
|
## Session identity
|
|
207
229
|
|
|
208
230
|
Every registered Pi session publishes a compact display model to the daemon:
|
|
@@ -215,7 +237,7 @@ Every registered Pi session publishes a compact display model to the daemon:
|
|
|
215
237
|
- working directory and process id
|
|
216
238
|
- remote-control state
|
|
217
239
|
|
|
218
|
-
Stopped sessions remain visible in the daemon registry so the dashboard does not lose context when a Pi process exits. Their controls are disabled and their temporary sandbox is cleaned after the reconnect grace period.
|
|
240
|
+
Stopped sessions remain visible in the daemon registry so the dashboard does not lose context when a Pi process exits. Their controls are disabled and their temporary sandbox is cleaned after the reconnect grace period. Use **Archive session** on the list or in the session menu to remove a disconnected/stopped entry. The daemon rejects removal of active, idle, waiting, or otherwise connected sessions. Archiving removes only the daemon entry; it never deletes Pi history, project files or other files on disk. Temporary uploads still follow the existing disconnect cleanup lifecycle. Reopening the Pi session and running `/companion` can register it again.
|
|
219
241
|
|
|
220
242
|
## UI
|
|
221
243
|
|
|
@@ -223,15 +245,15 @@ A single-page SvelteKit app, embedded in the daemon binary:
|
|
|
223
245
|
|
|
224
246
|
| Page | Who sees it | What it is for |
|
|
225
247
|
|---|---|---|
|
|
226
|
-
| Overview | everyone | Dot-field hero, live counts, sessions that need your answer, live
|
|
227
|
-
| Sessions | everyone | Searchable, filterable
|
|
228
|
-
| Session detail | everyone |
|
|
248
|
+
| Overview | everyone | Dot-field hero, live counts, sessions that need your answer, a labeled live-session table, and a spaced onboarding/help panel with Claymorphism actions |
|
|
249
|
+
| Sessions | everyone | Searchable, filterable full-width session rows (Working / Waiting / Ended), with short titles, workspace/model metadata, View details and safe Archive actions. Paired devices only see shared sessions |
|
|
250
|
+
| Session detail | everyone | Activity-first transcript with Markdown, highlighted code, Mermaid diagrams, collapsible tool output/thinking and question sheets. Compact header and navigation leave more space for the shell. The expanding composer includes Auto/Plan selection, attachment previews and a full-width labeled Send/Steer action; Plan uses the existing `/plan` command, not an agent permission setting. The top-right session menu opens Shared files, Changes and Archive session. The session header shows a status dot; clicking its title reveals status and session metadata. Changes are grouped by file with clear separators and a filename filter |
|
|
229
251
|
| Devices, Settings | local console only | Pairing, connection status, disconnect and revoke; daemon settings with a save bar that appears only for unsaved edits |
|
|
230
252
|
| Help, Privacy, Terms | everyone | Setup stepper, commands, tools, troubleshooting; data handling; terms of use. Help is opened from the Overview |
|
|
231
253
|
|
|
232
254
|
The Live indicator in the header and sidebar opens the connection sheet (status, version and, on this computer, the console and device addresses and data folders).
|
|
233
255
|
|
|
234
|
-
Git problems
|
|
256
|
+
Git problems in the Changes panel (for example a folder that is not a git repository) show as a toast, never in the transcript.
|
|
235
257
|
|
|
236
258
|
Design notes:
|
|
237
259
|
|
|
@@ -239,12 +261,13 @@ Design notes:
|
|
|
239
261
|
- claymorphism: solid surfaces with an outer drop shadow plus inset highlight and shade (no blur or glass); small icons are Lucide line icons on raised clay wells, feature art is 3D clay renders from [3dicons](https://3dicons.co) (CC0)
|
|
240
262
|
- warm amber on graphite or paper, matching the logo; system fonts only, so nothing loads from the network. highlight.js and Mermaid load only when a reply contains code or a diagram
|
|
241
263
|
- the window never scrolls. The sidebar, page content, activity feed, file list, diff, chips and tabs each scroll inside their own container, and the composer and mobile tab bar stay put
|
|
264
|
+
- the shell gently breathes while the displayed session is working, with a slower [thinking animation](docs/thinking.webp) and a soft progress sweep on the mobile header; it stops when idle or disconnected and respects reduced-motion preferences
|
|
242
265
|
- the activity feed follows new output and stops following when you scroll up ("Jump to latest" brings you back)
|
|
243
|
-
- installable PWA:
|
|
266
|
+
- installable PWA: scoped manifest with explicit 192×192 and 512×512 PNG icons plus a maskable icon, standalone display mode, Apple home-screen metadata, and a service worker that caches only the public shell and static assets; API/session traffic and uploads are never cached
|
|
244
267
|
- mobile: bottom tab bar, safe-area insets, 44px touch targets, 16px inputs (no iOS zoom), Enter inserts a newline on touch keyboards
|
|
245
|
-
- accessibility: skip link, visible focus rings,
|
|
268
|
+
- accessibility: skip link, visible focus rings, labeled controls and table metadata, accessible session menus, live regions for the feed and questions, reduced-motion support
|
|
246
269
|
- the dot field is Raksara's component, loaded when the browser is idle, paused when hidden, and static under reduced motion
|
|
247
|
-
- dropping a file anywhere outside the
|
|
270
|
+
- dropping a file anywhere outside the Shared files drop zone is ignored. Without this, the browser tries to open the file itself (Firefox reports this as "may not load or link to file:///")
|
|
248
271
|
|
|
249
272
|
### Caching and updates
|
|
250
273
|
|
|
@@ -302,7 +325,7 @@ Type checks cover the extension (`tsc`) and the UI (`svelte-check`, warnings fai
|
|
|
302
325
|
|
|
303
326
|
npm run check
|
|
304
327
|
|
|
305
|
-
Tests cover the question relay (`node --test`)
|
|
328
|
+
Tests cover the question relay, deferred per-session startup and remote-off/re-enable behavior (including an executable extension/bridge harness), attachment validation and path references, changed-file grouping/filtering, workspace error classification and actual connection-store recovery, install icon dimensions and service-worker cache isolation (`node --test`), plus the daemon's wire protocol, safe session archiving, pairing expiry and code throttling, origin policy, bridge and device reconnects, path traversal, upload limits, MIME allowlist, state-file permissions and multi-session isolation (`cargo test`):
|
|
306
329
|
|
|
307
330
|
npm test
|
|
308
331
|
|
|
@@ -312,7 +335,7 @@ Then, in another shell, start Pi with the extension from this checkout:
|
|
|
312
335
|
|
|
313
336
|
pi -e ./src/index.ts
|
|
314
337
|
|
|
315
|
-
|
|
338
|
+
After you run `/companion`, the extension detects the development daemon already listening on port 43721 and does not launch another daemon.
|
|
316
339
|
|
|
317
340
|
For hot-reloading UI work, keep the daemon running and use:
|
|
318
341
|
|
|
@@ -320,13 +343,13 @@ For hot-reloading UI work, keep the daemon running and use:
|
|
|
320
343
|
|
|
321
344
|
which proxies /api and /ws to the daemon on 43721.
|
|
322
345
|
|
|
323
|
-
If you start Pi from the checkout without running `npm run serve`,
|
|
346
|
+
If you start Pi from the checkout without running `npm run serve`, running `/companion` can still auto-start a local daemon build if one exists under `server/target/debug` or `server/target/release`. For predictable UI work, prefer `npm run serve` in one terminal and `pi -e ./src/index.ts` in another.
|
|
324
347
|
|
|
325
348
|
To test the same behavior as a published install, stop any development daemon first and install the package normally:
|
|
326
349
|
|
|
327
350
|
pi install npm:@yunazgr/pi-companion
|
|
328
351
|
|
|
329
|
-
Then start Pi
|
|
352
|
+
Then start Pi and run `/companion`. The extension downloads and starts the released daemon on demand; merely starting Pi does neither.
|
|
330
353
|
|
|
331
354
|
## Releases
|
|
332
355
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunazgr/pi-companion",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Local-first remote control for Pi sessions: live activity feed, steering, git diff, file drop and phone pairing from a single Rust daemon.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/ask.ts
CHANGED
|
@@ -28,6 +28,7 @@ export function relayDialogs(ui: ExtensionUIContext, channel: AskChannel, tools:
|
|
|
28
28
|
const confirm = ui.confirm.bind(ui);
|
|
29
29
|
const input = ui.input.bind(ui);
|
|
30
30
|
const custom = ui.custom.bind(ui);
|
|
31
|
+
const originals = { select: ui.select, confirm: ui.confirm, input: ui.input, custom: ui.custom };
|
|
31
32
|
|
|
32
33
|
ui.custom = (factory, options) => {
|
|
33
34
|
const complete = tools.claim();
|
|
@@ -73,6 +74,11 @@ export function relayDialogs(ui: ExtensionUIContext, channel: AskChannel, tools:
|
|
|
73
74
|
undefined,
|
|
74
75
|
opts?.signal
|
|
75
76
|
);
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
Object.assign(ui, originals);
|
|
80
|
+
delete target[RELAYED];
|
|
81
|
+
};
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
async function race<T>(
|
package/src/bridge.ts
CHANGED
|
@@ -17,6 +17,8 @@ export class CompanionBridge implements AskChannel {
|
|
|
17
17
|
private ctx?: ExtensionContext;
|
|
18
18
|
private reconnect?: NodeJS.Timeout;
|
|
19
19
|
private closed = false;
|
|
20
|
+
private activated = false;
|
|
21
|
+
private restoreDialogs?: () => void;
|
|
20
22
|
private connecting = false;
|
|
21
23
|
private everConnected = false;
|
|
22
24
|
private downSince = 0;
|
|
@@ -40,11 +42,13 @@ export class CompanionBridge implements AskChannel {
|
|
|
40
42
|
|
|
41
43
|
setContext(ctx: ExtensionContext) {
|
|
42
44
|
this.ctx = ctx;
|
|
43
|
-
if (ctx.hasUI) relayDialogs(ctx.ui, this, this.toolDialogs);
|
|
45
|
+
if (this.activated && this.snapshot.remoteEnabled && ctx.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(ctx.ui, this, this.toolDialogs);
|
|
46
|
+
const name = this.pi.getSessionName() ?? this.snapshot.name;
|
|
44
47
|
this.snapshot = {
|
|
45
48
|
...this.snapshot,
|
|
49
|
+
name,
|
|
46
50
|
cwd: ctx.cwd,
|
|
47
|
-
shortTitle:
|
|
51
|
+
shortTitle: name?.trim() || ctx.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi",
|
|
48
52
|
mainModel: ctx.model?.id,
|
|
49
53
|
effort: ctx.thinkingLevel,
|
|
50
54
|
status: ctx.isIdle() ? "idle" : "active"
|
|
@@ -53,12 +57,32 @@ export class CompanionBridge implements AskChannel {
|
|
|
53
57
|
|
|
54
58
|
setName(name?: string) {
|
|
55
59
|
this.snapshot.name = name;
|
|
56
|
-
this.
|
|
60
|
+
this.snapshot.shortTitle = name?.trim() || this.snapshot.cwd.split(/[\\/]/).filter(Boolean).pop() || "Pi";
|
|
61
|
+
this.send({ type: "session.update", session: { name, shortTitle: this.snapshot.shortTitle } });
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
setRemoteEnabled(remoteEnabled: boolean) {
|
|
60
65
|
this.snapshot.remoteEnabled = remoteEnabled;
|
|
61
|
-
this.
|
|
66
|
+
this.snapshot.status = remoteEnabled ? (this.ctx?.isIdle() === false ? "active" : "idle") : "stopped";
|
|
67
|
+
this.send({ type: "session.update", session: { remoteEnabled, status: this.snapshot.status } });
|
|
68
|
+
if (remoteEnabled) {
|
|
69
|
+
if (this.isActivated()) { this.activate(); void this.connect(); }
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// End only Companion sharing: Pi itself and its local history keep running.
|
|
73
|
+
this.restoreDialogs?.();
|
|
74
|
+
this.restoreDialogs = undefined;
|
|
75
|
+
for (const entry of [...this.asks.values()]) entry.settle(null);
|
|
76
|
+
for (const pending of this.pendingDeletes.values()) {
|
|
77
|
+
clearTimeout(pending.timer);
|
|
78
|
+
pending.resolve({ ok: false, error: "Session sharing ended." });
|
|
79
|
+
}
|
|
80
|
+
this.pendingDeletes.clear();
|
|
81
|
+
if (this.reconnect) clearTimeout(this.reconnect);
|
|
82
|
+
this.reconnect = undefined;
|
|
83
|
+
const ws = this.ws;
|
|
84
|
+
this.ws = undefined;
|
|
85
|
+
ws?.close();
|
|
62
86
|
}
|
|
63
87
|
|
|
64
88
|
isRemoteEnabled() {
|
|
@@ -69,9 +93,19 @@ export class CompanionBridge implements AskChannel {
|
|
|
69
93
|
return [...this.tempFiles];
|
|
70
94
|
}
|
|
71
95
|
|
|
72
|
-
/**
|
|
96
|
+
/** Only an explicit /companion command may activate this session's bridge. */
|
|
97
|
+
activate() {
|
|
98
|
+
this.activated = true;
|
|
99
|
+
if (this.snapshot.remoteEnabled && this.ctx?.hasUI && !this.restoreDialogs) this.restoreDialogs = relayDialogs(this.ctx.ui, this, this.toolDialogs);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
isActivated() {
|
|
103
|
+
return this.activated && !this.closed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Connect to the shared daemon only after this session opted in. */
|
|
73
107
|
async connect() {
|
|
74
|
-
if (this.closed || this.connecting) return;
|
|
108
|
+
if (!this.activated || !this.snapshot.remoteEnabled || this.closed || this.connecting) return;
|
|
75
109
|
if (this.ws && this.ws.readyState <= WebSocket.OPEN) return; // connecting or already live
|
|
76
110
|
this.connecting = true;
|
|
77
111
|
try {
|
|
@@ -79,7 +113,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
79
113
|
// period to come back before launching a replacement.
|
|
80
114
|
const allowSpawn = !this.everConnected || (this.downSince > 0 && Date.now() - this.downSince > 20_000);
|
|
81
115
|
if (allowSpawn) await ensureDaemon((message, level = "info") => this.log(message, level));
|
|
82
|
-
if (this.closed) return;
|
|
116
|
+
if (this.closed || !this.snapshot.remoteEnabled) return;
|
|
83
117
|
this.open();
|
|
84
118
|
} finally {
|
|
85
119
|
this.connecting = false;
|
|
@@ -91,12 +125,14 @@ export class CompanionBridge implements AskChannel {
|
|
|
91
125
|
const ws = new WebSocket(base.replace(/\/$/, "") + "/ws/bridge/" + this.sessionId);
|
|
92
126
|
this.ws = ws;
|
|
93
127
|
ws.on("open", () => {
|
|
128
|
+
if (this.ws !== ws || this.closed || !this.snapshot.remoteEnabled) { ws.close(); return; }
|
|
94
129
|
this.everConnected = true;
|
|
95
130
|
this.downSince = 0;
|
|
96
131
|
this.retryDelay = 1000;
|
|
97
132
|
this.send({ type: "register", session: this.snapshot });
|
|
98
133
|
});
|
|
99
134
|
ws.on("message", raw => {
|
|
135
|
+
if (this.ws !== ws || this.closed || !this.snapshot.remoteEnabled) return;
|
|
100
136
|
try { void this.handle(JSON.parse(raw.toString()) as ServerMessage); }
|
|
101
137
|
catch (error) { this.send({ type: "error", message: "Invalid server message: " + String(error) }); }
|
|
102
138
|
});
|
|
@@ -116,6 +152,9 @@ export class CompanionBridge implements AskChannel {
|
|
|
116
152
|
|
|
117
153
|
close() {
|
|
118
154
|
this.closed = true;
|
|
155
|
+
this.restoreDialogs?.();
|
|
156
|
+
this.restoreDialogs = undefined;
|
|
157
|
+
for (const entry of [...this.asks.values()]) entry.settle(null);
|
|
119
158
|
if (this.reconnect) clearTimeout(this.reconnect);
|
|
120
159
|
this.ws?.close();
|
|
121
160
|
}
|
|
@@ -136,7 +175,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
136
175
|
ask(input: AskInput, signal?: AbortSignal) {
|
|
137
176
|
const request: AskRequest = { ...input, requestId: randomUUID(), createdAt: new Date().toISOString() };
|
|
138
177
|
return new Promise<AskAnswers | null>(resolve => {
|
|
139
|
-
if (signal?.aborted) return resolve(null);
|
|
178
|
+
if (!this.isActivated() || !this.snapshot.remoteEnabled || signal?.aborted) return resolve(null);
|
|
140
179
|
const onAbort = () => settle(null);
|
|
141
180
|
const settle = (answers: AskAnswers | null) => {
|
|
142
181
|
if (!this.asks.delete(request.requestId)) return;
|
|
@@ -157,6 +196,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
157
196
|
}
|
|
158
197
|
|
|
159
198
|
async deleteTempFile(fileId: string) {
|
|
199
|
+
if (!this.isActivated()) return { ok: false, error: "Run /companion first to enable this session." };
|
|
160
200
|
const requestId = randomUUID();
|
|
161
201
|
this.send({ type: "file.delete", requestId, fileId });
|
|
162
202
|
return await new Promise<{ ok: boolean; error?: string }>(resolve => {
|
|
@@ -174,7 +214,7 @@ export class CompanionBridge implements AskChannel {
|
|
|
174
214
|
|
|
175
215
|
private scheduleReconnect() {
|
|
176
216
|
if (this.reconnect) clearTimeout(this.reconnect);
|
|
177
|
-
if (this.closed) return;
|
|
217
|
+
if (!this.activated || !this.snapshot.remoteEnabled || this.closed) return;
|
|
178
218
|
this.reconnect = setTimeout(() => void this.connect(), this.retryDelay);
|
|
179
219
|
this.retryDelay = Math.min(this.retryDelay * 2, 10_000);
|
|
180
220
|
}
|
package/src/index.ts
CHANGED
|
@@ -68,11 +68,15 @@ function resultText(result: unknown) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export default function companionExtension(pi: ExtensionAPI) {
|
|
71
|
-
|
|
71
|
+
let bridge = new CompanionBridge(pi);
|
|
72
|
+
let sharingGeneration = 0;
|
|
72
73
|
|
|
73
74
|
pi.on("session_start", (_event, ctx) => {
|
|
75
|
+
// Switching or starting a Pi session must not inherit the previous opt-in.
|
|
76
|
+
sharingGeneration += 1;
|
|
77
|
+
bridge.close();
|
|
78
|
+
bridge = new CompanionBridge(pi);
|
|
74
79
|
bridge.setContext(ctx);
|
|
75
|
-
void bridge.connect();
|
|
76
80
|
bridge.emit("session.start", { cwd: ctx.cwd });
|
|
77
81
|
});
|
|
78
82
|
pi.on("session_info_changed", (event, ctx) => {
|
|
@@ -118,6 +122,7 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
118
122
|
});
|
|
119
123
|
});
|
|
120
124
|
pi.on("session_shutdown", event => {
|
|
125
|
+
sharingGeneration += 1;
|
|
121
126
|
bridge.updateStatus("stopped");
|
|
122
127
|
bridge.emit("session.shutdown", event);
|
|
123
128
|
bridge.close();
|
|
@@ -131,6 +136,12 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
131
136
|
parameters: AskParams,
|
|
132
137
|
executionMode: "sequential",
|
|
133
138
|
async execute(_toolCallId, params, signal) {
|
|
139
|
+
if (!bridge.isActivated()) {
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: "text", text: "Pi Companion is not enabled for this session. Run /companion before asking through the dashboard." }],
|
|
142
|
+
details: { questions: [], answers: null }
|
|
143
|
+
};
|
|
144
|
+
}
|
|
134
145
|
const items = params.questions?.length
|
|
135
146
|
? params.questions
|
|
136
147
|
: params.question
|
|
@@ -196,18 +207,28 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
196
207
|
pi.registerCommand("companion", {
|
|
197
208
|
description: "Enable Pi Companion for this session and show the dashboard address (`/companion off` to stop sharing)",
|
|
198
209
|
handler: async (args, ctx) => {
|
|
210
|
+
const generation = ++sharingGeneration;
|
|
211
|
+
bridge.setContext(ctx);
|
|
212
|
+
if (args.trim().toLowerCase() === "off") {
|
|
213
|
+
bridge.setRemoteEnabled(false);
|
|
214
|
+
ctx.ui.notify("Pi Companion sharing ended for this session. Pi continues locally.", "info");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const currentBridge = bridge;
|
|
199
218
|
const up = await ensureDaemon((message, level = "info") => ctx.ui.notify(message, level));
|
|
219
|
+
if (bridge !== currentBridge || generation !== sharingGeneration) return;
|
|
200
220
|
if (!up) {
|
|
201
221
|
ctx.ui.notify("Pi Companion daemon is not reachable at " + adminHttpUrl(), "warning");
|
|
202
222
|
return;
|
|
203
223
|
}
|
|
224
|
+
bridge.activate();
|
|
204
225
|
void bridge.connect();
|
|
205
|
-
const enabled =
|
|
226
|
+
const enabled = true;
|
|
206
227
|
bridge.setRemoteEnabled(enabled);
|
|
207
228
|
ctx.ui.notify(
|
|
208
229
|
enabled
|
|
209
230
|
? "Pi Companion enabled for this session: " + adminHttpUrl() + " (paired devices can now see it)"
|
|
210
|
-
: "Pi Companion
|
|
231
|
+
: "Pi Companion sharing ended for this session. Pi continues locally.",
|
|
211
232
|
"info"
|
|
212
233
|
);
|
|
213
234
|
}
|
|
@@ -216,12 +237,17 @@ export default function companionExtension(pi: ExtensionAPI) {
|
|
|
216
237
|
pi.registerCommand("remote-control", {
|
|
217
238
|
description: "Toggle remote control for this Pi session",
|
|
218
239
|
handler: async (_args, ctx) => {
|
|
240
|
+
sharingGeneration += 1;
|
|
241
|
+
if (!bridge.isActivated()) {
|
|
242
|
+
ctx.ui.notify("Run /companion first to enable this session's daemon connection.", "warning");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
219
245
|
const enabled = !bridge.isRemoteEnabled();
|
|
220
246
|
bridge.setRemoteEnabled(enabled);
|
|
221
247
|
ctx.ui.notify(
|
|
222
248
|
enabled
|
|
223
249
|
? "Remote control enabled for this session. Pair a device from Pi Companion."
|
|
224
|
-
: "Remote control disabled
|
|
250
|
+
: "Remote control disabled; this Companion session has ended. Pi continues locally.",
|
|
225
251
|
enabled ? "info" : "warning"
|
|
226
252
|
);
|
|
227
253
|
}
|