pi-sessions 0.5.1 → 0.7.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 +22 -1
- package/extensions/session-ask/agent.ts +400 -0
- package/extensions/session-ask/navigate.ts +880 -0
- package/extensions/session-ask.ts +82 -134
- 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 +4 -12
- 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 +52 -6
- package/extensions/session-handoff/query.ts +78 -62
- package/extensions/session-handoff/refs.ts +14 -20
- package/extensions/session-handoff/spawn.ts +1 -1
- package/extensions/session-handoff.ts +54 -35
- package/extensions/session-hooks.ts +3 -3
- package/extensions/session-index.ts +4 -4
- package/extensions/session-messaging/broker/process.ts +302 -0
- package/extensions/session-messaging/broker/spawn.ts +164 -0
- package/extensions/session-messaging/pi/incoming-runtime.ts +114 -0
- package/extensions/session-messaging/pi/message-contracts.ts +36 -0
- package/extensions/session-messaging/pi/message-view.ts +131 -0
- package/extensions/session-messaging/pi/renderer.ts +16 -0
- package/extensions/session-messaging/pi/service.ts +370 -0
- package/extensions/session-messaging/pi/tools.ts +92 -0
- package/extensions/session-messaging.ts +30 -0
- package/extensions/session-search/extract.ts +90 -440
- package/extensions/session-search/hooks.ts +20 -28
- package/extensions/session-search/normalize.ts +1 -1
- package/extensions/session-search/reindex.ts +3 -2
- package/extensions/session-search.ts +161 -132
- package/extensions/shared/model.ts +18 -0
- package/extensions/shared/session-broker/active.ts +26 -0
- package/extensions/shared/session-broker/client.ts +253 -0
- package/extensions/shared/session-broker/framing.ts +72 -0
- package/extensions/shared/session-broker/protocol.ts +95 -0
- package/extensions/shared/session-broker/socket-path.ts +30 -0
- package/extensions/shared/session-index/access.ts +128 -0
- package/extensions/shared/session-index/common.ts +46 -51
- package/extensions/shared/session-index/index.ts +6 -5
- package/extensions/shared/session-index/lineage.ts +19 -2
- package/extensions/shared/session-index/query/ast.ts +40 -0
- package/extensions/shared/session-index/query/compiler.ts +146 -0
- package/extensions/shared/session-index/query/lexer.ts +140 -0
- package/extensions/shared/session-index/query/parser.ts +178 -0
- package/extensions/shared/session-index/schema.ts +25 -9
- package/extensions/shared/session-index/scoring.ts +80 -0
- package/extensions/shared/session-index/search.ts +555 -281
- package/extensions/shared/session-index/sqlite.ts +16 -9
- package/extensions/shared/session-index/store.ts +14 -23
- package/extensions/shared/settings.ts +62 -5
- package/extensions/shared/text.ts +50 -0
- package/package.json +4 -3
|
@@ -0,0 +1,302 @@
|
|
|
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/session-broker/framing.ts";
|
|
5
|
+
import {
|
|
6
|
+
CLIENT_FRAME_SCHEMA,
|
|
7
|
+
type SessionMessagingClientFrame,
|
|
8
|
+
type SessionMessagingIncomingAckClientFrame,
|
|
9
|
+
type SessionMessagingSendClientFrame,
|
|
10
|
+
} from "../../shared/session-broker/protocol.ts";
|
|
11
|
+
import {
|
|
12
|
+
getSessionMessagingDir,
|
|
13
|
+
getSessionMessagingSocketPath,
|
|
14
|
+
} from "../../shared/session-broker/socket-path.ts";
|
|
15
|
+
|
|
16
|
+
const SEND_TIMEOUT_MS = 10_000;
|
|
17
|
+
const EXIT_IDLE_MS = 5_000;
|
|
18
|
+
const KEEPALIVE_DELAY_MS = 5_000;
|
|
19
|
+
|
|
20
|
+
const messagingDir = getSessionMessagingDir();
|
|
21
|
+
const socketPath = getSessionMessagingSocketPath();
|
|
22
|
+
const pidPath = join(messagingDir, "broker.pid");
|
|
23
|
+
|
|
24
|
+
interface PendingSend {
|
|
25
|
+
source: Socket;
|
|
26
|
+
timeout: NodeJS.Timeout;
|
|
27
|
+
messageId: string;
|
|
28
|
+
targetSessionId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sessions = new Map<string, Socket>();
|
|
32
|
+
const pendingSends = new Map<string, PendingSend>();
|
|
33
|
+
let idleTimer: NodeJS.Timeout | undefined;
|
|
34
|
+
let server: ReturnType<typeof createServer> | undefined;
|
|
35
|
+
|
|
36
|
+
function closeWithError(socket: Socket, message: string): void {
|
|
37
|
+
try {
|
|
38
|
+
writeFrame(socket, { type: "error", message });
|
|
39
|
+
} catch {}
|
|
40
|
+
socket.destroy();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function scheduleIdleExit(): void {
|
|
44
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
45
|
+
idleTimer = setTimeout(() => {
|
|
46
|
+
if (sessions.size === 0) shutdown();
|
|
47
|
+
}, EXIT_IDLE_MS);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clearIdleExit(): void {
|
|
51
|
+
if (!idleTimer) return;
|
|
52
|
+
clearTimeout(idleTimer);
|
|
53
|
+
idleTimer = undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function unregister(sessionId: string | undefined): void {
|
|
57
|
+
if (!sessionId) return;
|
|
58
|
+
sessions.delete(sessionId);
|
|
59
|
+
failPendingSendsTo(sessionId);
|
|
60
|
+
if (sessions.size === 0) scheduleIdleExit();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveTarget(target: string): { sessionId: string; socket: Socket } | { error: string } {
|
|
64
|
+
const socket = sessions.get(target);
|
|
65
|
+
if (socket) return { sessionId: target, socket };
|
|
66
|
+
|
|
67
|
+
return { error: `No live session found for id: ${target}` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function handleFrame(
|
|
71
|
+
socket: Socket,
|
|
72
|
+
message: SessionMessagingClientFrame,
|
|
73
|
+
getSessionId: () => string | undefined,
|
|
74
|
+
setSessionId: (sessionId: string | undefined) => void,
|
|
75
|
+
): void {
|
|
76
|
+
const currentSessionId = getSessionId();
|
|
77
|
+
if (!currentSessionId && message.type !== "register") {
|
|
78
|
+
closeWithError(socket, `Received ${message.type} before register.`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
switch (message.type) {
|
|
83
|
+
case "register": {
|
|
84
|
+
const existing = sessions.get(message.sessionId);
|
|
85
|
+
if (existing && existing !== socket && !existing.destroyed) {
|
|
86
|
+
writeFrame(socket, {
|
|
87
|
+
type: "register_failed",
|
|
88
|
+
reason:
|
|
89
|
+
"Session messaging unavailable: this session is already registered by another live Pi process.",
|
|
90
|
+
});
|
|
91
|
+
socket.end();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
clearIdleExit();
|
|
96
|
+
setSessionId(message.sessionId);
|
|
97
|
+
sessions.set(message.sessionId, socket);
|
|
98
|
+
socket.setKeepAlive(true, KEEPALIVE_DELAY_MS);
|
|
99
|
+
writeFrame(socket, { type: "registered", sessionId: message.sessionId });
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
case "unregister":
|
|
104
|
+
unregister(currentSessionId);
|
|
105
|
+
setSessionId(undefined);
|
|
106
|
+
socket.end();
|
|
107
|
+
break;
|
|
108
|
+
|
|
109
|
+
case "list":
|
|
110
|
+
writeFrame(socket, {
|
|
111
|
+
type: "sessions",
|
|
112
|
+
requestId: message.requestId,
|
|
113
|
+
sessionIds: [...sessions.keys()],
|
|
114
|
+
});
|
|
115
|
+
break;
|
|
116
|
+
|
|
117
|
+
case "send":
|
|
118
|
+
handleSend(socket, currentSessionId, message);
|
|
119
|
+
break;
|
|
120
|
+
|
|
121
|
+
case "incoming_ack":
|
|
122
|
+
handleIncomingAck(message);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function handleSend(
|
|
128
|
+
socket: Socket,
|
|
129
|
+
currentSessionId: string | undefined,
|
|
130
|
+
message: SessionMessagingSendClientFrame,
|
|
131
|
+
): void {
|
|
132
|
+
const source = currentSessionId ? sessions.get(currentSessionId) : undefined;
|
|
133
|
+
const { messageId } = message;
|
|
134
|
+
|
|
135
|
+
if (!currentSessionId || !source) {
|
|
136
|
+
writeFrame(socket, {
|
|
137
|
+
type: "send_result",
|
|
138
|
+
requestId: message.requestId,
|
|
139
|
+
messageId,
|
|
140
|
+
delivered: false,
|
|
141
|
+
error: "Sender session is not registered.",
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const resolved = resolveTarget(message.target);
|
|
147
|
+
if ("error" in resolved) {
|
|
148
|
+
writeFrame(socket, {
|
|
149
|
+
type: "send_result",
|
|
150
|
+
requestId: message.requestId,
|
|
151
|
+
messageId,
|
|
152
|
+
delivered: false,
|
|
153
|
+
error: resolved.error,
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (resolved.sessionId === currentSessionId) {
|
|
159
|
+
writeFrame(socket, {
|
|
160
|
+
type: "send_result",
|
|
161
|
+
requestId: message.requestId,
|
|
162
|
+
messageId,
|
|
163
|
+
delivered: false,
|
|
164
|
+
error: "Cannot send a session message to the current session.",
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const timeout = setTimeout(() => {
|
|
170
|
+
pendingSends.delete(message.requestId);
|
|
171
|
+
writeFrame(socket, {
|
|
172
|
+
type: "send_result",
|
|
173
|
+
requestId: message.requestId,
|
|
174
|
+
messageId,
|
|
175
|
+
delivered: false,
|
|
176
|
+
error: "Timed out waiting for target session to accept the message.",
|
|
177
|
+
});
|
|
178
|
+
}, SEND_TIMEOUT_MS);
|
|
179
|
+
pendingSends.set(message.requestId, {
|
|
180
|
+
source: socket,
|
|
181
|
+
timeout,
|
|
182
|
+
messageId,
|
|
183
|
+
targetSessionId: resolved.sessionId,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
writeFrame(resolved.socket, {
|
|
187
|
+
type: "incoming",
|
|
188
|
+
requestId: message.requestId,
|
|
189
|
+
message: {
|
|
190
|
+
messageId,
|
|
191
|
+
source: currentSessionId,
|
|
192
|
+
target: resolved.sessionId,
|
|
193
|
+
body: message.body,
|
|
194
|
+
...(message.requestResponse === undefined
|
|
195
|
+
? {}
|
|
196
|
+
: { requestResponse: message.requestResponse }),
|
|
197
|
+
sentAt: message.sentAt,
|
|
198
|
+
...(message.sourceToolCallId === undefined
|
|
199
|
+
? {}
|
|
200
|
+
: { sourceToolCallId: message.sourceToolCallId }),
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function failPendingSendsTo(targetSessionId: string): void {
|
|
206
|
+
for (const [requestId, pending] of pendingSends) {
|
|
207
|
+
if (pending.targetSessionId !== targetSessionId) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
clearTimeout(pending.timeout);
|
|
212
|
+
pendingSends.delete(requestId);
|
|
213
|
+
writeFrame(pending.source, {
|
|
214
|
+
type: "send_result",
|
|
215
|
+
requestId,
|
|
216
|
+
messageId: pending.messageId,
|
|
217
|
+
delivered: false,
|
|
218
|
+
error: "Target disconnected before accepting the message.",
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function handleIncomingAck(message: SessionMessagingIncomingAckClientFrame): void {
|
|
224
|
+
const pending = pendingSends.get(message.requestId);
|
|
225
|
+
if (!pending) return;
|
|
226
|
+
|
|
227
|
+
clearTimeout(pending.timeout);
|
|
228
|
+
pendingSends.delete(message.requestId);
|
|
229
|
+
writeFrame(pending.source, {
|
|
230
|
+
type: "send_result",
|
|
231
|
+
requestId: message.requestId,
|
|
232
|
+
messageId: message.messageId,
|
|
233
|
+
delivered: message.delivered,
|
|
234
|
+
...(message.error === undefined ? {} : { error: message.error }),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function handleConnection(socket: Socket): void {
|
|
239
|
+
let sessionId: string | undefined;
|
|
240
|
+
socket.setKeepAlive(true, KEEPALIVE_DELAY_MS);
|
|
241
|
+
void readConnection(
|
|
242
|
+
socket,
|
|
243
|
+
() => sessionId,
|
|
244
|
+
(nextSessionId) => {
|
|
245
|
+
sessionId = nextSessionId;
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
socket.on("close", () => {
|
|
250
|
+
unregister(sessionId);
|
|
251
|
+
});
|
|
252
|
+
socket.on("error", () => {});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function readConnection(
|
|
256
|
+
socket: Socket,
|
|
257
|
+
getSessionId: () => string | undefined,
|
|
258
|
+
setSessionId: (sessionId: string | undefined) => void,
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
try {
|
|
261
|
+
for await (const frame of readFrames(
|
|
262
|
+
socket,
|
|
263
|
+
CLIENT_FRAME_SCHEMA,
|
|
264
|
+
"Invalid session messaging client frame",
|
|
265
|
+
)) {
|
|
266
|
+
handleFrame(socket, frame, getSessionId, setSessionId);
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
closeWithError(socket, error instanceof Error ? error.message : String(error));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function shutdown(): void {
|
|
274
|
+
for (const socket of sessions.values()) socket.end();
|
|
275
|
+
sessions.clear();
|
|
276
|
+
for (const pending of pendingSends.values()) clearTimeout(pending.timeout);
|
|
277
|
+
pendingSends.clear();
|
|
278
|
+
if (server) server.close();
|
|
279
|
+
if (process.platform !== "win32") {
|
|
280
|
+
try {
|
|
281
|
+
unlinkSync(socketPath);
|
|
282
|
+
} catch {}
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
unlinkSync(pidPath);
|
|
286
|
+
} catch {}
|
|
287
|
+
process.exit(0);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
mkdirSync(messagingDir, { recursive: true, mode: 0o700 });
|
|
291
|
+
if (process.platform !== "win32") {
|
|
292
|
+
try {
|
|
293
|
+
unlinkSync(socketPath);
|
|
294
|
+
} catch {}
|
|
295
|
+
}
|
|
296
|
+
server = createServer(handleConnection);
|
|
297
|
+
server.listen(socketPath, () => {
|
|
298
|
+
writeFileSync(pidPath, String(process.pid));
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
process.on("SIGTERM", shutdown);
|
|
302
|
+
process.on("SIGINT", shutdown);
|
|
@@ -0,0 +1,164 @@
|
|
|
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 {
|
|
7
|
+
getSessionMessagingDir,
|
|
8
|
+
getSessionMessagingSocketPath,
|
|
9
|
+
} from "../../shared/session-broker/socket-path.ts";
|
|
10
|
+
|
|
11
|
+
const BROKER_START_TIMEOUT_MS = 5_000;
|
|
12
|
+
const BROKER_CONNECT_TIMEOUT_MS = 1_000;
|
|
13
|
+
const BROKER_SPAWN_LOCK_STALE_MS = 10_000;
|
|
14
|
+
|
|
15
|
+
const brokerDir = getSessionMessagingDir();
|
|
16
|
+
const brokerSocketPath = getSessionMessagingSocketPath();
|
|
17
|
+
const brokerPidPath = join(brokerDir, "broker.pid");
|
|
18
|
+
const brokerSpawnLockPath = join(brokerDir, "broker.spawn.lock");
|
|
19
|
+
|
|
20
|
+
export async function spawnSessionMessagingBrokerIfNeeded(): Promise<void> {
|
|
21
|
+
mkdirSync(brokerDir, { recursive: true, mode: 0o700 });
|
|
22
|
+
|
|
23
|
+
if (await isBrokerRunning()) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!acquireSpawnLock()) {
|
|
28
|
+
await waitForBroker();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
if (await isBrokerRunning()) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const brokerPath = join(dirname(fileURLToPath(import.meta.url)), "process.ts");
|
|
38
|
+
const child = spawn(process.execPath, [brokerPath], {
|
|
39
|
+
detached: true,
|
|
40
|
+
stdio: "ignore",
|
|
41
|
+
windowsHide: process.platform === "win32",
|
|
42
|
+
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
|
43
|
+
});
|
|
44
|
+
child.unref();
|
|
45
|
+
await waitForBroker();
|
|
46
|
+
} finally {
|
|
47
|
+
releaseSpawnLock();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function isBrokerRunning(): Promise<boolean> {
|
|
52
|
+
if (await canConnectToBroker()) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!existsSync(brokerPidPath)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const pid = Number.parseInt(readFileSync(brokerPidPath, "utf8").trim(), 10);
|
|
62
|
+
if (!Number.isFinite(pid)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
process.kill(pid, 0);
|
|
66
|
+
return canConnectToBroker();
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function canConnectToBroker(): Promise<boolean> {
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const socket = net.connect(brokerSocketPath);
|
|
75
|
+
const finish = (connected: boolean): void => {
|
|
76
|
+
clearTimeout(timeout);
|
|
77
|
+
socket.off("connect", onConnect);
|
|
78
|
+
socket.off("error", onError);
|
|
79
|
+
resolve(connected);
|
|
80
|
+
};
|
|
81
|
+
const onConnect = (): void => {
|
|
82
|
+
socket.end();
|
|
83
|
+
finish(true);
|
|
84
|
+
};
|
|
85
|
+
const onError = (): void => {
|
|
86
|
+
socket.destroy();
|
|
87
|
+
finish(false);
|
|
88
|
+
};
|
|
89
|
+
const timeout = setTimeout(() => {
|
|
90
|
+
socket.destroy();
|
|
91
|
+
finish(false);
|
|
92
|
+
}, BROKER_CONNECT_TIMEOUT_MS);
|
|
93
|
+
socket.on("connect", onConnect);
|
|
94
|
+
socket.on("error", onError);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function waitForBroker(): Promise<void> {
|
|
99
|
+
const start = Date.now();
|
|
100
|
+
while (Date.now() - start < BROKER_START_TIMEOUT_MS) {
|
|
101
|
+
if (await canConnectToBroker()) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
throw new Error("Session messaging broker failed to start.");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function acquireSpawnLock(): boolean {
|
|
111
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
112
|
+
try {
|
|
113
|
+
writeFileSync(brokerSpawnLockPath, `${process.pid}\n${Date.now()}\n`, {
|
|
114
|
+
flag: "wx",
|
|
115
|
+
});
|
|
116
|
+
return true;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (!(error instanceof Error) || (error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
if (isSpawnLockStale()) {
|
|
122
|
+
try {
|
|
123
|
+
unlinkSync(brokerSpawnLockPath);
|
|
124
|
+
} catch {}
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isSpawnLockStale(): boolean {
|
|
135
|
+
if (!existsSync(brokerSpawnLockPath)) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const [pidLine = "", createdAtLine = "0"] = readFileSync(brokerSpawnLockPath, "utf8")
|
|
141
|
+
.trim()
|
|
142
|
+
.split("\n");
|
|
143
|
+
const pid = Number.parseInt(pidLine, 10);
|
|
144
|
+
const createdAt = Number.parseInt(createdAtLine, 10);
|
|
145
|
+
|
|
146
|
+
if (Number.isFinite(pid)) {
|
|
147
|
+
try {
|
|
148
|
+
process.kill(pid, 0);
|
|
149
|
+
} catch {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return !Number.isFinite(createdAt) || Date.now() - createdAt > BROKER_SPAWN_LOCK_STALE_MS;
|
|
155
|
+
} catch {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function releaseSpawnLock(): void {
|
|
161
|
+
try {
|
|
162
|
+
unlinkSync(brokerSpawnLockPath);
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { safeParseTypeBoxValue } from "../../shared/typebox.ts";
|
|
3
|
+
import { RECEIVED_MESSAGE_ENTRY_SCHEMA, type ReceivedMessageEntry } from "./message-contracts.ts";
|
|
4
|
+
|
|
5
|
+
export const MESSAGE_RECEIVED_CUSTOM_TYPE = "pi-sessions.message_received";
|
|
6
|
+
export const SESSION_MESSAGE_CUSTOM_TYPE = "pi-sessions.session_message";
|
|
7
|
+
|
|
8
|
+
type DeliveryResult = { delivered: true } | { delivered: false; error: string };
|
|
9
|
+
|
|
10
|
+
interface IncomingMessageActions {
|
|
11
|
+
appendEntry(customType: string, data: unknown): void;
|
|
12
|
+
sendMessage(
|
|
13
|
+
message: {
|
|
14
|
+
customType: string;
|
|
15
|
+
content: string;
|
|
16
|
+
display: boolean;
|
|
17
|
+
details: unknown;
|
|
18
|
+
},
|
|
19
|
+
options: { triggerTurn: true } | { deliverAs: "steer" },
|
|
20
|
+
): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class IncomingSessionMessageRuntime {
|
|
24
|
+
private context: ExtensionContext | undefined;
|
|
25
|
+
private readonly actions: IncomingMessageActions;
|
|
26
|
+
|
|
27
|
+
constructor(pi: ExtensionAPI) {
|
|
28
|
+
this.actions = {
|
|
29
|
+
appendEntry: pi.appendEntry.bind(pi),
|
|
30
|
+
sendMessage: pi.sendMessage.bind(pi),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
pi.on("session_start", (_event, ctx) => {
|
|
34
|
+
this.context = ctx;
|
|
35
|
+
});
|
|
36
|
+
pi.on("session_shutdown", () => {
|
|
37
|
+
this.context = undefined;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
deliver(received: ReceivedMessageEntry): DeliveryResult {
|
|
42
|
+
const ctx = this.context;
|
|
43
|
+
if (!ctx) {
|
|
44
|
+
return { delivered: false, error: "Target session is not ready." };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
this.actions.appendEntry(MESSAGE_RECEIVED_CUSTOM_TYPE, received);
|
|
49
|
+
this.inject(ctx, received);
|
|
50
|
+
return { delivered: true };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return { delivered: false, error: formatError(error) };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
replayPending(ctx: ExtensionContext): void {
|
|
57
|
+
const entries = ctx.sessionManager.getEntries();
|
|
58
|
+
const deliveredMessageIds = new Set<string>();
|
|
59
|
+
const pendingReceipts: ReceivedMessageEntry[] = [];
|
|
60
|
+
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (entry.type === "custom_message" && entry.customType === SESSION_MESSAGE_CUSTOM_TYPE) {
|
|
63
|
+
const delivered = safeParseTypeBoxValue(RECEIVED_MESSAGE_ENTRY_SCHEMA, entry.details);
|
|
64
|
+
if (delivered) {
|
|
65
|
+
deliveredMessageIds.add(delivered.messageId);
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (entry.type !== "custom" || entry.customType !== MESSAGE_RECEIVED_CUSTOM_TYPE) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const received = safeParseTypeBoxValue(RECEIVED_MESSAGE_ENTRY_SCHEMA, entry.data);
|
|
75
|
+
if (received) {
|
|
76
|
+
pendingReceipts.push(received);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const received of pendingReceipts) {
|
|
81
|
+
if (!deliveredMessageIds.has(received.messageId)) {
|
|
82
|
+
this.inject(ctx, received);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private inject(ctx: ExtensionContext, received: ReceivedMessageEntry): void {
|
|
88
|
+
const deliveryOptions = ctx.isIdle()
|
|
89
|
+
? { triggerTurn: true as const }
|
|
90
|
+
: { deliverAs: "steer" as const };
|
|
91
|
+
|
|
92
|
+
this.actions.sendMessage(
|
|
93
|
+
{
|
|
94
|
+
customType: SESSION_MESSAGE_CUSTOM_TYPE,
|
|
95
|
+
content: formatIncomingMessageForModel(received),
|
|
96
|
+
display: true,
|
|
97
|
+
details: received,
|
|
98
|
+
},
|
|
99
|
+
deliveryOptions,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatIncomingMessageForModel(received: ReceivedMessageEntry): string {
|
|
105
|
+
const title = received.source.sessionName?.trim();
|
|
106
|
+
const titlePart = title ? ` "${title}"` : "";
|
|
107
|
+
const relationPart = received.relation ? `, relation: ${received.relation}` : "";
|
|
108
|
+
const responseLine = received.requestResponse ? "\n\nResponse requested." : "";
|
|
109
|
+
return `Incoming message from session${titlePart} (session: ${received.source.sessionId}${relationPart}):\n\n${received.body}${responseLine}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatError(error: unknown): string {
|
|
113
|
+
return error instanceof Error && error.message.trim() ? error.message : String(error);
|
|
114
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
|
|
3
|
+
const RECEIVED_MESSAGE_ENDPOINT_SCHEMA = Type.Object({
|
|
4
|
+
sessionId: Type.String(),
|
|
5
|
+
sessionName: Type.Optional(Type.String()),
|
|
6
|
+
cwd: Type.Optional(Type.String()),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const SEND_MESSAGE_PARAMS = Type.Object({
|
|
10
|
+
session: Type.String({ description: "Pi session UUID." }),
|
|
11
|
+
message: Type.String({
|
|
12
|
+
description: "Message to send to the target session.",
|
|
13
|
+
}),
|
|
14
|
+
requestResponse: Type.Optional(
|
|
15
|
+
Type.Boolean({
|
|
16
|
+
description:
|
|
17
|
+
"Whether the target session should respond with completion/results back to this session.",
|
|
18
|
+
}),
|
|
19
|
+
),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const RECEIVED_MESSAGE_ENTRY_SCHEMA = Type.Object({
|
|
23
|
+
messageId: Type.String(),
|
|
24
|
+
source: RECEIVED_MESSAGE_ENDPOINT_SCHEMA,
|
|
25
|
+
target: RECEIVED_MESSAGE_ENDPOINT_SCHEMA,
|
|
26
|
+
body: Type.String(),
|
|
27
|
+
sentAt: Type.String(),
|
|
28
|
+
receivedAt: Type.String(),
|
|
29
|
+
requestResponse: Type.Optional(Type.Boolean()),
|
|
30
|
+
sourceToolCallId: Type.Optional(Type.String()),
|
|
31
|
+
relation: Type.Optional(Type.String()),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type SendMessageParams = Static<typeof SEND_MESSAGE_PARAMS>;
|
|
35
|
+
export type ReceivedMessageEndpoint = Static<typeof RECEIVED_MESSAGE_ENDPOINT_SCHEMA>;
|
|
36
|
+
export type ReceivedMessageEntry = Static<typeof RECEIVED_MESSAGE_ENTRY_SCHEMA>;
|