@yunazgr/pi-companion 0.2.0 → 0.2.2
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 +44 -10
- package/package.json +8 -3
- package/src/daemon.ts +67 -11
package/README.md
CHANGED
|
@@ -21,7 +21,11 @@ or straight from git:
|
|
|
21
21
|
|
|
22
22
|
pi install git:github.com/ygrip/pi-companion
|
|
23
23
|
|
|
24
|
-
Then start
|
|
24
|
+
Then start Pi as usual. Installing the Pi extension is enough for normal use: when a Pi session starts, the extension probes the local admin port and automatically starts the daemon if needed. 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
|
+
|
|
26
|
+
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
|
+
|
|
28
|
+
You do **not** need to run `pi-companion-server` manually for a normal npm install. Manual daemon startup is mainly for local development, debugging, or when `PI_COMPANION_AUTOSTART=0` is set.
|
|
25
29
|
|
|
26
30
|
## Architecture
|
|
27
31
|
|
|
@@ -56,7 +60,24 @@ Then start pi as usual and run /companion in each session you want to follow fro
|
|
|
56
60
|
|
|
57
61
|
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.
|
|
58
62
|
|
|
59
|
-
##
|
|
63
|
+
## How the daemon runs
|
|
64
|
+
|
|
65
|
+
For a normal install:
|
|
66
|
+
|
|
67
|
+
pi install npm:@yunazgr/pi-companion
|
|
68
|
+
|
|
69
|
+
That is enough. Start Pi normally; the extension will 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
|
+
|
|
71
|
+
The lifecycle is:
|
|
72
|
+
|
|
73
|
+
1. A Pi session loads the extension.
|
|
74
|
+
2. The extension probes `http://127.0.0.1:43721`.
|
|
75
|
+
3. If the daemon is already running, the session connects to it.
|
|
76
|
+
4. If not, the extension resolves the daemon binary, downloading the matching GitHub Release on first use if necessary.
|
|
77
|
+
5. It starts the daemon detached in the background.
|
|
78
|
+
6. Other Pi sessions reuse the same daemon.
|
|
79
|
+
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
|
+
8. The daemon keeps running independently until it is stopped or the machine restarts.
|
|
60
81
|
|
|
61
82
|
There is exactly one daemon per machine, shared by every Pi session. The extension manages it with no configuration:
|
|
62
83
|
|
|
@@ -122,7 +143,7 @@ Dialogs from other extensions (`ctx.ui.select`, `ctx.ui.confirm`, `ctx.ui.input`
|
|
|
122
143
|
Pairing is daemon-wide rather than session-specific. Everything lives on the **Devices** page of the local console (port 43721):
|
|
123
144
|
|
|
124
145
|
1. Choose **Pair a device**. The daemon creates a single-use invitation (5 minutes by default, configurable in Settings) and shows it as a QR code, a copyable link and a short code (`XXXX-XXXX`).
|
|
125
|
-
2. On the phone, open the paired-device address. An unpaired device gets a connect screen: scan the QR code with the in-app camera (
|
|
146
|
+
2. On the phone, open the paired-device address. An unpaired device gets a connect screen: scan the QR code with the in-app camera (HTTPS required), use the phone's camera app to open the invitation link, or type the short code shown beside the QR, then give the device a name. Pi Companion explains why camera access is needed before triggering the browser permission prompt.
|
|
126
147
|
3. The daemon issues a long random credential and stores only its SHA-256 hash.
|
|
127
148
|
|
|
128
149
|
Typed codes are short, so wrong ones are counted: after ten misses within ten minutes every open invitation is withdrawn and code entry is refused (HTTP 429) until the window passes.
|
|
@@ -219,6 +240,7 @@ Design notes:
|
|
|
219
240
|
- 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
|
|
220
241
|
- 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
|
|
221
242
|
- the activity feed follows new output and stops following when you scroll up ("Jump to latest" brings you back)
|
|
243
|
+
- installable PWA: web app manifest, standalone display mode, Apple home-screen metadata, and a small service worker that caches only the shell and immutable assets; API/session traffic is never cached
|
|
222
244
|
- mobile: bottom tab bar, safe-area insets, 44px touch targets, 16px inputs (no iOS zoom), Enter inserts a newline on touch keyboards
|
|
223
245
|
- accessibility: skip link, visible focus rings, arrow-key tabs, labelled controls, live regions for the feed and questions, reduced-motion support
|
|
224
246
|
- the dot field is Raksara's component, loaded when the browser is idle, paused when hidden, and static under reduced motion
|
|
@@ -254,16 +276,20 @@ The initial slice supports:
|
|
|
254
276
|
|
|
255
277
|
There is deliberately no arbitrary shell, arbitrary tool invocation, or arbitrary filesystem API.
|
|
256
278
|
|
|
257
|
-
##
|
|
279
|
+
## Running locally from source
|
|
280
|
+
|
|
281
|
+
Use this only when developing Pi Companion itself. A published install does not require these steps.
|
|
258
282
|
|
|
259
283
|
Requirements: Node 22.17+ and stable Rust.
|
|
260
284
|
|
|
285
|
+
Clone the repository, then:
|
|
286
|
+
|
|
261
287
|
npm install
|
|
262
288
|
npm run serve
|
|
263
289
|
|
|
264
|
-
|
|
290
|
+
`npm run serve` builds the embedded Svelte UI and starts the Rust daemon in the foreground. It prints:
|
|
265
291
|
|
|
266
|
-
Pi Companion v0.
|
|
292
|
+
Pi Companion v0.2.2
|
|
267
293
|
|
|
268
294
|
Console http://127.0.0.1:43721
|
|
269
295
|
Paired devices http://127.0.0.1:43722
|
|
@@ -286,17 +312,25 @@ Then, in another shell, start Pi with the extension from this checkout:
|
|
|
286
312
|
|
|
287
313
|
pi -e ./src/index.ts
|
|
288
314
|
|
|
315
|
+
Because the development daemon is already listening on port 43721, the extension detects it and does not launch another daemon.
|
|
316
|
+
|
|
289
317
|
For hot-reloading UI work, keep the daemon running and use:
|
|
290
318
|
|
|
291
319
|
npm run ui:dev
|
|
292
320
|
|
|
293
321
|
which proxies /api and /ws to the daemon on 43721.
|
|
294
322
|
|
|
295
|
-
|
|
323
|
+
If you start Pi from the checkout without running `npm run serve`, the extension 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
|
+
|
|
325
|
+
To test the same behavior as a published install, stop any development daemon first and install the package normally:
|
|
326
|
+
|
|
327
|
+
pi install npm:@yunazgr/pi-companion
|
|
328
|
+
|
|
329
|
+
Then start Pi. The extension downloads and starts the released daemon automatically.
|
|
296
330
|
|
|
297
331
|
## Releases
|
|
298
332
|
|
|
299
|
-
The package carries the pi-package keyword,
|
|
333
|
+
The package carries the required `pi-package` keyword, which makes the published npm package eligible for discovery in the Pi package gallery (https://pi.dev/packages). The manifest also includes focused discovery keywords such as `pi-extension`, `pi-coding-agent`, `remote-control`, and `session-dashboard`. Host-provided packages (@earendil-works/pi-coding-agent, typebox) are peer dependencies, as Pi requires.
|
|
300
334
|
|
|
301
335
|
GitHub Actions run only for release tags. Pushing ordinary commits or opening pull requests does not trigger any workflow; CI can also be started manually with workflow_dispatch.
|
|
302
336
|
|
|
@@ -304,9 +338,9 @@ A tag matching vX.Y.Z triggers both the CI and release workflows.
|
|
|
304
338
|
|
|
305
339
|
Before publishing, the workflow requires X.Y.Z to match both package.json and server/Cargo.toml. It then runs TypeScript and Rust checks, builds native daemon archives for Linux x86_64, macOS arm64, macOS x86_64, and Windows x86_64, generates SHA-256 checksums, and creates a GitHub Release.
|
|
306
340
|
|
|
307
|
-
Only after the GitHub Release exists,
|
|
341
|
+
Only after the GitHub Release exists, the same tag publishes @yunazgr/pi-companion to npm with provenance through npm Trusted Publishing (GitHub OIDC). No long-lived NPM_TOKEN is required. The trusted publisher should point to GitHub owner ygrip, repository pi-companion, workflow release.yml. Git installs need no npm credential.
|
|
308
342
|
|
|
309
|
-
On first use the extension downloads `pi-companion-server-<os>-<arch>` for its own package version (`releases/download/v<version>/`), verifies it against `SHA256SUMS`, and caches it under `~/.pi/agent/pi-companion/bin/<version>/`. A git install from a branch that is ahead of the newest tag falls back to the latest release.
|
|
343
|
+
On first use the extension downloads `pi-companion-server-<os>-<arch>` for its own package version (`releases/download/v<version>/`), verifies it against `SHA256SUMS`, and caches it under `~/.pi/agent/pi-companion/bin/<version>/`. On later extension upgrades it detects a running daemon with a different version, stops it locally, and starts the matching daemon automatically. A git install from a branch that is ahead of the newest tag falls back to the latest release.
|
|
310
344
|
|
|
311
345
|
## Security boundary
|
|
312
346
|
|
package/package.json
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yunazgr/pi-companion",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
7
|
-
"pi",
|
|
8
7
|
"pi-extension",
|
|
9
8
|
"pi-coding-agent",
|
|
9
|
+
"pi",
|
|
10
10
|
"remote-control",
|
|
11
|
+
"remote-agent",
|
|
11
12
|
"companion",
|
|
12
|
-
"dashboard",
|
|
13
|
+
"session-dashboard",
|
|
14
|
+
"developer-tools",
|
|
13
15
|
"mobile"
|
|
14
16
|
],
|
|
15
17
|
"homepage": "https://github.com/ygrip/pi-companion#readme",
|
|
@@ -84,5 +86,8 @@
|
|
|
84
86
|
"@google/genai": false,
|
|
85
87
|
"protobufjs": false,
|
|
86
88
|
"fsevents": false
|
|
89
|
+
},
|
|
90
|
+
"publishConfig": {
|
|
91
|
+
"access": "public"
|
|
87
92
|
}
|
|
88
93
|
}
|
package/src/daemon.ts
CHANGED
|
@@ -40,15 +40,22 @@ function isLocalTarget() {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
type DaemonContext = { version?: string; pid?: number };
|
|
44
|
+
|
|
45
|
+
async function daemonContext(timeoutMs = 800): Promise<DaemonContext | null> {
|
|
44
46
|
try {
|
|
45
47
|
const response = await fetch(adminHttpUrl() + "/api/context", { signal: AbortSignal.timeout(timeoutMs) });
|
|
46
|
-
|
|
48
|
+
if (!response.ok) return null;
|
|
49
|
+
return await response.json() as DaemonContext;
|
|
47
50
|
} catch {
|
|
48
|
-
return
|
|
51
|
+
return null;
|
|
49
52
|
}
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
export async function isDaemonUp(timeoutMs = 800) {
|
|
56
|
+
return Boolean(await daemonContext(timeoutMs));
|
|
57
|
+
}
|
|
58
|
+
|
|
52
59
|
function packageVersion() {
|
|
53
60
|
try {
|
|
54
61
|
return JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8")).version as string;
|
|
@@ -182,35 +189,84 @@ async function acquireLock() {
|
|
|
182
189
|
}
|
|
183
190
|
}
|
|
184
191
|
|
|
185
|
-
async function waitForDaemon(ms: number) {
|
|
192
|
+
async function waitForDaemon(ms: number, expectedVersion?: string) {
|
|
186
193
|
const deadline = Date.now() + ms;
|
|
187
194
|
while (Date.now() < deadline) {
|
|
188
|
-
|
|
195
|
+
const context = await daemonContext(500);
|
|
196
|
+
if (context && (!expectedVersion || context.version === expectedVersion)) return true;
|
|
189
197
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
190
198
|
}
|
|
191
199
|
return false;
|
|
192
200
|
}
|
|
193
201
|
|
|
202
|
+
async function waitForDaemonDown(ms: number) {
|
|
203
|
+
const deadline = Date.now() + ms;
|
|
204
|
+
while (Date.now() < deadline) {
|
|
205
|
+
if (!(await isDaemonUp(300))) return true;
|
|
206
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function stopDaemonForUpgrade(context: DaemonContext, log: DaemonLog) {
|
|
212
|
+
try {
|
|
213
|
+
const response = await fetch(adminHttpUrl() + "/api/shutdown", {
|
|
214
|
+
method: "POST",
|
|
215
|
+
signal: AbortSignal.timeout(2_000)
|
|
216
|
+
});
|
|
217
|
+
if (response.ok && await waitForDaemonDown(5_000)) return true;
|
|
218
|
+
} catch {
|
|
219
|
+
// Older daemons do not have /api/shutdown. Fall through to a local process stop.
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
if (typeof context.pid === "number" && Number.isInteger(context.pid) && context.pid > 1) {
|
|
224
|
+
process.kill(context.pid, "SIGTERM");
|
|
225
|
+
} else if (process.platform === "win32") {
|
|
226
|
+
await execFileAsync("taskkill", ["/IM", "pi-companion-server.exe", "/F"]);
|
|
227
|
+
} else {
|
|
228
|
+
await execFileAsync("pkill", ["-f", "pi-companion-server"]);
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// The process may already have exited between the probe and the stop attempt.
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const stopped = await waitForDaemonDown(5_000);
|
|
235
|
+
if (!stopped) log("Could not stop the older Pi Companion daemon automatically.", "warning");
|
|
236
|
+
return stopped;
|
|
237
|
+
}
|
|
238
|
+
|
|
194
239
|
/**
|
|
195
240
|
* Make sure a daemon is reachable. Returns true when one answers.
|
|
196
241
|
* Never spawns when a daemon (manual `cargo run`, another session's daemon, …) is already up.
|
|
197
242
|
*/
|
|
198
243
|
export async function ensureDaemon(log: DaemonLog) {
|
|
199
|
-
|
|
200
|
-
|
|
244
|
+
const expectedVersion = packageVersion();
|
|
245
|
+
const running = await daemonContext();
|
|
246
|
+
if (running?.version === expectedVersion) return true;
|
|
247
|
+
if (process.env.PI_COMPANION_AUTOSTART === "0" || !isLocalTarget()) return Boolean(running);
|
|
201
248
|
|
|
202
249
|
const release = await acquireLock();
|
|
203
|
-
if (!release) return await waitForDaemon(10_000); // another Pi session is starting it
|
|
250
|
+
if (!release) return await waitForDaemon(10_000, expectedVersion); // another Pi session is starting/upgrading it
|
|
204
251
|
try {
|
|
205
|
-
|
|
252
|
+
const current = await daemonContext();
|
|
253
|
+
if (current?.version === expectedVersion) return true;
|
|
254
|
+
|
|
255
|
+
if (current) {
|
|
256
|
+
log(
|
|
257
|
+
"Updating Pi Companion daemon from v" + (current.version ?? "unknown") + " to v" + expectedVersion + "…"
|
|
258
|
+
);
|
|
259
|
+
if (!(await stopDaemonForUpgrade(current, log))) return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
206
262
|
const binary = await resolveDaemonBinary(log);
|
|
207
263
|
await new Promise<void>((resolve, reject) => {
|
|
208
264
|
const child = spawn(binary, [], { detached: true, stdio: "ignore", windowsHide: true });
|
|
209
265
|
child.once("error", reject);
|
|
210
266
|
child.once("spawn", () => { child.unref(); resolve(); });
|
|
211
267
|
});
|
|
212
|
-
const up = await waitForDaemon(10_000);
|
|
213
|
-
if (!up) log("Pi Companion daemon did not become ready (" + binary + ")", "warning");
|
|
268
|
+
const up = await waitForDaemon(10_000, expectedVersion);
|
|
269
|
+
if (!up) log("Pi Companion daemon v" + expectedVersion + " did not become ready (" + binary + ")", "warning");
|
|
214
270
|
return up;
|
|
215
271
|
} catch (error) {
|
|
216
272
|
log("Could not start Pi Companion daemon: " + (error instanceof Error ? error.message : String(error)), "warning");
|