pi-sessions 0.5.1 → 0.6.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 +19 -9
- package/extensions/session-ask.ts +4 -4
- package/extensions/session-auto-title/command.ts +1 -1
- package/extensions/session-auto-title/controller.ts +3 -3
- package/extensions/session-auto-title/generate.ts +2 -2
- package/extensions/session-auto-title/model.ts +1 -1
- package/extensions/session-auto-title/retitle.ts +5 -5
- package/extensions/session-auto-title/state.ts +1 -1
- package/extensions/session-auto-title/wizard.ts +4 -4
- package/extensions/session-auto-title.ts +8 -8
- package/extensions/session-handoff/extract.ts +18 -5
- package/extensions/session-handoff/metadata.ts +4 -1
- package/extensions/session-handoff/picker.ts +2 -2
- package/extensions/session-handoff/query.ts +6 -8
- package/extensions/session-handoff/refs.ts +2 -2
- package/extensions/session-handoff/spawn.ts +1 -1
- package/extensions/session-handoff.ts +18 -8
- package/extensions/session-hooks.ts +3 -3
- package/extensions/session-index.ts +4 -4
- package/extensions/session-messaging/broker/process.ts +305 -0
- package/extensions/session-messaging/broker/spawn.ts +159 -0
- package/extensions/session-messaging/pi/client.ts +256 -0
- package/extensions/session-messaging/pi/incoming-runtime.ts +130 -0
- package/extensions/session-messaging/pi/message-contracts.ts +37 -0
- package/extensions/session-messaging/pi/message-view.ts +120 -0
- package/extensions/session-messaging/pi/renderer.ts +16 -0
- package/extensions/session-messaging/pi/service.ts +315 -0
- package/extensions/session-messaging/pi/tools.ts +144 -0
- package/extensions/session-messaging/shared/framing.ts +72 -0
- package/extensions/session-messaging/shared/protocol.ts +102 -0
- package/extensions/session-messaging/shared/socket-path.ts +30 -0
- package/extensions/session-messaging.ts +30 -0
- package/extensions/session-search/extract.ts +4 -4
- package/extensions/session-search/hooks.ts +3 -3
- package/extensions/session-search/reindex.ts +2 -2
- package/extensions/session-search.ts +4 -4
- package/extensions/shared/session-index/common.ts +3 -3
- package/extensions/shared/session-index/index.ts +5 -5
- package/extensions/shared/session-index/lineage.ts +9 -2
- package/extensions/shared/session-index/schema.ts +3 -3
- package/extensions/shared/session-index/search.ts +4 -4
- package/extensions/shared/session-index/store.ts +2 -2
- package/extensions/shared/settings.ts +1 -1
- package/package.json +4 -3
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createServer, type Socket } from "node:net";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readFrames, writeFrame } from "../shared/framing.ts";
|
|
5
|
+
import {
|
|
6
|
+
CLIENT_FRAME_SCHEMA,
|
|
7
|
+
type SessionMessagingClientFrame,
|
|
8
|
+
type SessionMessagingIncomingAckClientFrame,
|
|
9
|
+
type SessionMessagingSendClientFrame,
|
|
10
|
+
type SessionMessagingSessionInfo,
|
|
11
|
+
} from "../shared/protocol.ts";
|
|
12
|
+
import { getSessionMessagingDir, getSessionMessagingSocketPath } from "../shared/socket-path.ts";
|
|
13
|
+
|
|
14
|
+
const SEND_TIMEOUT_MS = 10_000;
|
|
15
|
+
const EXIT_IDLE_MS = 5_000;
|
|
16
|
+
const KEEPALIVE_DELAY_MS = 5_000;
|
|
17
|
+
|
|
18
|
+
const messagingDir = getSessionMessagingDir();
|
|
19
|
+
const socketPath = getSessionMessagingSocketPath();
|
|
20
|
+
const pidPath = join(messagingDir, "broker.pid");
|
|
21
|
+
|
|
22
|
+
interface ConnectedSession {
|
|
23
|
+
socket: Socket;
|
|
24
|
+
session: SessionMessagingSessionInfo;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PendingSend {
|
|
28
|
+
source: Socket;
|
|
29
|
+
timeout: NodeJS.Timeout;
|
|
30
|
+
messageId: string;
|
|
31
|
+
targetSessionId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const sessions = new Map<string, ConnectedSession>();
|
|
35
|
+
const pendingSends = new Map<string, PendingSend>();
|
|
36
|
+
let idleTimer: NodeJS.Timeout | undefined;
|
|
37
|
+
let server: ReturnType<typeof createServer> | undefined;
|
|
38
|
+
|
|
39
|
+
function closeWithError(socket: Socket, message: string): void {
|
|
40
|
+
try {
|
|
41
|
+
writeFrame(socket, { type: "error", message });
|
|
42
|
+
} catch {}
|
|
43
|
+
socket.destroy();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function scheduleIdleExit(): void {
|
|
47
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
48
|
+
idleTimer = setTimeout(() => {
|
|
49
|
+
if (sessions.size === 0) shutdown();
|
|
50
|
+
}, EXIT_IDLE_MS);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function clearIdleExit(): void {
|
|
54
|
+
if (!idleTimer) return;
|
|
55
|
+
clearTimeout(idleTimer);
|
|
56
|
+
idleTimer = undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function unregister(sessionId: string | undefined): void {
|
|
60
|
+
if (!sessionId) return;
|
|
61
|
+
sessions.delete(sessionId);
|
|
62
|
+
failPendingSendsTo(sessionId);
|
|
63
|
+
if (sessions.size === 0) scheduleIdleExit();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveTarget(target: string): { session: ConnectedSession } | { error: string } {
|
|
67
|
+
const exact = sessions.get(target);
|
|
68
|
+
if (exact) return { session: exact };
|
|
69
|
+
|
|
70
|
+
return { error: `No live session found for id: ${target}` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function handleFrame(
|
|
74
|
+
socket: Socket,
|
|
75
|
+
message: SessionMessagingClientFrame,
|
|
76
|
+
getSessionId: () => string | undefined,
|
|
77
|
+
setSessionId: (sessionId: string | undefined) => void,
|
|
78
|
+
): void {
|
|
79
|
+
const currentSessionId = getSessionId();
|
|
80
|
+
if (!currentSessionId && message.type !== "register") {
|
|
81
|
+
closeWithError(socket, `Received ${message.type} before register.`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
switch (message.type) {
|
|
86
|
+
case "register": {
|
|
87
|
+
const existing = sessions.get(message.session.sessionId);
|
|
88
|
+
if (existing && existing.socket !== socket && !existing.socket.destroyed) {
|
|
89
|
+
writeFrame(socket, {
|
|
90
|
+
type: "register_failed",
|
|
91
|
+
reason:
|
|
92
|
+
"Session messaging unavailable: this session is already registered by another live Pi process.",
|
|
93
|
+
});
|
|
94
|
+
socket.end();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
clearIdleExit();
|
|
99
|
+
setSessionId(message.session.sessionId);
|
|
100
|
+
sessions.set(message.session.sessionId, { socket, session: message.session });
|
|
101
|
+
socket.setKeepAlive(true, KEEPALIVE_DELAY_MS);
|
|
102
|
+
writeFrame(socket, { type: "registered", session: message.session });
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case "unregister":
|
|
107
|
+
unregister(currentSessionId);
|
|
108
|
+
setSessionId(undefined);
|
|
109
|
+
socket.end();
|
|
110
|
+
break;
|
|
111
|
+
|
|
112
|
+
case "list":
|
|
113
|
+
writeFrame(socket, {
|
|
114
|
+
type: "sessions",
|
|
115
|
+
requestId: message.requestId,
|
|
116
|
+
sessions: [...sessions.values()].map(({ session }) => session),
|
|
117
|
+
});
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case "send":
|
|
121
|
+
handleSend(socket, currentSessionId, message);
|
|
122
|
+
break;
|
|
123
|
+
|
|
124
|
+
case "incoming_ack":
|
|
125
|
+
handleIncomingAck(message);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function handleSend(
|
|
131
|
+
socket: Socket,
|
|
132
|
+
currentSessionId: string | undefined,
|
|
133
|
+
message: SessionMessagingSendClientFrame,
|
|
134
|
+
): void {
|
|
135
|
+
const source = currentSessionId ? sessions.get(currentSessionId) : undefined;
|
|
136
|
+
const { messageId } = message;
|
|
137
|
+
|
|
138
|
+
if (!source) {
|
|
139
|
+
writeFrame(socket, {
|
|
140
|
+
type: "send_result",
|
|
141
|
+
requestId: message.requestId,
|
|
142
|
+
messageId,
|
|
143
|
+
delivered: false,
|
|
144
|
+
error: "Sender session is not registered.",
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const resolved = resolveTarget(message.target);
|
|
150
|
+
if ("error" in resolved) {
|
|
151
|
+
writeFrame(socket, {
|
|
152
|
+
type: "send_result",
|
|
153
|
+
requestId: message.requestId,
|
|
154
|
+
messageId,
|
|
155
|
+
delivered: false,
|
|
156
|
+
error: resolved.error,
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (resolved.session.session.sessionId === source.session.sessionId) {
|
|
162
|
+
writeFrame(socket, {
|
|
163
|
+
type: "send_result",
|
|
164
|
+
requestId: message.requestId,
|
|
165
|
+
messageId,
|
|
166
|
+
delivered: false,
|
|
167
|
+
error: "Cannot send a session message to the current session.",
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const timeout = setTimeout(() => {
|
|
173
|
+
pendingSends.delete(message.requestId);
|
|
174
|
+
writeFrame(socket, {
|
|
175
|
+
type: "send_result",
|
|
176
|
+
requestId: message.requestId,
|
|
177
|
+
messageId,
|
|
178
|
+
delivered: false,
|
|
179
|
+
error: "Timed out waiting for target session to accept the message.",
|
|
180
|
+
});
|
|
181
|
+
}, SEND_TIMEOUT_MS);
|
|
182
|
+
pendingSends.set(message.requestId, {
|
|
183
|
+
source: socket,
|
|
184
|
+
timeout,
|
|
185
|
+
messageId,
|
|
186
|
+
targetSessionId: resolved.session.session.sessionId,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
writeFrame(resolved.session.socket, {
|
|
190
|
+
type: "incoming",
|
|
191
|
+
requestId: message.requestId,
|
|
192
|
+
message: {
|
|
193
|
+
messageId,
|
|
194
|
+
source: source.session,
|
|
195
|
+
target: resolved.session.session,
|
|
196
|
+
body: message.body,
|
|
197
|
+
...(message.requestResponse === undefined
|
|
198
|
+
? {}
|
|
199
|
+
: { requestResponse: message.requestResponse }),
|
|
200
|
+
sentAt: message.sentAt,
|
|
201
|
+
...(message.sourceToolCallId === undefined
|
|
202
|
+
? {}
|
|
203
|
+
: { sourceToolCallId: message.sourceToolCallId }),
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function failPendingSendsTo(targetSessionId: string): void {
|
|
209
|
+
for (const [requestId, pending] of pendingSends) {
|
|
210
|
+
if (pending.targetSessionId !== targetSessionId) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
clearTimeout(pending.timeout);
|
|
215
|
+
pendingSends.delete(requestId);
|
|
216
|
+
writeFrame(pending.source, {
|
|
217
|
+
type: "send_result",
|
|
218
|
+
requestId,
|
|
219
|
+
messageId: pending.messageId,
|
|
220
|
+
delivered: false,
|
|
221
|
+
error: "Target disconnected before accepting the message.",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function handleIncomingAck(message: SessionMessagingIncomingAckClientFrame): void {
|
|
227
|
+
const pending = pendingSends.get(message.requestId);
|
|
228
|
+
if (!pending) return;
|
|
229
|
+
|
|
230
|
+
clearTimeout(pending.timeout);
|
|
231
|
+
pendingSends.delete(message.requestId);
|
|
232
|
+
writeFrame(pending.source, {
|
|
233
|
+
type: "send_result",
|
|
234
|
+
requestId: message.requestId,
|
|
235
|
+
messageId: message.messageId,
|
|
236
|
+
delivered: message.delivered,
|
|
237
|
+
...(message.error === undefined ? {} : { error: message.error }),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function handleConnection(socket: Socket): void {
|
|
242
|
+
let sessionId: string | undefined;
|
|
243
|
+
socket.setKeepAlive(true, KEEPALIVE_DELAY_MS);
|
|
244
|
+
void readConnection(
|
|
245
|
+
socket,
|
|
246
|
+
() => sessionId,
|
|
247
|
+
(nextSessionId) => {
|
|
248
|
+
sessionId = nextSessionId;
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
socket.on("close", () => {
|
|
253
|
+
unregister(sessionId);
|
|
254
|
+
});
|
|
255
|
+
socket.on("error", () => {});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function readConnection(
|
|
259
|
+
socket: Socket,
|
|
260
|
+
getSessionId: () => string | undefined,
|
|
261
|
+
setSessionId: (sessionId: string | undefined) => void,
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
try {
|
|
264
|
+
for await (const frame of readFrames(
|
|
265
|
+
socket,
|
|
266
|
+
CLIENT_FRAME_SCHEMA,
|
|
267
|
+
"Invalid session messaging client frame",
|
|
268
|
+
)) {
|
|
269
|
+
handleFrame(socket, frame, getSessionId, setSessionId);
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
closeWithError(socket, error instanceof Error ? error.message : String(error));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function shutdown(): void {
|
|
277
|
+
for (const { socket } of sessions.values()) socket.end();
|
|
278
|
+
sessions.clear();
|
|
279
|
+
for (const pending of pendingSends.values()) clearTimeout(pending.timeout);
|
|
280
|
+
pendingSends.clear();
|
|
281
|
+
if (server) server.close();
|
|
282
|
+
if (process.platform !== "win32") {
|
|
283
|
+
try {
|
|
284
|
+
unlinkSync(socketPath);
|
|
285
|
+
} catch {}
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
unlinkSync(pidPath);
|
|
289
|
+
} catch {}
|
|
290
|
+
process.exit(0);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
mkdirSync(messagingDir, { recursive: true, mode: 0o700 });
|
|
294
|
+
if (process.platform !== "win32") {
|
|
295
|
+
try {
|
|
296
|
+
unlinkSync(socketPath);
|
|
297
|
+
} catch {}
|
|
298
|
+
}
|
|
299
|
+
server = createServer(handleConnection);
|
|
300
|
+
server.listen(socketPath, () => {
|
|
301
|
+
writeFileSync(pidPath, String(process.pid));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
process.on("SIGTERM", shutdown);
|
|
305
|
+
process.on("SIGINT", shutdown);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { getSessionMessagingDir, getSessionMessagingSocketPath } from "../shared/socket-path.ts";
|
|
7
|
+
|
|
8
|
+
const BROKER_START_TIMEOUT_MS = 5_000;
|
|
9
|
+
const BROKER_CONNECT_TIMEOUT_MS = 1_000;
|
|
10
|
+
const BROKER_SPAWN_LOCK_STALE_MS = 10_000;
|
|
11
|
+
|
|
12
|
+
const brokerDir = getSessionMessagingDir();
|
|
13
|
+
const brokerSocketPath = getSessionMessagingSocketPath();
|
|
14
|
+
const brokerPidPath = join(brokerDir, "broker.pid");
|
|
15
|
+
const brokerSpawnLockPath = join(brokerDir, "broker.spawn.lock");
|
|
16
|
+
|
|
17
|
+
export async function spawnSessionMessagingBrokerIfNeeded(): Promise<void> {
|
|
18
|
+
mkdirSync(brokerDir, { recursive: true, mode: 0o700 });
|
|
19
|
+
|
|
20
|
+
if (await isBrokerRunning()) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!acquireSpawnLock()) {
|
|
25
|
+
await waitForBroker();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
if (await isBrokerRunning()) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const brokerPath = join(dirname(fileURLToPath(import.meta.url)), "process.ts");
|
|
35
|
+
const child = spawn(process.execPath, [brokerPath], {
|
|
36
|
+
detached: true,
|
|
37
|
+
stdio: "ignore",
|
|
38
|
+
windowsHide: process.platform === "win32",
|
|
39
|
+
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
|
40
|
+
});
|
|
41
|
+
child.unref();
|
|
42
|
+
await waitForBroker();
|
|
43
|
+
} finally {
|
|
44
|
+
releaseSpawnLock();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function isBrokerRunning(): Promise<boolean> {
|
|
49
|
+
if (await canConnectToBroker()) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!existsSync(brokerPidPath)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const pid = Number.parseInt(readFileSync(brokerPidPath, "utf8").trim(), 10);
|
|
59
|
+
if (!Number.isFinite(pid)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
return canConnectToBroker();
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function canConnectToBroker(): Promise<boolean> {
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const socket = net.connect(brokerSocketPath);
|
|
72
|
+
const finish = (connected: boolean): void => {
|
|
73
|
+
clearTimeout(timeout);
|
|
74
|
+
socket.off("connect", onConnect);
|
|
75
|
+
socket.off("error", onError);
|
|
76
|
+
resolve(connected);
|
|
77
|
+
};
|
|
78
|
+
const onConnect = (): void => {
|
|
79
|
+
socket.end();
|
|
80
|
+
finish(true);
|
|
81
|
+
};
|
|
82
|
+
const onError = (): void => {
|
|
83
|
+
socket.destroy();
|
|
84
|
+
finish(false);
|
|
85
|
+
};
|
|
86
|
+
const timeout = setTimeout(() => {
|
|
87
|
+
socket.destroy();
|
|
88
|
+
finish(false);
|
|
89
|
+
}, BROKER_CONNECT_TIMEOUT_MS);
|
|
90
|
+
socket.on("connect", onConnect);
|
|
91
|
+
socket.on("error", onError);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function waitForBroker(): Promise<void> {
|
|
96
|
+
const start = Date.now();
|
|
97
|
+
while (Date.now() - start < BROKER_START_TIMEOUT_MS) {
|
|
98
|
+
if (await canConnectToBroker()) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
throw new Error("Session messaging broker failed to start.");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function acquireSpawnLock(): boolean {
|
|
108
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
109
|
+
try {
|
|
110
|
+
writeFileSync(brokerSpawnLockPath, `${process.pid}\n${Date.now()}\n`, { flag: "wx" });
|
|
111
|
+
return true;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
if (isSpawnLockStale()) {
|
|
117
|
+
try {
|
|
118
|
+
unlinkSync(brokerSpawnLockPath);
|
|
119
|
+
} catch {}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isSpawnLockStale(): boolean {
|
|
130
|
+
if (!existsSync(brokerSpawnLockPath)) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const [pidLine = "", createdAtLine = "0"] = readFileSync(brokerSpawnLockPath, "utf8")
|
|
136
|
+
.trim()
|
|
137
|
+
.split("\n");
|
|
138
|
+
const pid = Number.parseInt(pidLine, 10);
|
|
139
|
+
const createdAt = Number.parseInt(createdAtLine, 10);
|
|
140
|
+
|
|
141
|
+
if (Number.isFinite(pid)) {
|
|
142
|
+
try {
|
|
143
|
+
process.kill(pid, 0);
|
|
144
|
+
} catch {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return !Number.isFinite(createdAt) || Date.now() - createdAt > BROKER_SPAWN_LOCK_STALE_MS;
|
|
150
|
+
} catch {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function releaseSpawnLock(): void {
|
|
156
|
+
try {
|
|
157
|
+
unlinkSync(brokerSpawnLockPath);
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import { readFrames, writeFrame } from "../shared/framing.ts";
|
|
5
|
+
import {
|
|
6
|
+
BROKER_FRAME_SCHEMA,
|
|
7
|
+
type SessionMessagingBrokerFrame,
|
|
8
|
+
type SessionMessagingSessionInfo,
|
|
9
|
+
} from "../shared/protocol.ts";
|
|
10
|
+
import { getSessionMessagingSocketPath } from "../shared/socket-path.ts";
|
|
11
|
+
export const INCOMING_SESSION_MESSAGE_EVENT = "session_messaging.incoming_message";
|
|
12
|
+
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
14
|
+
const SOCKET_PATH = getSessionMessagingSocketPath();
|
|
15
|
+
|
|
16
|
+
interface PendingListRequest {
|
|
17
|
+
resolve(sessions: SessionMessagingSessionInfo[]): void;
|
|
18
|
+
reject(error: Error): void;
|
|
19
|
+
timeout: NodeJS.Timeout;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PendingSendRequest {
|
|
23
|
+
resolve(result: SessionMessageSendResult): void;
|
|
24
|
+
reject(error: Error): void;
|
|
25
|
+
timeout: NodeJS.Timeout;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SessionMessageSendResult {
|
|
29
|
+
messageId: string;
|
|
30
|
+
delivered: boolean;
|
|
31
|
+
error?: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SendSessionMessageOptions {
|
|
35
|
+
messageId: string;
|
|
36
|
+
target: string;
|
|
37
|
+
body: string;
|
|
38
|
+
sentAt: string;
|
|
39
|
+
requestResponse?: boolean | undefined;
|
|
40
|
+
sourceToolCallId?: string | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class SessionMessagingClient extends EventEmitter {
|
|
44
|
+
private socket: net.Socket | undefined;
|
|
45
|
+
private session: SessionMessagingSessionInfo | undefined;
|
|
46
|
+
private pendingLists = new Map<string, PendingListRequest>();
|
|
47
|
+
private pendingSends = new Map<string, PendingSendRequest>();
|
|
48
|
+
|
|
49
|
+
get isConnected(): boolean {
|
|
50
|
+
return Boolean(this.socket && !this.socket.destroyed && this.socket.writable && this.session);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
get registeredSession(): SessionMessagingSessionInfo | undefined {
|
|
54
|
+
return this.session;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async connect(session: SessionMessagingSessionInfo): Promise<void> {
|
|
58
|
+
if (this.isConnected) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await new Promise<void>((resolve, reject) => {
|
|
63
|
+
const socket = net.connect(SOCKET_PATH);
|
|
64
|
+
this.socket = socket;
|
|
65
|
+
let settled = false;
|
|
66
|
+
|
|
67
|
+
const timeout = setTimeout(() => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
cleanup();
|
|
71
|
+
socket.destroy();
|
|
72
|
+
reject(new Error("Session messaging broker connection timed out."));
|
|
73
|
+
}, REQUEST_TIMEOUT_MS);
|
|
74
|
+
|
|
75
|
+
const cleanup = (): void => {
|
|
76
|
+
clearTimeout(timeout);
|
|
77
|
+
socket.off("error", onError);
|
|
78
|
+
socket.off("close", onClose);
|
|
79
|
+
this.off("registered", onRegistered);
|
|
80
|
+
this.off("register_failed", onRegisterFailed);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const fail = (error: Error): void => {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true;
|
|
86
|
+
cleanup();
|
|
87
|
+
socket.destroy();
|
|
88
|
+
reject(error);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const onError = (error: Error): void => fail(error);
|
|
92
|
+
const onClose = (): void =>
|
|
93
|
+
fail(new Error("Session messaging broker closed before registration."));
|
|
94
|
+
const onRegistered = (): void => {
|
|
95
|
+
if (settled) return;
|
|
96
|
+
settled = true;
|
|
97
|
+
cleanup();
|
|
98
|
+
socket.on("close", () => {
|
|
99
|
+
if (this.socket === socket) {
|
|
100
|
+
this.cleanup();
|
|
101
|
+
this.emit("disconnect");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
resolve();
|
|
105
|
+
};
|
|
106
|
+
const onRegisterFailed = (reason: string): void => fail(new Error(reason));
|
|
107
|
+
|
|
108
|
+
socket.on("error", onError);
|
|
109
|
+
socket.on("close", onClose);
|
|
110
|
+
this.once("registered", onRegistered);
|
|
111
|
+
this.once("register_failed", onRegisterFailed);
|
|
112
|
+
void this.readSocket(socket);
|
|
113
|
+
|
|
114
|
+
socket.setKeepAlive(true, 5_000);
|
|
115
|
+
writeFrame(socket, { type: "register", session });
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
disconnect(): void {
|
|
120
|
+
if (this.socket && !this.socket.destroyed) {
|
|
121
|
+
if (!this.socket.writableEnded) {
|
|
122
|
+
try {
|
|
123
|
+
writeFrame(this.socket, { type: "unregister" });
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
this.socket.end();
|
|
127
|
+
}
|
|
128
|
+
this.cleanup();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
listSessions(): Promise<SessionMessagingSessionInfo[]> {
|
|
132
|
+
const socket = this.requireSocket();
|
|
133
|
+
const requestId = randomUUID();
|
|
134
|
+
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
const timeout = setTimeout(() => {
|
|
137
|
+
this.pendingLists.delete(requestId);
|
|
138
|
+
reject(new Error("Session live list timed out."));
|
|
139
|
+
}, REQUEST_TIMEOUT_MS);
|
|
140
|
+
this.pendingLists.set(requestId, { resolve, reject, timeout });
|
|
141
|
+
writeFrame(socket, { type: "list", requestId });
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
sendMessage(options: SendSessionMessageOptions): Promise<SessionMessageSendResult> {
|
|
146
|
+
const socket = this.requireSocket();
|
|
147
|
+
const requestId = randomUUID();
|
|
148
|
+
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const timeout = setTimeout(() => {
|
|
151
|
+
this.pendingSends.delete(requestId);
|
|
152
|
+
reject(new Error("Session message send timed out."));
|
|
153
|
+
}, REQUEST_TIMEOUT_MS + 1_000);
|
|
154
|
+
this.pendingSends.set(requestId, { resolve, reject, timeout });
|
|
155
|
+
writeFrame(socket, {
|
|
156
|
+
type: "send",
|
|
157
|
+
requestId,
|
|
158
|
+
messageId: options.messageId,
|
|
159
|
+
target: options.target,
|
|
160
|
+
body: options.body,
|
|
161
|
+
requestResponse: options.requestResponse,
|
|
162
|
+
sourceToolCallId: options.sourceToolCallId,
|
|
163
|
+
sentAt: options.sentAt,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
acknowledgeIncoming(
|
|
169
|
+
requestId: string,
|
|
170
|
+
messageId: string,
|
|
171
|
+
result: { delivered: true } | { delivered: false; error: string },
|
|
172
|
+
): void {
|
|
173
|
+
const socket = this.requireSocket();
|
|
174
|
+
writeFrame(socket, {
|
|
175
|
+
type: "incoming_ack",
|
|
176
|
+
requestId,
|
|
177
|
+
messageId,
|
|
178
|
+
delivered: result.delivered,
|
|
179
|
+
error: result.delivered ? undefined : result.error,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async readSocket(socket: net.Socket): Promise<void> {
|
|
184
|
+
try {
|
|
185
|
+
for await (const frame of readFrames(
|
|
186
|
+
socket,
|
|
187
|
+
BROKER_FRAME_SCHEMA,
|
|
188
|
+
"Invalid session messaging broker frame",
|
|
189
|
+
)) {
|
|
190
|
+
this.handleFrame(frame);
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
socket.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private handleFrame(frame: SessionMessagingBrokerFrame): void {
|
|
198
|
+
switch (frame.type) {
|
|
199
|
+
case "registered":
|
|
200
|
+
this.session = frame.session;
|
|
201
|
+
this.emit("registered");
|
|
202
|
+
break;
|
|
203
|
+
case "register_failed":
|
|
204
|
+
this.emit("register_failed", frame.reason);
|
|
205
|
+
break;
|
|
206
|
+
case "sessions": {
|
|
207
|
+
const pending = this.pendingLists.get(frame.requestId);
|
|
208
|
+
if (!pending) return;
|
|
209
|
+
clearTimeout(pending.timeout);
|
|
210
|
+
this.pendingLists.delete(frame.requestId);
|
|
211
|
+
pending.resolve(frame.sessions);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case "incoming":
|
|
215
|
+
this.emit(INCOMING_SESSION_MESSAGE_EVENT, frame.requestId, frame.message);
|
|
216
|
+
break;
|
|
217
|
+
case "send_result": {
|
|
218
|
+
const pending = this.pendingSends.get(frame.requestId);
|
|
219
|
+
if (!pending) return;
|
|
220
|
+
clearTimeout(pending.timeout);
|
|
221
|
+
this.pendingSends.delete(frame.requestId);
|
|
222
|
+
pending.resolve({
|
|
223
|
+
messageId: frame.messageId,
|
|
224
|
+
delivered: frame.delivered,
|
|
225
|
+
error: frame.error,
|
|
226
|
+
});
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
case "error":
|
|
230
|
+
this.socket?.destroy(new Error(frame.message));
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private requireSocket(): net.Socket {
|
|
236
|
+
if (!this.socket || this.socket.destroyed || !this.socket.writable || !this.session) {
|
|
237
|
+
throw new Error("Session messaging is not connected.");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return this.socket;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private cleanup(): void {
|
|
244
|
+
this.session = undefined;
|
|
245
|
+
for (const pending of this.pendingLists.values()) {
|
|
246
|
+
clearTimeout(pending.timeout);
|
|
247
|
+
pending.reject(new Error("Session messaging disconnected."));
|
|
248
|
+
}
|
|
249
|
+
this.pendingLists.clear();
|
|
250
|
+
for (const pending of this.pendingSends.values()) {
|
|
251
|
+
clearTimeout(pending.timeout);
|
|
252
|
+
pending.reject(new Error("Session messaging disconnected."));
|
|
253
|
+
}
|
|
254
|
+
this.pendingSends.clear();
|
|
255
|
+
}
|
|
256
|
+
}
|