@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.
- package/dist/remote/register.js +4 -2
- package/dist/remote/server.d.ts +14 -0
- package/dist/remote/server.js +61 -18
- package/package.json +1 -1
package/dist/remote/register.js
CHANGED
|
@@ -67,7 +67,9 @@ export function registerRemote(pi) {
|
|
|
67
67
|
bridge.currentCtx = makeShimmedCtx(ctx);
|
|
68
68
|
}
|
|
69
69
|
if (getConfig().remote) {
|
|
70
|
-
|
|
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(`
|
|
163
|
+
ctx.ui.notify(`Remote UI unavailable: ${err.message}`, 'error');
|
|
162
164
|
}
|
|
163
165
|
}
|
|
164
166
|
});
|
package/dist/remote/server.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/remote/server.js
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|