pi-central 0.2.0 → 0.4.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,18 +1,43 @@
1
- # Pi Central
1
+ <p align="center">
2
+ <img src="assets/pi-central-logo-full-dark-mode.svg" alt="Pi Central" width="420" />
3
+ </p>
2
4
 
3
- > The central station where Pi sessions come together and are orchestrated.
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/pi-central"><img alt="npm version" src="https://img.shields.io/npm/v/pi-central" /></a>
7
+ <a href="https://www.npmjs.com/package/pi-central"><img alt="npm downloads" src="https://img.shields.io/npm/dm/pi-central" /></a>
8
+ <a href="https://github.com/sven1103-agent/pi-central/actions/workflows/ci.yml"><img alt="CI status" src="https://github.com/sven1103-agent/pi-central/actions/workflows/ci.yml/badge.svg" /></a>
9
+ <img alt="node version" src="https://img.shields.io/node/v/pi-central" />
10
+ <img alt="license" src="https://img.shields.io/npm/l/pi-central" />
11
+ </p>
4
12
 
5
- Pi Central is a local dashboard for running, watching, and switching between multiple
6
- [Pi](https://pi.dev) coding-agent sessions that live in **different working directories**, while
7
- keeping every session inside its own **Agent Safehouse** sandbox.
13
+ **Pi Central** (/paɪ ˈsɛntrəl/) is a local dashboard for running, watching, and switching between
14
+ multiple [Pi](https://pi.dev) coding-agent sessions that live in **different working directories**,
15
+ while keeping every session inside its own **Agent Safehouse** sandbox.
8
16
 
9
- The name is the design brief: a central station is where independent lines arrive, are dispatched,
10
- and are kept apart from one another. Pi Central does the same for agent sessions.
17
+ The central station is the design brief: independent lines arrive, are dispatched, and are kept apart
18
+ from one another. Pi Central does the same for agent sessions.
11
19
 
12
20
  **Status: works end to end, verified, and published.** The version is deliberately not written down
13
21
  here: `pi-central --version` reports the version of the package it was actually shipped in, and a
14
22
  number restated in prose is a number that drifts. See [docs/RELEASING.md](docs/RELEASING.md).
15
23
 
24
+ ## Install it as an app
25
+
26
+ Pi Central is a web app that installs to your dock. Start it once (see [Quick start](#quick-start)),
27
+ open <http://127.0.0.1:8787>, and then install it from the browser's own menu:
28
+
29
+ | Browser | How |
30
+ |---|---|
31
+ | **Safari** | *File → Add to Dock* |
32
+ | **Chrome / Edge** | the install icon at the right end of the address bar, or *⋮ → Install page as app* |
33
+ | **Firefox** | **not supported** — Mozilla has no PWA install on desktop. The service worker still works; use another browser for the dock icon. |
34
+
35
+ You get the π mark in the dock and a window with no tab bar or address field.
36
+
37
+ **It is not an offline app, deliberately.** Every screen is fed by the socket, so if the orchestrator is
38
+ not running the installed app says `disconnected` and nothing else. A window that claimed to work
39
+ offline would open onto a blank dashboard.
40
+
16
41
  ## The problem
17
42
 
18
43
  Pi has no fleet concept. One `pi` process owns one session and one working directory:
@@ -41,6 +66,18 @@ explanation rather than failing confusingly later.
41
66
 
42
67
  ## Design in one picture
43
68
 
69
+ ![The Pi Central dashboard. On the left, a session rail lists two sessions with their state and
70
+ cost. In the middle, a transcript shows the reader's own prompt in a tinted block labelled YOU,
71
+ followed by the agent's reasoning, two tool runs, and a live "responding" indicator. Below the
72
+ transcript, a tool trace lists the last three commands with their durations. The right-hand panel is
73
+ collapsed.](docs/images/dashboard.png)
74
+
75
+ *A session mid-run. The rail carries the fleet, the transcript carries the conversation, and the tool
76
+ trace carries what the agent actually ran. The right-hand panel — session facts, or a file browser —
77
+ is **collapsed by default**; `Show panel` in the top bar opens it.*
78
+
79
+ The layers behind that window:
80
+
44
81
  ```
45
82
  ┌──────────────────────────────┐
46
83
  │ Pi Central UI (localhost) │ session rail · transcript · approvals · tool trace
@@ -67,7 +104,29 @@ never runs untrusted content, so being outside the sandbox is safe by constructi
67
104
 
68
105
  **One process.** The orchestrator serves the built UI and the WebSocket from the same origin, so
69
106
  there is no dev server to start and no port to coordinate. It must run from a plain terminal (see
70
- the constraint above).
107
+ the constraint above), and it prints the URL it is serving on — <http://127.0.0.1:8787> unless you
108
+ passed `--port`.
109
+
110
+ ### From npm — what a user does
111
+
112
+ ```bash
113
+ npm install --global pi-central
114
+ pi-central
115
+ ```
116
+
117
+ Or without installing it at all:
118
+
119
+ ```bash
120
+ npx pi-central
121
+ ```
122
+
123
+ Once it is running, [install it as an app](#install-it-as-an-app) and you will not need the terminal
124
+ again until the next reboot.
125
+
126
+ The package is **one bundled ESM file plus the built UI**, with a single runtime dependency (`ws`),
127
+ and `pi-central --version` reports the version of the package it was actually shipped in.
128
+
129
+ ### From a checkout — what a contributor does
71
130
 
72
131
  ```bash
73
132
  npm install
@@ -75,11 +134,7 @@ npm run ui:build # once, and after any UI change
75
134
  npm run server # the orchestrator, which also serves the UI
76
135
  ```
77
136
 
78
- Then open <http://127.0.0.1:8787> — the URL the server prints.
79
-
80
- ### From the packaged artifact
81
-
82
- Nothing is published yet, but the artifact is buildable and installable today (issue #52):
137
+ To build the publishable artifact from a checkout:
83
138
 
84
139
  ```bash
85
140
  npm run build:package # bundles the orchestrator, copies the built UI
@@ -87,10 +142,8 @@ npm pack --dry-run # show exactly what would ship
87
142
  npm run serve:package # run the packaged binary from ./bin
88
143
  ```
89
144
 
90
- It ships as JavaScript — TypeScript cannot be loaded from `node_modules` — as **one bundled ESM
91
- file plus the built UI**, with a single runtime dependency. `pi-central --version` reports the
92
- version of the package it was actually shipped in, read from that `package.json` rather than
93
- restated in code. See [ADR-0011](docs/decisions/0011-distribution-and-update.md).
145
+ It ships as JavaScript — TypeScript cannot be loaded from `node_modules` — as one bundled ESM file
146
+ plus the built UI. See [ADR-0011](docs/decisions/0011-distribution-and-update.md).
94
147
 
95
148
  Serving the UI from the orchestrator is not only convenience. A Vite dev server watches
96
149
  `packages/ui/src`, so an agent editing a component reloads the interface you are using out from
package/bin/pi-central.js CHANGED
@@ -25,7 +25,7 @@ function isBlockingDialog(record) {
25
25
  }
26
26
 
27
27
  // packages/protocol/src/envelopes.ts
28
- var PROTOCOL_VERSION = 2;
28
+ var PROTOCOL_VERSION = 3;
29
29
 
30
30
  // packages/protocol/src/own-installation.ts
31
31
  var PIPELINE_MARKERS = ["packages/orchestrator", "packages/ui", "packages/protocol"];
@@ -1132,6 +1132,7 @@ function renderPolicyFragment(options = {}) {
1132
1132
  import { lstatSync, readdirSync as readdirSync2, readFileSync as readFileSync4, realpathSync, statSync as statSync4 } from "node:fs";
1133
1133
  import { isAbsolute, join as join7, resolve as resolve6, sep as sep3 } from "node:path";
1134
1134
  var MAX_FILE_BYTES = 256 * 1024;
1135
+ var MAX_IMAGE_BYTES = 8 * 1024 * 1024;
1135
1136
  var BINARY_SNIFF_BYTES = 8 * 1024;
1136
1137
  var DEFAULT_ENTRY_LIMIT = 2e3;
1137
1138
  function containsPath(parent, child) {
@@ -1311,7 +1312,7 @@ function readFileText(root, relative) {
1311
1312
  ok: false,
1312
1313
  error: {
1313
1314
  reason: "binary",
1314
- message: `"${relPath}" is a binary file (it contains NUL bytes), so it was not rendered as text`
1315
+ message: `"${relPath}" is a binary file of ${formatBytes(stats.size)} (it contains NUL bytes), so it was not rendered as text`
1315
1316
  }
1316
1317
  };
1317
1318
  }
@@ -1323,11 +1324,77 @@ function readFileText(root, relative) {
1323
1324
  ok: false,
1324
1325
  error: {
1325
1326
  reason: "binary",
1326
- message: `"${relPath}" is not valid UTF-8, so it was not rendered as text`
1327
+ message: `"${relPath}" is ${formatBytes(stats.size)} and not valid UTF-8, so it was not rendered as text`
1327
1328
  }
1328
1329
  };
1329
1330
  }
1330
- return { ok: true, value: { path: relPath, text, bytes: buffer.byteLength } };
1331
+ return { ok: true, value: { kind: "text", path: relPath, text, bytes: buffer.byteLength } };
1332
+ }
1333
+ function imageTypeOf(buffer) {
1334
+ const starts = (...signature) => signature.every((byte, index) => buffer[index] === byte);
1335
+ if (starts(137, 80, 78, 71, 13, 10, 26, 10)) return "image/png";
1336
+ if (starts(255, 216, 255)) return "image/jpeg";
1337
+ if (starts(71, 73, 70, 56)) return "image/gif";
1338
+ if (buffer.length >= 12 && buffer.subarray(0, 4).toString("latin1") === "RIFF" && buffer.subarray(8, 12).toString("latin1") === "WEBP") {
1339
+ return "image/webp";
1340
+ }
1341
+ return void 0;
1342
+ }
1343
+ function svgRootOf(text) {
1344
+ let rest = text.slice(0, SVG_HEAD_BYTES);
1345
+ for (; ; ) {
1346
+ const next = rest.replace(/^\s+|^<\?xml[^>]*\?>|^<!--[\s\S]*?-->|^<!DOCTYPE[^>[]*(?:\[[\s\S]*?\])?\s*>/gi, "");
1347
+ if (next === rest) break;
1348
+ rest = next;
1349
+ }
1350
+ if (!/^<svg[\s>]/i.test(rest)) return false;
1351
+ return /xmlns\s*=\s*["']http:\/\/www\.w3\.org\/2000\/svg["']/i.test(rest);
1352
+ }
1353
+ var SVG_HEAD_BYTES = 8 * 1024;
1354
+ function readFileForDisplay(root, relative) {
1355
+ const resolvedPath = resolveWithin(root, relative);
1356
+ if (!resolvedPath.ok) return resolvedPath;
1357
+ const { path, relPath } = resolvedPath.value;
1358
+ let stats;
1359
+ try {
1360
+ stats = lstatSync(path);
1361
+ } catch (error) {
1362
+ return { ok: false, error: fromErrno(error, "cannot open", relPath) };
1363
+ }
1364
+ if (stats.isDirectory()) {
1365
+ return { ok: false, error: { reason: "is-directory", message: `"${relPath}" is a directory` } };
1366
+ }
1367
+ if (stats.size > MAX_IMAGE_BYTES) {
1368
+ return {
1369
+ ok: false,
1370
+ error: {
1371
+ reason: "too-large",
1372
+ message: `"${relPath}" is ${formatBytes(stats.size)}, above the ${formatBytes(MAX_IMAGE_BYTES)} limit for displaying a file here, so it was not read`
1373
+ }
1374
+ };
1375
+ }
1376
+ let buffer;
1377
+ try {
1378
+ buffer = readFileSync4(path);
1379
+ } catch (error) {
1380
+ return { ok: false, error: fromErrno(error, "cannot read", relPath) };
1381
+ }
1382
+ const imageType = imageTypeOf(buffer);
1383
+ if (imageType !== void 0) {
1384
+ return {
1385
+ ok: true,
1386
+ value: { kind: "image", path: relPath, type: imageType, bytes: buffer.byteLength, data: buffer }
1387
+ };
1388
+ }
1389
+ const asText = readFileText(root, relative);
1390
+ if (!asText.ok) return asText;
1391
+ if (svgRootOf(asText.value.text)) {
1392
+ return {
1393
+ ok: true,
1394
+ value: { kind: "image", path: relPath, type: "image/svg+xml", bytes: stats.size, data: buffer }
1395
+ };
1396
+ }
1397
+ return asText;
1331
1398
  }
1332
1399
 
1333
1400
  // packages/orchestrator/src/state.ts
@@ -1858,7 +1925,7 @@ ${rpc.stderrTail}` : ""}`;
1858
1925
  error: { reason: "not-found", message: `no session ${id}, so there is no working directory to read from` }
1859
1926
  };
1860
1927
  }
1861
- return readFileText(session.cwd, path);
1928
+ return readFileForDisplay(session.cwd, path);
1862
1929
  }
1863
1930
  /**
1864
1931
  * Where this Pi Central installation lives, resolved once.
@@ -2079,7 +2146,15 @@ function directoryExists(dir) {
2079
2146
  import { createReadStream as createReadStream2, existsSync as existsSync6, statSync as statSync6 } from "node:fs";
2080
2147
  import { extname, join as join11, posix, resolve as resolve7, sep as sep4 } from "node:path";
2081
2148
  import { fileURLToPath as fileURLToPath3 } from "node:url";
2082
- var UI_CSP = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
2149
+ var UI_CSP = (
2150
+ // `blob:` is REQUIRED and is not covered by `'self'` (issue #111).
2151
+ //
2152
+ // A blob URL has an opaque origin, so `img-src 'self'` does not match it — the file viewer builds
2153
+ // its images from a Blob and would have every one of them blocked, with the `<img>` firing `error`
2154
+ // and the UI reporting "could not decode". That is a false accusation about a valid file, and it is
2155
+ // what shipped: the comment said a blob URL "counts as 'self'", which stopped anyone checking.
2156
+ "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
2157
+ );
2083
2158
  var CONTENT_TYPES = {
2084
2159
  ".html": "text/html; charset=utf-8",
2085
2160
  ".js": "text/javascript; charset=utf-8",
@@ -2096,7 +2171,13 @@ var CONTENT_TYPES = {
2096
2171
  ".ico": "image/x-icon",
2097
2172
  ".woff": "font/woff",
2098
2173
  ".woff2": "font/woff2",
2099
- ".txt": "text/plain; charset=utf-8"
2174
+ ".txt": "text/plain; charset=utf-8",
2175
+ // A manifest served as `application/octet-stream` is IGNORED by every browser, silently — no icon, no
2176
+ // install prompt, no error anywhere. The failure mode is a feature that simply does not exist.
2177
+ ".webmanifest": "application/manifest+json",
2178
+ // The service worker must be JavaScript, and must not be cached: a stale worker is the one artefact that
2179
+ // can outlive a rebuild indefinitely (issues #119, #120).
2180
+ ".webmanifest.json": "application/manifest+json"
2100
2181
  };
2101
2182
  function resolveUiDistDir(env = process.env, from = import.meta.url) {
2102
2183
  const explicit = env["PI_CENTRAL_UI_DIST"];
@@ -2549,8 +2630,8 @@ var Transport = class {
2549
2630
  sessionId: control.sessionId,
2550
2631
  // As sent, for the same reason as `files`: one key for the client's bookkeeping.
2551
2632
  path: control.path,
2552
- text: result.value.text,
2553
- bytes: result.value.bytes
2633
+ bytes: result.value.bytes,
2634
+ content: result.value.kind === "image" ? { kind: "image", type: result.value.type, base64: result.value.data.toString("base64") } : { kind: "text", text: result.value.text }
2554
2635
  });
2555
2636
  } else {
2556
2637
  this.#broadcastFileError(control.sessionId, control.path, "read", result.error);
@@ -2634,6 +2715,54 @@ var Transport = class {
2634
2715
  }
2635
2716
  };
2636
2717
 
2718
+ // packages/orchestrator/src/startup-error.ts
2719
+ import { readFileSync as readFileSync6, statSync as statSync7 } from "node:fs";
2720
+ import { join as join12 } from "node:path";
2721
+ function explainStartupError(error, host, port) {
2722
+ const code = error?.code;
2723
+ if (code === "EADDRINUSE") {
2724
+ return [
2725
+ `Port ${port} is already in use on ${host}.`,
2726
+ ``,
2727
+ `That is almost always a Pi Central that is already running \u2014 and it is why this one did not`,
2728
+ `start. If you were expecting the code you just rebuilt to be served, you are probably looking at`,
2729
+ `the OLD process: its pages are still being served, and Node has its old modules cached.`,
2730
+ ``,
2731
+ ` Find it: lsof -ti tcp:${port}`,
2732
+ ` Stop it: kill $(lsof -ti tcp:${port})`,
2733
+ ` Or use another port: --port <n>`
2734
+ ].join("\n");
2735
+ }
2736
+ if (code === "EACCES") {
2737
+ return [
2738
+ `Permission denied binding ${host}:${port}.`,
2739
+ ``,
2740
+ `Ports below 1024 need elevated privileges on most systems, and Pi Central has no reason to want`,
2741
+ `one \u2014 it is a local tool. Use a port above 1024: --port 8788`
2742
+ ].join("\n");
2743
+ }
2744
+ if (code === "EADDRNOTAVAIL") {
2745
+ return [
2746
+ `${host} is not an address this machine has.`,
2747
+ ``,
2748
+ `Pi Central binds loopback only (C5), so the host should be 127.0.0.1. A wrong host is usually a`,
2749
+ `typo in --host or in PI_CENTRAL_HOST.`
2750
+ ].join("\n");
2751
+ }
2752
+ return void 0;
2753
+ }
2754
+ function describeBuiltUi(uiDir) {
2755
+ try {
2756
+ const html = readFileSync6(join12(uiDir, "index.html"), "utf8");
2757
+ const entry = /assets\/index-[A-Za-z0-9_-]+\.js/.exec(html)?.[0];
2758
+ if (entry === void 0) return "(no entry bundle named in index.html)";
2759
+ const { mtime } = statSync7(join12(uiDir, "index.html"));
2760
+ return `${entry} (built ${mtime.toISOString().slice(0, 16).replace("T", " ")})`;
2761
+ } catch {
2762
+ return "(unreadable)";
2763
+ }
2764
+ }
2765
+
2637
2766
  // packages/orchestrator/src/sandbox-guard.ts
2638
2767
  import { spawnSync } from "node:child_process";
2639
2768
  function probeSandbox(execPath = "/usr/bin/sandbox-exec") {
@@ -2703,6 +2832,7 @@ Pi Central starts sessions with \`pi\` under Agent Safehouse. Run it from a PLAI
2703
2832
  Seatbelt does not nest, so a sandboxed shell cannot start a sandboxed session (C1).`);
2704
2833
  }
2705
2834
  var DEFAULT_PORT = 8787;
2835
+ var startupPort;
2706
2836
  function parseArgs(argv) {
2707
2837
  const options = {
2708
2838
  port: DEFAULT_PORT,
@@ -2744,6 +2874,7 @@ function parseArgs(argv) {
2744
2874
  }
2745
2875
  async function main() {
2746
2876
  const options = parseArgs(process.argv.slice(2));
2877
+ startupPort = options.port;
2747
2878
  if (options.showVersion) {
2748
2879
  console.log(versionLine());
2749
2880
  return 0;
@@ -2789,6 +2920,7 @@ async function main() {
2789
2920
  console.log(`UI: not served (--no-ui). Use a dev server, or drop the flag to serve a build.`);
2790
2921
  } else if (hasBuiltUi(options.uiDir)) {
2791
2922
  console.log(`Open ${httpUrl} \u2014 the UI is served from ${options.uiDir}`);
2923
+ console.log(` UI build: ${describeBuiltUi(options.uiDir)}`);
2792
2924
  } else {
2793
2925
  console.log(
2794
2926
  `No built UI at ${options.uiDir}
@@ -2829,7 +2961,8 @@ main().then(
2829
2961
  if (code !== 0) process.exit(code);
2830
2962
  },
2831
2963
  (error) => {
2832
- console.error(`Pi Central failed to start: ${error.message}`);
2964
+ const explained = explainStartupError(error, "127.0.0.1", startupPort ?? 0);
2965
+ console.error(explained ?? `Pi Central failed to start: ${error.message}`);
2833
2966
  process.exit(1);
2834
2967
  }
2835
2968
  );