@m64/nats-pi-bridge 0.0.1
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/DESIGN.md +350 -0
- package/LICENSE +201 -0
- package/README.md +238 -0
- package/dist/server.js +689 -0
- package/package.json +54 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// server.ts
|
|
4
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
connect,
|
|
9
|
+
credsAuthenticator,
|
|
10
|
+
jwtAuthenticator,
|
|
11
|
+
nkeyAuthenticator,
|
|
12
|
+
tokenAuthenticator,
|
|
13
|
+
usernamePasswordAuthenticator
|
|
14
|
+
} from "@nats-io/transport-node";
|
|
15
|
+
import { Svcm } from "@nats-io/services";
|
|
16
|
+
import {
|
|
17
|
+
AuthStorage,
|
|
18
|
+
ModelRegistry,
|
|
19
|
+
SessionManager,
|
|
20
|
+
createAgentSession
|
|
21
|
+
} from "@mariozechner/pi-coding-agent";
|
|
22
|
+
var STATE_DIR = join(homedir(), ".pi-exec");
|
|
23
|
+
var CONFIG_FILE = join(STATE_DIR, "config.json");
|
|
24
|
+
var NATS_CONTEXT_DIR = join(homedir(), ".config", "nats", "context");
|
|
25
|
+
var SERVICE_NAME = "pi-exec";
|
|
26
|
+
var SERVICE_VERSION = "0.0.1";
|
|
27
|
+
var DEFAULT_MAX_LIFETIME_SECONDS = 1800;
|
|
28
|
+
var STALE_REQUEST_CUTOFF_MS = 30 * 60 * 1e3;
|
|
29
|
+
var STALE_PRUNE_INTERVAL_MS = 6e4;
|
|
30
|
+
var LIFETIME_CHECK_INTERVAL_MS = 3e4;
|
|
31
|
+
var SHUTDOWN_FORCED_EXIT_MS = 2e3;
|
|
32
|
+
var VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
33
|
+
var DEFAULT_CONTEXT = {
|
|
34
|
+
url: "demo.nats.io",
|
|
35
|
+
description: "NATS demo server (no auth)"
|
|
36
|
+
};
|
|
37
|
+
function intakeSubject(owner2) {
|
|
38
|
+
return `agents.pi-exec.${owner2}`;
|
|
39
|
+
}
|
|
40
|
+
function sessionSubject(owner2, sessionId) {
|
|
41
|
+
return `agents.pi-exec.${owner2}.${sessionId}`;
|
|
42
|
+
}
|
|
43
|
+
function sessionInspectSubject(owner2, sessionId) {
|
|
44
|
+
return `${sessionSubject(owner2, sessionId)}.inspect`;
|
|
45
|
+
}
|
|
46
|
+
function sanitizeSessionName(s) {
|
|
47
|
+
return s.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase().replace(/^-+|-+$/g, "");
|
|
48
|
+
}
|
|
49
|
+
function loadNatsContext(name) {
|
|
50
|
+
const contextFile = join(NATS_CONTEXT_DIR, `${name}.json`);
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(contextFile, "utf8"));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`NATS context "${name}" not found at ${contextFile} (${err.message})`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function contextToConnectOpts(ctx) {
|
|
60
|
+
const opts = {
|
|
61
|
+
name: "pi-exec"
|
|
62
|
+
};
|
|
63
|
+
if (ctx.url) {
|
|
64
|
+
opts.servers = ctx.url;
|
|
65
|
+
}
|
|
66
|
+
if (ctx.creds) {
|
|
67
|
+
opts.authenticator = credsAuthenticator(readFileSync(ctx.creds));
|
|
68
|
+
} else if (ctx.nkey) {
|
|
69
|
+
opts.authenticator = nkeyAuthenticator(readFileSync(ctx.nkey));
|
|
70
|
+
} else if (ctx.user_jwt && ctx.user_seed) {
|
|
71
|
+
const seed = new TextEncoder().encode(ctx.user_seed);
|
|
72
|
+
opts.authenticator = jwtAuthenticator(ctx.user_jwt, seed);
|
|
73
|
+
} else if (ctx.token) {
|
|
74
|
+
opts.authenticator = tokenAuthenticator(ctx.token);
|
|
75
|
+
} else if (ctx.user) {
|
|
76
|
+
opts.authenticator = usernamePasswordAuthenticator(ctx.user, ctx.password ?? "");
|
|
77
|
+
}
|
|
78
|
+
if (ctx.cert || ctx.key || ctx.ca) {
|
|
79
|
+
opts.tls = {
|
|
80
|
+
certFile: ctx.cert || void 0,
|
|
81
|
+
keyFile: ctx.key || void 0,
|
|
82
|
+
caFile: ctx.ca || void 0,
|
|
83
|
+
handshakeFirst: ctx.tls_first || void 0
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (ctx.inbox_prefix) {
|
|
87
|
+
opts.inboxPrefix = ctx.inbox_prefix;
|
|
88
|
+
}
|
|
89
|
+
return opts;
|
|
90
|
+
}
|
|
91
|
+
function loadConfig() {
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
94
|
+
} catch {
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function parseEnvelope(data) {
|
|
99
|
+
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(text);
|
|
103
|
+
} catch {
|
|
104
|
+
return { from: "anonymous", body: text };
|
|
105
|
+
}
|
|
106
|
+
if (typeof parsed === "object" && parsed !== null && typeof parsed.body === "string") {
|
|
107
|
+
const rec = parsed;
|
|
108
|
+
return {
|
|
109
|
+
from: typeof rec.from === "string" ? rec.from : "anonymous",
|
|
110
|
+
body: rec.body
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { from: "anonymous", body: text };
|
|
114
|
+
}
|
|
115
|
+
function publishChunked(nc2, replySubject, text) {
|
|
116
|
+
const maxPayload = nc2.info?.max_payload ?? 1024 * 1024;
|
|
117
|
+
const encoded = new TextEncoder().encode(text);
|
|
118
|
+
if (encoded.byteLength <= maxPayload) {
|
|
119
|
+
nc2.publish(replySubject, text);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
let offset = 0;
|
|
123
|
+
while (offset < encoded.byteLength) {
|
|
124
|
+
const chunk = encoded.subarray(offset, offset + maxPayload);
|
|
125
|
+
nc2.publish(replySubject, chunk);
|
|
126
|
+
offset += maxPayload;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function buildInspectResponse(sessionName, subject, cwd) {
|
|
130
|
+
return {
|
|
131
|
+
name: sessionName,
|
|
132
|
+
description: `PI agent in ${cwd}`,
|
|
133
|
+
capabilities: [
|
|
134
|
+
{
|
|
135
|
+
name: "prompt",
|
|
136
|
+
endpoint: subject,
|
|
137
|
+
description: "Send a prompt (plain text or {from,body} JSON) and receive a streamed response. Empty payload signals end-of-stream.",
|
|
138
|
+
pattern: "request/reply"
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
async function startStatusLoop(conn) {
|
|
144
|
+
try {
|
|
145
|
+
for await (const s of conn.status()) {
|
|
146
|
+
switch (s.type) {
|
|
147
|
+
case "disconnect":
|
|
148
|
+
process.stderr.write(`pi-exec: NATS disconnected from ${s.server} \u2014 retrying
|
|
149
|
+
`);
|
|
150
|
+
break;
|
|
151
|
+
case "reconnect":
|
|
152
|
+
process.stderr.write(`pi-exec: NATS reconnected to ${s.server}
|
|
153
|
+
`);
|
|
154
|
+
break;
|
|
155
|
+
case "error": {
|
|
156
|
+
const err = s.error;
|
|
157
|
+
const data = s.data;
|
|
158
|
+
process.stderr.write(
|
|
159
|
+
`pi-exec: NATS error: ${err?.message ?? String(data ?? "(unknown)")}
|
|
160
|
+
`
|
|
161
|
+
);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
var shuttingDown = false;
|
|
170
|
+
var requestCounter = 0;
|
|
171
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
172
|
+
var creating = /* @__PURE__ */ new Set();
|
|
173
|
+
var nc;
|
|
174
|
+
var intakeService;
|
|
175
|
+
var owner;
|
|
176
|
+
var config;
|
|
177
|
+
var authStorage;
|
|
178
|
+
var modelRegistry;
|
|
179
|
+
var lifetimeInterval;
|
|
180
|
+
var pruneInterval;
|
|
181
|
+
function resolveModel(modelSpec) {
|
|
182
|
+
if (!modelSpec) return void 0;
|
|
183
|
+
const slash = modelSpec.indexOf("/");
|
|
184
|
+
if (slash < 0) return void 0;
|
|
185
|
+
return modelRegistry.find(modelSpec.slice(0, slash), modelSpec.slice(slash + 1));
|
|
186
|
+
}
|
|
187
|
+
function validateThinkingLevel(level) {
|
|
188
|
+
if (level === void 0 || level === "") return void 0;
|
|
189
|
+
if (VALID_THINKING_LEVELS.includes(level)) {
|
|
190
|
+
return level;
|
|
191
|
+
}
|
|
192
|
+
throw new Error(
|
|
193
|
+
`invalid thinkingLevel: ${level} (must be one of ${VALID_THINKING_LEVELS.join(", ")})`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
async function createPiSession(intake) {
|
|
197
|
+
if (!intake.cwd) throw new Error("cwd is required");
|
|
198
|
+
const absCwd = resolve(intake.cwd);
|
|
199
|
+
if (!existsSync(absCwd) || !statSync(absCwd).isDirectory()) {
|
|
200
|
+
throw new Error(`cwd not found or not a directory: ${absCwd}`);
|
|
201
|
+
}
|
|
202
|
+
const modelSpec = intake.model ?? config.defaultModel;
|
|
203
|
+
const model = resolveModel(modelSpec);
|
|
204
|
+
if (modelSpec && !model) throw new Error(`unknown model: ${modelSpec}`);
|
|
205
|
+
const thinkingLevel = validateThinkingLevel(
|
|
206
|
+
intake.thinkingLevel ?? config.defaultThinkingLevel
|
|
207
|
+
);
|
|
208
|
+
const { session } = await createAgentSession({
|
|
209
|
+
cwd: absCwd,
|
|
210
|
+
sessionManager: SessionManager.inMemory(),
|
|
211
|
+
authStorage,
|
|
212
|
+
modelRegistry,
|
|
213
|
+
model,
|
|
214
|
+
thinkingLevel
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
session,
|
|
218
|
+
cwd: absCwd,
|
|
219
|
+
resolvedModel: model ? `${model.provider}/${model.id}` : void 0,
|
|
220
|
+
thinkingLevel
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
async function createPerSessionService(sessionId, subject, cwd) {
|
|
224
|
+
const svcm2 = new Svcm(nc);
|
|
225
|
+
const service = await svcm2.add({
|
|
226
|
+
name: SERVICE_NAME,
|
|
227
|
+
version: SERVICE_VERSION,
|
|
228
|
+
description: `${sessionId} \u2014 ${cwd}`,
|
|
229
|
+
metadata: {
|
|
230
|
+
type: "session",
|
|
231
|
+
platform: "pi",
|
|
232
|
+
sessionId,
|
|
233
|
+
cwd,
|
|
234
|
+
owner
|
|
235
|
+
},
|
|
236
|
+
queue: ""
|
|
237
|
+
});
|
|
238
|
+
service.addEndpoint("prompt", {
|
|
239
|
+
subject,
|
|
240
|
+
handler: (err, msg) => handlePerSessionMessage(sessionId, err, msg)
|
|
241
|
+
});
|
|
242
|
+
const inspectResp = JSON.stringify(buildInspectResponse(sessionId, subject, cwd));
|
|
243
|
+
service.addEndpoint("inspect", {
|
|
244
|
+
subject: sessionInspectSubject(owner, sessionId),
|
|
245
|
+
handler: (_err, msg) => {
|
|
246
|
+
try {
|
|
247
|
+
msg.respond(inspectResp);
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
return service;
|
|
253
|
+
}
|
|
254
|
+
function respondJson(msg, payload) {
|
|
255
|
+
try {
|
|
256
|
+
msg.respond(JSON.stringify(payload));
|
|
257
|
+
} catch {
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function handleIntakeMessage(err, msg) {
|
|
261
|
+
if (err) {
|
|
262
|
+
process.stderr.write(`pi-exec: intake error: ${err.message}
|
|
263
|
+
`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (shuttingDown) {
|
|
267
|
+
respondJson(msg, { error: "shutting_down" });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
let intake;
|
|
271
|
+
try {
|
|
272
|
+
intake = JSON.parse(msg.string());
|
|
273
|
+
} catch (e) {
|
|
274
|
+
respondJson(msg, { error: "invalid_json", message: e.message });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (!intake || typeof intake.sessionMode !== "string") {
|
|
278
|
+
respondJson(msg, { error: "invalid_request", message: "sessionMode required" });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
switch (intake.sessionMode) {
|
|
282
|
+
case "run":
|
|
283
|
+
void handleRunMode(msg, intake);
|
|
284
|
+
break;
|
|
285
|
+
case "session":
|
|
286
|
+
void handleSessionMode(msg, intake);
|
|
287
|
+
break;
|
|
288
|
+
case "stop":
|
|
289
|
+
void handleStopMode(msg, intake);
|
|
290
|
+
break;
|
|
291
|
+
case "list":
|
|
292
|
+
handleListMode(msg);
|
|
293
|
+
break;
|
|
294
|
+
default:
|
|
295
|
+
respondJson(msg, {
|
|
296
|
+
error: "invalid_sessionMode",
|
|
297
|
+
sessionMode: intake.sessionMode
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async function handleRunMode(msg, intake) {
|
|
302
|
+
const reply = msg.reply;
|
|
303
|
+
if (!reply) {
|
|
304
|
+
respondJson(msg, { error: "no_reply_subject" });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (!intake.body || !intake.cwd) {
|
|
308
|
+
respondJson(msg, { error: "missing_fields", required: ["body", "cwd"] });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
let session;
|
|
312
|
+
let unsubscribe;
|
|
313
|
+
try {
|
|
314
|
+
const created = await createPiSession(intake);
|
|
315
|
+
session = created.session;
|
|
316
|
+
unsubscribe = session.subscribe((ev) => {
|
|
317
|
+
if (ev.type === "message_update" && ev.assistantMessageEvent.type === "text_delta" && ev.assistantMessageEvent.delta) {
|
|
318
|
+
publishChunked(nc, reply, ev.assistantMessageEvent.delta);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
await session.prompt(intake.body);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
try {
|
|
324
|
+
nc.publish(reply, `error: ${e.message}`);
|
|
325
|
+
} catch {
|
|
326
|
+
}
|
|
327
|
+
process.stderr.write(`pi-exec: run error: ${e.message}
|
|
328
|
+
`);
|
|
329
|
+
} finally {
|
|
330
|
+
try {
|
|
331
|
+
unsubscribe?.();
|
|
332
|
+
} catch {
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
session?.dispose();
|
|
336
|
+
} catch {
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
nc.publish(reply, "");
|
|
340
|
+
} catch {
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
await nc.flush();
|
|
344
|
+
} catch {
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async function handleSessionMode(msg, intake) {
|
|
349
|
+
const reply = msg.reply;
|
|
350
|
+
if (!reply) {
|
|
351
|
+
respondJson(msg, { error: "no_reply_subject" });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (!intake.body || !intake.cwd || !intake.sessionId) {
|
|
355
|
+
respondJson(msg, {
|
|
356
|
+
error: "missing_fields",
|
|
357
|
+
required: ["body", "cwd", "sessionId"]
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const sessionId = sanitizeSessionName(intake.sessionId);
|
|
362
|
+
if (!sessionId) {
|
|
363
|
+
respondJson(msg, {
|
|
364
|
+
error: "invalid_sessionId",
|
|
365
|
+
message: "empty after sanitize"
|
|
366
|
+
});
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (sessionId !== intake.sessionId) {
|
|
370
|
+
respondJson(msg, {
|
|
371
|
+
error: "invalid_sessionId",
|
|
372
|
+
sessionId: intake.sessionId,
|
|
373
|
+
message: `sessionId must match [a-z0-9_-]+; suggested: ${sessionId}`
|
|
374
|
+
});
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (sessions.has(sessionId) || creating.has(sessionId)) {
|
|
378
|
+
respondJson(msg, {
|
|
379
|
+
error: "session_exists",
|
|
380
|
+
sessionId,
|
|
381
|
+
subject: sessionSubject(owner, sessionId),
|
|
382
|
+
message: "Session already exists. Send follow-up prompts to the session subject."
|
|
383
|
+
});
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
creating.add(sessionId);
|
|
387
|
+
let created;
|
|
388
|
+
try {
|
|
389
|
+
created = await createPiSession(intake);
|
|
390
|
+
} catch (e) {
|
|
391
|
+
creating.delete(sessionId);
|
|
392
|
+
respondJson(msg, {
|
|
393
|
+
error: "session_create_failed",
|
|
394
|
+
message: e.message
|
|
395
|
+
});
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const subject = sessionSubject(owner, sessionId);
|
|
399
|
+
let service;
|
|
400
|
+
try {
|
|
401
|
+
service = await createPerSessionService(sessionId, subject, created.cwd);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
creating.delete(sessionId);
|
|
404
|
+
try {
|
|
405
|
+
created.session.dispose();
|
|
406
|
+
} catch {
|
|
407
|
+
}
|
|
408
|
+
respondJson(msg, {
|
|
409
|
+
error: "service_register_failed",
|
|
410
|
+
message: e.message
|
|
411
|
+
});
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const managed = {
|
|
415
|
+
sessionId,
|
|
416
|
+
session: created.session,
|
|
417
|
+
service,
|
|
418
|
+
cwd: created.cwd,
|
|
419
|
+
model: created.resolvedModel,
|
|
420
|
+
thinkingLevel: created.thinkingLevel,
|
|
421
|
+
createdAt: Date.now(),
|
|
422
|
+
lastActivity: Date.now(),
|
|
423
|
+
maxLifetime: intake.maxLifetime ?? config.defaultMaxLifetime ?? DEFAULT_MAX_LIFETIME_SECONDS,
|
|
424
|
+
subject,
|
|
425
|
+
pendingRequests: /* @__PURE__ */ new Map(),
|
|
426
|
+
requestQueue: [],
|
|
427
|
+
activeRequestId: null,
|
|
428
|
+
disposed: false
|
|
429
|
+
};
|
|
430
|
+
sessions.set(sessionId, managed);
|
|
431
|
+
creating.delete(sessionId);
|
|
432
|
+
process.stderr.write(`pi-exec: session created ${sessionId} cwd=${created.cwd}
|
|
433
|
+
`);
|
|
434
|
+
const initialId = `init-${++requestCounter}`;
|
|
435
|
+
managed.pendingRequests.set(initialId, {
|
|
436
|
+
requestId: initialId,
|
|
437
|
+
replySubject: reply,
|
|
438
|
+
from: intake.from ?? "anonymous",
|
|
439
|
+
body: intake.body,
|
|
440
|
+
createdAt: Date.now()
|
|
441
|
+
});
|
|
442
|
+
managed.requestQueue.push(initialId);
|
|
443
|
+
void drainSession(
|
|
444
|
+
managed,
|
|
445
|
+
() => {
|
|
446
|
+
process.stderr.write(`pi-exec: initial prompt failed for ${sessionId}; disposing
|
|
447
|
+
`);
|
|
448
|
+
void disposeSession(managed);
|
|
449
|
+
},
|
|
450
|
+
initialId
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
async function handleStopMode(msg, intake) {
|
|
454
|
+
if (!intake.sessionId) {
|
|
455
|
+
respondJson(msg, { error: "missing_fields", required: ["sessionId"] });
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const sid = sanitizeSessionName(intake.sessionId);
|
|
459
|
+
const managed = sessions.get(sid);
|
|
460
|
+
if (!managed) {
|
|
461
|
+
respondJson(msg, { error: "not_found", sessionId: sid });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
await disposeSession(managed);
|
|
465
|
+
respondJson(msg, { ok: true, sessionId: sid });
|
|
466
|
+
}
|
|
467
|
+
function handleListMode(msg) {
|
|
468
|
+
const now = Date.now();
|
|
469
|
+
const list = Array.from(sessions.values()).map((m) => {
|
|
470
|
+
const elapsedSec = Math.floor((now - m.createdAt) / 1e3);
|
|
471
|
+
const remaining = m.maxLifetime > 0 ? Math.max(0, m.maxLifetime - elapsedSec) : 0;
|
|
472
|
+
return {
|
|
473
|
+
sessionId: m.sessionId,
|
|
474
|
+
subject: m.subject,
|
|
475
|
+
cwd: m.cwd,
|
|
476
|
+
model: m.model,
|
|
477
|
+
thinkingLevel: m.thinkingLevel,
|
|
478
|
+
createdAt: new Date(m.createdAt).toISOString(),
|
|
479
|
+
lastActivity: new Date(m.lastActivity).toISOString(),
|
|
480
|
+
maxLifetime: m.maxLifetime,
|
|
481
|
+
remainingLifetime: remaining,
|
|
482
|
+
activeRequest: m.activeRequestId !== null,
|
|
483
|
+
queuedRequests: m.requestQueue.length
|
|
484
|
+
};
|
|
485
|
+
});
|
|
486
|
+
respondJson(msg, { sessions: list });
|
|
487
|
+
}
|
|
488
|
+
function handlePerSessionMessage(sessionId, err, msg) {
|
|
489
|
+
if (err) {
|
|
490
|
+
process.stderr.write(`pi-exec: ${sessionId} handler err: ${err.message}
|
|
491
|
+
`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (!msg.reply) return;
|
|
495
|
+
const managed = sessions.get(sessionId);
|
|
496
|
+
if (!managed || managed.disposed) return;
|
|
497
|
+
const env = parseEnvelope(msg.data);
|
|
498
|
+
if (!env.body) return;
|
|
499
|
+
const rid = `${sessionId}-${++requestCounter}`;
|
|
500
|
+
managed.pendingRequests.set(rid, {
|
|
501
|
+
requestId: rid,
|
|
502
|
+
replySubject: msg.reply,
|
|
503
|
+
from: env.from,
|
|
504
|
+
body: env.body,
|
|
505
|
+
createdAt: Date.now()
|
|
506
|
+
});
|
|
507
|
+
managed.requestQueue.push(rid);
|
|
508
|
+
managed.lastActivity = Date.now();
|
|
509
|
+
void drainSession(managed);
|
|
510
|
+
}
|
|
511
|
+
async function drainSession(m, onInitialFailure, initialRequestId) {
|
|
512
|
+
if (m.activeRequestId) return;
|
|
513
|
+
if (m.disposed) return;
|
|
514
|
+
const next = m.requestQueue.shift();
|
|
515
|
+
if (!next) return;
|
|
516
|
+
const pr = m.pendingRequests.get(next);
|
|
517
|
+
if (!pr) {
|
|
518
|
+
void drainSession(m);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
m.activeRequestId = next;
|
|
522
|
+
m.lastActivity = Date.now();
|
|
523
|
+
let unsubscribe;
|
|
524
|
+
let failed = false;
|
|
525
|
+
try {
|
|
526
|
+
unsubscribe = m.session.subscribe((ev) => {
|
|
527
|
+
if (ev.type === "message_update" && ev.assistantMessageEvent.type === "text_delta" && ev.assistantMessageEvent.delta) {
|
|
528
|
+
publishChunked(nc, pr.replySubject, ev.assistantMessageEvent.delta);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
await m.session.prompt(pr.body);
|
|
532
|
+
} catch (e) {
|
|
533
|
+
failed = true;
|
|
534
|
+
try {
|
|
535
|
+
nc.publish(pr.replySubject, `error: ${e.message}`);
|
|
536
|
+
} catch {
|
|
537
|
+
}
|
|
538
|
+
process.stderr.write(`pi-exec: ${m.sessionId} prompt error: ${e.message}
|
|
539
|
+
`);
|
|
540
|
+
} finally {
|
|
541
|
+
try {
|
|
542
|
+
unsubscribe?.();
|
|
543
|
+
} catch {
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
nc.publish(pr.replySubject, "");
|
|
547
|
+
} catch {
|
|
548
|
+
}
|
|
549
|
+
try {
|
|
550
|
+
await nc.flush();
|
|
551
|
+
} catch {
|
|
552
|
+
}
|
|
553
|
+
m.pendingRequests.delete(next);
|
|
554
|
+
m.activeRequestId = null;
|
|
555
|
+
m.lastActivity = Date.now();
|
|
556
|
+
if (failed && onInitialFailure && next === initialRequestId) {
|
|
557
|
+
onInitialFailure();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (!m.disposed && m.requestQueue.length > 0) {
|
|
561
|
+
setImmediate(() => void drainSession(m));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
async function disposeSession(m) {
|
|
566
|
+
if (m.disposed) return;
|
|
567
|
+
m.disposed = true;
|
|
568
|
+
for (const pr of m.pendingRequests.values()) {
|
|
569
|
+
try {
|
|
570
|
+
nc.publish(pr.replySubject, "");
|
|
571
|
+
} catch {
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
m.pendingRequests.clear();
|
|
575
|
+
m.requestQueue.length = 0;
|
|
576
|
+
try {
|
|
577
|
+
await m.service.stop();
|
|
578
|
+
} catch {
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
m.session.dispose();
|
|
582
|
+
} catch {
|
|
583
|
+
}
|
|
584
|
+
sessions.delete(m.sessionId);
|
|
585
|
+
process.stderr.write(`pi-exec: session disposed ${m.sessionId}
|
|
586
|
+
`);
|
|
587
|
+
}
|
|
588
|
+
function checkLifetimes() {
|
|
589
|
+
const now = Date.now();
|
|
590
|
+
for (const m of sessions.values()) {
|
|
591
|
+
if (m.maxLifetime <= 0) continue;
|
|
592
|
+
if (now - m.createdAt > m.maxLifetime * 1e3) {
|
|
593
|
+
process.stderr.write(`pi-exec: session ${m.sessionId} expired (${m.maxLifetime}s)
|
|
594
|
+
`);
|
|
595
|
+
void disposeSession(m);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function pruneStaleRequests() {
|
|
600
|
+
const cutoff = Date.now() - STALE_REQUEST_CUTOFF_MS;
|
|
601
|
+
for (const m of sessions.values()) {
|
|
602
|
+
for (const [id, pr] of m.pendingRequests) {
|
|
603
|
+
if (id === m.activeRequestId) continue;
|
|
604
|
+
if (pr.createdAt < cutoff) {
|
|
605
|
+
m.pendingRequests.delete(id);
|
|
606
|
+
const qi = m.requestQueue.indexOf(id);
|
|
607
|
+
if (qi >= 0) m.requestQueue.splice(qi, 1);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
async function shutdown(signal) {
|
|
613
|
+
if (shuttingDown) return;
|
|
614
|
+
shuttingDown = true;
|
|
615
|
+
process.stderr.write(`pi-exec: shutdown (${signal})
|
|
616
|
+
`);
|
|
617
|
+
const forceTimer = setTimeout(() => {
|
|
618
|
+
process.stderr.write("pi-exec: forced exit\n");
|
|
619
|
+
process.exit(0);
|
|
620
|
+
}, SHUTDOWN_FORCED_EXIT_MS);
|
|
621
|
+
forceTimer.unref();
|
|
622
|
+
clearInterval(lifetimeInterval);
|
|
623
|
+
clearInterval(pruneInterval);
|
|
624
|
+
await Promise.allSettled(Array.from(sessions.values()).map((m) => disposeSession(m)));
|
|
625
|
+
try {
|
|
626
|
+
await intakeService.stop();
|
|
627
|
+
} catch {
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
await nc.drain();
|
|
631
|
+
} catch {
|
|
632
|
+
}
|
|
633
|
+
clearTimeout(forceTimer);
|
|
634
|
+
process.exit(0);
|
|
635
|
+
}
|
|
636
|
+
process.on("unhandledRejection", (err) => {
|
|
637
|
+
process.stderr.write(`pi-exec: unhandledRejection: ${err}
|
|
638
|
+
`);
|
|
639
|
+
});
|
|
640
|
+
process.on("uncaughtException", (err) => {
|
|
641
|
+
process.stderr.write(`pi-exec: uncaughtException: ${err}
|
|
642
|
+
`);
|
|
643
|
+
});
|
|
644
|
+
config = loadConfig();
|
|
645
|
+
var contextName = process.env.NATS_CONTEXT ?? config.context;
|
|
646
|
+
config.defaultModel = process.env.PI_EXEC_DEFAULT_MODEL ?? config.defaultModel;
|
|
647
|
+
var envMaxLifetime = process.env.PI_EXEC_DEFAULT_MAX_LIFETIME;
|
|
648
|
+
config.defaultMaxLifetime = envMaxLifetime ? Number(envMaxLifetime) : config.defaultMaxLifetime ?? DEFAULT_MAX_LIFETIME_SECONDS;
|
|
649
|
+
var natsCtx;
|
|
650
|
+
try {
|
|
651
|
+
natsCtx = contextName ? loadNatsContext(contextName) : DEFAULT_CONTEXT;
|
|
652
|
+
} catch (e) {
|
|
653
|
+
process.stderr.write(`pi-exec: ${e.message}
|
|
654
|
+
`);
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
process.stderr.write(
|
|
658
|
+
`pi-exec: connecting to ${natsCtx.url ?? "default"} (context: ${contextName ?? "default"})
|
|
659
|
+
`
|
|
660
|
+
);
|
|
661
|
+
var connectOpts = contextToConnectOpts(natsCtx);
|
|
662
|
+
connectOpts.name = "pi-exec";
|
|
663
|
+
nc = await connect(connectOpts);
|
|
664
|
+
process.stderr.write(`pi-exec: connected
|
|
665
|
+
`);
|
|
666
|
+
owner = sanitizeSessionName(process.env.USER ?? "unknown") || "unknown";
|
|
667
|
+
authStorage = AuthStorage.create();
|
|
668
|
+
modelRegistry = ModelRegistry.create(authStorage);
|
|
669
|
+
var svcm = new Svcm(nc);
|
|
670
|
+
intakeService = await svcm.add({
|
|
671
|
+
name: SERVICE_NAME,
|
|
672
|
+
version: SERVICE_VERSION,
|
|
673
|
+
description: "intake",
|
|
674
|
+
metadata: { type: "intake", platform: "pi", owner },
|
|
675
|
+
queue: ""
|
|
676
|
+
});
|
|
677
|
+
intakeService.addEndpoint("intake", {
|
|
678
|
+
subject: intakeSubject(owner),
|
|
679
|
+
handler: handleIntakeMessage
|
|
680
|
+
});
|
|
681
|
+
process.stderr.write(`pi-exec: intake registered on ${intakeSubject(owner)}
|
|
682
|
+
`);
|
|
683
|
+
lifetimeInterval = setInterval(checkLifetimes, LIFETIME_CHECK_INTERVAL_MS);
|
|
684
|
+
lifetimeInterval.unref();
|
|
685
|
+
pruneInterval = setInterval(pruneStaleRequests, STALE_PRUNE_INTERVAL_MS);
|
|
686
|
+
pruneInterval.unref();
|
|
687
|
+
void startStatusLoop(nc);
|
|
688
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
689
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|