@mjasnikovs/pi-task 0.18.50 → 0.19.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.
@@ -67,7 +67,9 @@ export function registerRemote(pi) {
67
67
  bridge.currentCtx = makeShimmedCtx(ctx);
68
68
  }
69
69
  if (getConfig().remote) {
70
- void ensureServer().catch(err => ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error'));
70
+ // Optional feature: a bind failure must never take pi down. Degrade
71
+ // to a one-line warning and keep the agent running without remote.
72
+ void ensureServer().catch(err => ctx.ui.notify(`Remote UI unavailable: ${err.message}`, 'warning'));
71
73
  }
72
74
  });
73
75
  pi.on('session_shutdown', (event, _ctx) => {
@@ -158,7 +160,7 @@ export function registerRemote(pi) {
158
160
  ctx.ui.notify(`Remote running at ${primaryUrl}`, 'info');
159
161
  }
160
162
  catch (err) {
161
- ctx.ui.notify(`Failed to start remote: ${err.message}`, 'error');
163
+ ctx.ui.notify(`Remote UI unavailable: ${err.message}`, 'error');
162
164
  }
163
165
  }
164
166
  });
@@ -28,5 +28,19 @@ export declare function getLocalIPs(nets?: NodeJS.Dict<import("node:os").Network
28
28
  * Tailscale line uses the MagicDNS host when known (resolves to the same node,
29
29
  * but is what SSH and webpush certs need), falling back to the raw IP. */
30
30
  export declare function formatAddresses(ips: LocalIPs, port: number, tsHost?: string): AddressLine[];
31
+ /** Bind the REAL `server` to the first free port at or above `start`, trying up
32
+ * to `max` consecutive ports. On EADDRINUSE we bump the port and re-listen; any
33
+ * other error (e.g. EACCES), or exhausting the range, REJECTS the promise —
34
+ * never throws uncaught.
35
+ *
36
+ * Binding the real server directly (rather than probing a throwaway socket with
37
+ * createServer()/listen()/close() first, then binding the real one) removes a
38
+ * TOCTOU race: between "probe says port free" and "real listen", the port can be
39
+ * taken by someone else, and on Windows/Bun the PROBE socket's own port isn't
40
+ * fully released before the real listen runs — so the real bind hits EADDRINUSE
41
+ * on the very port that just tested free, and (with no 'error' listener on the
42
+ * real server) escapes as an uncaughtException that crashes pi (issue #7).
43
+ * Retrying the real bind has no probe and no window. */
44
+ export declare function listenWithRetry(server: import('node:http').Server, start: number, max: number): Promise<number>;
31
45
  export declare function startServer(onMessage: MessageCallback, getHtml: (wsUrl: string) => string, onInterrupt?: () => void): Promise<ServerHandle>;
32
46
  export {};
@@ -48,27 +48,60 @@ export function formatAddresses(ips, port, tsHost) {
48
48
  out.push({ label: '', url: `http://${ips.primary}:${port}` });
49
49
  return out;
50
50
  }
51
- async function tryBind(port) {
52
- return new Promise(resolve => {
53
- const s = createServer();
54
- s.listen(port, '0.0.0.0', () => {
55
- s.close(() => resolve(true));
56
- });
57
- s.on('error', () => resolve(false));
51
+ /** Bind the REAL `server` to the first free port at or above `start`, trying up
52
+ * to `max` consecutive ports. On EADDRINUSE we bump the port and re-listen; any
53
+ * other error (e.g. EACCES), or exhausting the range, REJECTS the promise —
54
+ * never throws uncaught.
55
+ *
56
+ * Binding the real server directly (rather than probing a throwaway socket with
57
+ * createServer()/listen()/close() first, then binding the real one) removes a
58
+ * TOCTOU race: between "probe says port free" and "real listen", the port can be
59
+ * taken by someone else, and on Windows/Bun the PROBE socket's own port isn't
60
+ * fully released before the real listen runs — so the real bind hits EADDRINUSE
61
+ * on the very port that just tested free, and (with no 'error' listener on the
62
+ * real server) escapes as an uncaughtException that crashes pi (issue #7).
63
+ * Retrying the real bind has no probe and no window. */
64
+ export function listenWithRetry(server, start, max) {
65
+ return new Promise((resolve, reject) => {
66
+ let port = start;
67
+ // Persistent 'listening'/'error' listeners (not one-shot listen(cb)):
68
+ // under Bun, a listen(port, host, cb) callback from a FAILED first bind
69
+ // is NOT carried over to a later listen() retry, so it never fires — the
70
+ // retry silently hangs. Registering both via .on() and re-calling
71
+ // listen(port) with no callback routes each attempt's outcome correctly
72
+ // on both Bun and Node.
73
+ const cleanup = () => {
74
+ server.removeListener('error', onError);
75
+ server.removeListener('listening', onListening);
76
+ };
77
+ const onListening = () => {
78
+ cleanup();
79
+ resolve(port);
80
+ };
81
+ const onError = (err) => {
82
+ if (err.code === 'EADDRINUSE' && port < start + max - 1) {
83
+ port++;
84
+ server.listen(port, '0.0.0.0');
85
+ return;
86
+ }
87
+ cleanup();
88
+ reject(err.code === 'EADDRINUSE' ?
89
+ new Error(`No free port found in range ${start}–${start + max - 1}`)
90
+ : err);
91
+ };
92
+ server.on('error', onError);
93
+ server.on('listening', onListening);
94
+ server.listen(port, '0.0.0.0');
58
95
  });
59
96
  }
60
- async function findPort(start, max) {
61
- for (let p = start; p < start + max; p++) {
62
- if (await tryBind(p))
63
- return p;
64
- }
65
- throw new Error(`No free port found in range ${start}–${start + max - 1}`);
66
- }
67
97
  export async function startServer(onMessage, getHtml, onInterrupt) {
68
- const port = await findPort(8800, 100);
69
98
  const ips = getLocalIPs();
70
99
  const ip = ips.primary;
71
- const wsUrl = `ws://${ip}:${port}/ws`;
100
+ // The bound port isn't known until listenWithRetry succeeds, and wsUrl
101
+ // depends on it. The request handler only ever runs once the server is
102
+ // listening (real client I/O, long after we set wsUrl below), so reading it
103
+ // lazily from this closure variable is safe.
104
+ let wsUrl = '';
72
105
  const httpServer = createServer((req, res) => {
73
106
  if (req.method === 'GET' && (req.url === '/' || req.url === '')) {
74
107
  const body = getHtml(wsUrl);
@@ -108,7 +141,6 @@ export async function startServer(onMessage, getHtml, onInterrupt) {
108
141
  res.end('Not found');
109
142
  }
110
143
  });
111
- const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
112
144
  // Track every accepted TCP socket so stop() can forcibly destroy lingering
113
145
  // keep-alive / WebSocket connections. Without this, httpServer.close() only
114
146
  // stops accepting new connections and waits for existing ones to drain — an
@@ -124,6 +156,18 @@ export async function startServer(onMessage, getHtml, onInterrupt) {
124
156
  sockets.add(s);
125
157
  s.on('close', () => sockets.delete(s));
126
158
  });
159
+ // Bind the real server now, retrying past any in-use ports. A bind failure
160
+ // REJECTS (see listenWithRetry) — register.ts's callers catch it and let pi
161
+ // continue without the remote UI; the remote server is optional.
162
+ const port = await listenWithRetry(httpServer, 8800, 100);
163
+ wsUrl = `ws://${ip}:${port}/ws`;
164
+ // Attach the WebSocket server only AFTER the http server is bound. ws adds an
165
+ // 'error' listener to the http server that re-emits on the WebSocketServer
166
+ // (which has no error listener) — so if it were attached during the bind, an
167
+ // EADDRINUSE on the first port would be forwarded to wss and thrown as an
168
+ // uncaughtException, crashing pi even though listenWithRetry handled it. ws
169
+ // works fine on an already-listening server.
170
+ const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
127
171
  const handle = {
128
172
  port,
129
173
  ip,
@@ -177,6 +221,5 @@ export async function startServer(onMessage, getHtml, onInterrupt) {
177
221
  removeClient(ws);
178
222
  });
179
223
  });
180
- await new Promise(resolve => httpServer.listen(port, '0.0.0.0', resolve));
181
224
  return handle;
182
225
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.50",
3
+ "version": "0.19.0",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",