pi-sdk-web 0.1.9 → 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/dist/cli.js CHANGED
@@ -73,9 +73,18 @@ async function cmdResume(name, port) {
73
73
  const server = new PiWebServer(session, { port });
74
74
  await server.start();
75
75
  console.log(`server at http://127.0.0.1:${port}/ (session: ${info.name ?? info.id})`);
76
+ let shuttingDown = false;
76
77
  const shutdown = async (signal) => {
78
+ if (shuttingDown)
79
+ return; // Repeated Ctrl+C must not re-enter teardown
80
+ shuttingDown = true;
77
81
  console.log(`\n${signal} received, shutting down...`);
78
- await server.stop();
82
+ try {
83
+ await server.stop();
84
+ }
85
+ catch {
86
+ // ignore teardown errors - we still need to exit
87
+ }
79
88
  try {
80
89
  session.dispose();
81
90
  }
package/dist/server.js CHANGED
@@ -12,7 +12,7 @@ import { execFileSync } from "node:child_process";
12
12
  import { existsSync, readFileSync } from "node:fs";
13
13
  import { readFile } from "node:fs/promises";
14
14
  import { createServer } from "node:http";
15
- import { dirname, join, resolve } from "node:path";
15
+ import { dirname, join, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { ModelRegistry, VERSION, } from "@earendil-works/pi-coding-agent";
18
18
  import { WebSocket, WebSocketServer } from "ws";
@@ -21,8 +21,16 @@ const DEFAULT_PORT = 4080;
21
21
  // Static frontend: prefer the in-package copy (built by `npm run build` for
22
22
  // global installs), fall back to the repo-root static/ during development.
23
23
  const PACKAGE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
24
- const STATIC_DIR = [resolve(PACKAGE_DIR, "static"), resolve(PACKAGE_DIR, "..", "static")].find((p) => existsSync(p)) ??
25
- resolve(PACKAGE_DIR, "..", "static");
24
+ // Static frontend source:
25
+ // - dev (tsx runs src/server.ts): always the repo-root static/ (live files)
26
+ // - published (dist/server.js): the built copy at dist/static (packaged)
27
+ const RUNNING_FROM_SRC = fileURLToPath(import.meta.url).includes(`${sep}src${sep}`);
28
+ const STATIC_DIR = RUNNING_FROM_SRC
29
+ ? resolve(PACKAGE_DIR, "..", "static")
30
+ : (() => {
31
+ const built = resolve(PACKAGE_DIR, "dist", "static");
32
+ return existsSync(built) ? built : resolve(PACKAGE_DIR, "..", "static");
33
+ })();
26
34
  // Our own package version (pi-sdk-web), shown in the header next to Pi's version
27
35
  const PI_WEB_VERSION = (() => {
28
36
  try {
@@ -135,9 +143,15 @@ export class PiWebServer {
135
143
  return;
136
144
  }
137
145
  for (const client of this.clients) {
138
- if (client.readyState === WebSocket.OPEN) {
146
+ if (client.readyState !== WebSocket.OPEN)
147
+ continue;
148
+ try {
139
149
  client.send(message);
140
150
  }
151
+ catch {
152
+ // One broken client must not block delivery to the others
153
+ this.clients.delete(client);
154
+ }
141
155
  }
142
156
  }
143
157
  broadcastStats() {
@@ -169,8 +183,10 @@ export class PiWebServer {
169
183
  let path = (req.url ?? "/").split("?")[0];
170
184
  if (path === "/")
171
185
  path = "/index.html";
186
+ const root = resolve(this.staticDir);
172
187
  const filePath = resolve(this.staticDir, "." + path);
173
- if (!filePath.startsWith(resolve(this.staticDir))) {
188
+ // Exact-prefix match (avoid /a/static-evil passing a /a/static check)
189
+ if (filePath !== root && !filePath.startsWith(root + sep)) {
174
190
  res.writeHead(403).end();
175
191
  return;
176
192
  }
@@ -245,7 +261,14 @@ export class PiWebServer {
245
261
  }
246
262
  return cwd;
247
263
  }
264
+ gitBranchCache = null;
265
+ static GIT_CACHE_TTL_MS = 5_000;
248
266
  getGitBranch(cwd) {
267
+ const now = Date.now();
268
+ if (this.gitBranchCache && this.gitBranchCache.cwd === cwd && now - this.gitBranchCache.at < PiWebServer.GIT_CACHE_TTL_MS) {
269
+ return this.gitBranchCache.branch;
270
+ }
271
+ let branch = null;
249
272
  try {
250
273
  const stdout = execFileSync("git", ["branch", "--show-current"], {
251
274
  cwd,
@@ -255,11 +278,13 @@ export class PiWebServer {
255
278
  // "fatal: not a git repository" in non-git dirs) - suppress it
256
279
  stdio: ["ignore", "pipe", "ignore"],
257
280
  });
258
- return stdout.trim() || null;
281
+ branch = stdout.trim() || null;
259
282
  }
260
283
  catch {
261
- return null;
284
+ branch = null;
262
285
  }
286
+ this.gitBranchCache = { cwd, branch, at: now };
287
+ return branch;
263
288
  }
264
289
  getCommands() {
265
290
  try {
@@ -514,7 +539,7 @@ export class PiWebServer {
514
539
  sessionManager: sm,
515
540
  modelRegistry: new ModelRegistry(this.session.modelRuntime),
516
541
  model: this.session.model,
517
- scopedModels: [],
542
+ scopedModels: this.session.scopedModels,
518
543
  thinkingLevel: this.session.thinkingLevel,
519
544
  isIdle: () => this.session.isIdle,
520
545
  isProjectTrusted: () => true,
@@ -535,7 +560,9 @@ export class PiWebServer {
535
560
  fork: async () => ({ cancelled: true }),
536
561
  navigateTree: async () => ({ cancelled: true }),
537
562
  switchSession: async () => ({ cancelled: true }),
538
- reload: async () => { },
563
+ reload: async () => {
564
+ await this.session.reload();
565
+ },
539
566
  };
540
567
  }
541
568
  }
@@ -1115,6 +1115,42 @@ class PiWebClient {
1115
1115
  if (scroller) scroller.scrollTop = scroller.scrollHeight;
1116
1116
  }
1117
1117
 
1118
+ /**
1119
+ * Jump to the previous (-1) or next (+1) user message in the scroll view.
1120
+ * Positions the target message at the top of the viewport.
1121
+ */
1122
+ jumpToUserMessage(direction) {
1123
+ const scroller = document.getElementById('scroll-view');
1124
+ if (!scroller) return;
1125
+ const users = [...document.querySelectorAll('.message.user')];
1126
+ if (users.length === 0) return;
1127
+
1128
+ const scrollerRect = scroller.getBoundingClientRect();
1129
+ const offsetTop = (el) => el.getBoundingClientRect().top - scrollerRect.top + scroller.scrollTop;
1130
+ const current = scroller.scrollTop;
1131
+ const buffer = 12; // px: treat "essentially at this message's top" as already there
1132
+
1133
+ let target = null;
1134
+ if (direction === -1) {
1135
+ for (let i = users.length - 1; i >= 0; i--) {
1136
+ if (offsetTop(users[i]) < current - buffer) {
1137
+ target = users[i];
1138
+ break;
1139
+ }
1140
+ }
1141
+ } else {
1142
+ for (const u of users) {
1143
+ if (offsetTop(u) > current + buffer) {
1144
+ target = u;
1145
+ break;
1146
+ }
1147
+ }
1148
+ }
1149
+ if (target) {
1150
+ scroller.scrollTo({ top: Math.max(0, offsetTop(target) - 8), behavior: 'smooth' });
1151
+ }
1152
+ }
1153
+
1118
1154
  // ------------------------------------------------------------------
1119
1155
  // Input
1120
1156
  // ------------------------------------------------------------------
@@ -1196,6 +1232,13 @@ class PiWebClient {
1196
1232
  }
1197
1233
  });
1198
1234
  this.inputEl.addEventListener('input', () => this.updateCommandMenu());
1235
+
1236
+ // Header nav buttons: jump to previous/next user message
1237
+ const navPrev = document.getElementById('nav-prev');
1238
+ const navNext = document.getElementById('nav-next');
1239
+ if (navPrev) navPrev.addEventListener('click', () => this.jumpToUserMessage(-1));
1240
+ if (navNext) navNext.addEventListener('click', () => this.jumpToUserMessage(1));
1241
+
1199
1242
  if (this.sendBtn) {
1200
1243
  this.sendBtn.addEventListener('click', () => this.sendMessage());
1201
1244
  }
@@ -16,6 +16,10 @@
16
16
  <span class="theme-sep">|</span>
17
17
  <span class="theme-option" data-theme="bright">Bright</span>
18
18
  </span>
19
+ <span id="msg-nav">
20
+ <span class="nav-btn" id="nav-prev" title="Jump to previous user message">↑</span>
21
+ <span class="nav-btn" id="nav-next" title="Jump to next user message">↓</span>
22
+ </span>
19
23
  <span id="conn-status" class="disconnected">Disconnected</span>
20
24
  </div>
21
25
  <div id="main">
@@ -131,6 +131,22 @@ body {
131
131
  margin: 0 4px;
132
132
  }
133
133
 
134
+ #msg-nav {
135
+ margin-left: 10px;
136
+ font-size: 13px;
137
+ user-select: none;
138
+ }
139
+
140
+ .nav-btn {
141
+ cursor: pointer;
142
+ color: var(--dim);
143
+ padding: 0 4px;
144
+ }
145
+
146
+ .nav-btn:hover {
147
+ color: var(--accent);
148
+ }
149
+
134
150
  #conn-status {
135
151
  font-size: 12px;
136
152
  padding: 2px 8px;
package/package.json CHANGED
@@ -1,17 +1,16 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "Browser Web access for Pi (AI coding agent) via the Pi SDK - standalone module, zero modification to Pi itself",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "pi-web": "./dist/cli.js"
8
8
  },
9
9
  "files": [
10
- "dist",
11
- "static"
10
+ "dist"
12
11
  ],
13
12
  "scripts": {
14
- "build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../static', 'static', { recursive: true })\"",
13
+ "build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../static', 'dist/static', { recursive: true })\"",
15
14
  "dev": "tsx src/cli.ts",
16
15
  "verify": "tsx src/verify-sdk.ts",
17
16
  "prepublishOnly": "npm run build"
File without changes