codeharbor 0.1.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/cli.js ADDED
@@ -0,0 +1,2342 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/cli.ts
27
+ var import_commander = require("commander");
28
+
29
+ // src/app.ts
30
+ var import_node_child_process2 = require("child_process");
31
+ var import_node_util = require("util");
32
+
33
+ // src/channels/matrix-channel.ts
34
+ var import_promises = __toESM(require("fs/promises"));
35
+ var import_node_os = __toESM(require("os"));
36
+ var import_node_path = __toESM(require("path"));
37
+ var import_matrix_js_sdk = require("matrix-js-sdk");
38
+
39
+ // src/utils/message.ts
40
+ function extractCommandText(rawText, prefix) {
41
+ const incoming = rawText.trim();
42
+ if (!incoming) {
43
+ return null;
44
+ }
45
+ if (!prefix) {
46
+ return incoming;
47
+ }
48
+ if (!incoming.startsWith(prefix)) {
49
+ return null;
50
+ }
51
+ const nextChar = incoming.slice(prefix.length, prefix.length + 1);
52
+ if (nextChar && !/\s/.test(nextChar)) {
53
+ return null;
54
+ }
55
+ const stripped = incoming.slice(prefix.length).trim();
56
+ return stripped.length > 0 ? stripped : null;
57
+ }
58
+ function splitText(text, chunkSize) {
59
+ const clean = text.trim();
60
+ if (!clean) {
61
+ return [""];
62
+ }
63
+ if (chunkSize <= 0 || clean.length <= chunkSize) {
64
+ return [clean];
65
+ }
66
+ const blocks = splitIntoBlocks(clean);
67
+ const chunks = [];
68
+ let current = "";
69
+ for (const block of blocks) {
70
+ if (block.length > chunkSize) {
71
+ if (current) {
72
+ chunks.push(current);
73
+ current = "";
74
+ }
75
+ chunks.push(...splitOversizedBlock(block, chunkSize));
76
+ continue;
77
+ }
78
+ if (!current) {
79
+ current = block;
80
+ continue;
81
+ }
82
+ const combined = `${current}
83
+
84
+ ${block}`;
85
+ if (combined.length <= chunkSize) {
86
+ current = combined;
87
+ continue;
88
+ }
89
+ chunks.push(current);
90
+ current = block;
91
+ }
92
+ if (current) {
93
+ chunks.push(current);
94
+ }
95
+ return chunks;
96
+ }
97
+ function splitIntoBlocks(text) {
98
+ const blocks = [];
99
+ const codeFenceRegex = /```[\s\S]*?```/g;
100
+ let cursor = 0;
101
+ for (const match of text.matchAll(codeFenceRegex)) {
102
+ const index = match.index ?? 0;
103
+ const before = text.slice(cursor, index);
104
+ blocks.push(...splitParagraphs(before));
105
+ const codeBlock = match[0]?.trim();
106
+ if (codeBlock) {
107
+ blocks.push(codeBlock);
108
+ }
109
+ cursor = index + (match[0]?.length ?? 0);
110
+ }
111
+ const remainder = text.slice(cursor);
112
+ blocks.push(...splitParagraphs(remainder));
113
+ return blocks.filter((entry) => entry.length > 0);
114
+ }
115
+ function splitParagraphs(text) {
116
+ return text.split(/\n\s*\n+/).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
117
+ }
118
+ function splitOversizedBlock(block, chunkSize) {
119
+ if (isFencedCodeBlock(block)) {
120
+ return splitFencedCodeBlock(block, chunkSize);
121
+ }
122
+ return splitPlainText(block, chunkSize);
123
+ }
124
+ function isFencedCodeBlock(block) {
125
+ return block.startsWith("```") && block.endsWith("```");
126
+ }
127
+ function splitFencedCodeBlock(block, chunkSize) {
128
+ const lines = block.split("\n");
129
+ if (lines.length < 2) {
130
+ return splitPlainText(block, chunkSize);
131
+ }
132
+ const openingFence = lines[0];
133
+ const closingFence = lines[lines.length - 1];
134
+ if (!openingFence.startsWith("```") || closingFence !== "```") {
135
+ return splitPlainText(block, chunkSize);
136
+ }
137
+ const maxBodySize = chunkSize - openingFence.length - closingFence.length - 2;
138
+ if (maxBodySize <= 20) {
139
+ return splitPlainText(block, chunkSize);
140
+ }
141
+ const body = lines.slice(1, -1).join("\n");
142
+ const bodyParts = splitPlainText(body, maxBodySize);
143
+ if (bodyParts.length === 0) {
144
+ return [block];
145
+ }
146
+ return bodyParts.map((part) => `${openingFence}
147
+ ${part}
148
+ ${closingFence}`);
149
+ }
150
+ function splitPlainText(text, chunkSize) {
151
+ if (text.length <= chunkSize) {
152
+ return [text];
153
+ }
154
+ const chunks = [];
155
+ let remaining = text;
156
+ while (remaining.length > chunkSize) {
157
+ const candidate = remaining.slice(0, chunkSize);
158
+ const breakIndex = findBreakIndex(candidate);
159
+ const splitAt = breakIndex > 0 ? breakIndex : chunkSize;
160
+ const head = remaining.slice(0, splitAt).trim();
161
+ if (head) {
162
+ chunks.push(head);
163
+ }
164
+ remaining = remaining.slice(splitAt).trim();
165
+ }
166
+ if (remaining) {
167
+ chunks.push(remaining);
168
+ }
169
+ return chunks.length > 0 ? chunks : [text.slice(0, chunkSize)];
170
+ }
171
+ function findBreakIndex(candidate) {
172
+ const newline = candidate.lastIndexOf("\n");
173
+ if (newline >= Math.floor(candidate.length * 0.5)) {
174
+ return newline;
175
+ }
176
+ const whitespace = candidate.search(/\s[^\s]*$/);
177
+ if (whitespace >= Math.floor(candidate.length * 0.5)) {
178
+ return whitespace;
179
+ }
180
+ return -1;
181
+ }
182
+
183
+ // src/channels/matrix-channel.ts
184
+ var MatrixChannel = class {
185
+ config;
186
+ logger;
187
+ chunkSize;
188
+ splitReplies;
189
+ preserveWhitespace;
190
+ fetchMedia;
191
+ client;
192
+ handler = null;
193
+ started = false;
194
+ constructor(config, logger) {
195
+ this.config = config;
196
+ this.logger = logger;
197
+ this.chunkSize = config.replyChunkSize;
198
+ this.splitReplies = !config.cliCompat.disableReplyChunkSplit;
199
+ this.preserveWhitespace = config.cliCompat.preserveWhitespace;
200
+ this.fetchMedia = config.cliCompat.fetchMedia;
201
+ this.client = (0, import_matrix_js_sdk.createClient)({
202
+ baseUrl: config.matrixHomeserver,
203
+ accessToken: config.matrixAccessToken,
204
+ userId: config.matrixUserId
205
+ });
206
+ }
207
+ async start(handler) {
208
+ this.handler = handler;
209
+ this.client.on(import_matrix_js_sdk.RoomEvent.Timeline, this.onTimeline);
210
+ this.client.on(import_matrix_js_sdk.RoomMemberEvent.Membership, this.onMembership);
211
+ const readyPromise = this.waitUntilReady();
212
+ this.client.startClient({ initialSyncLimit: 10 });
213
+ await readyPromise;
214
+ await this.joinPendingInvites();
215
+ this.started = true;
216
+ this.logger.info("Matrix channel ready.");
217
+ }
218
+ async sendMessage(conversationId, text) {
219
+ if (!this.started) {
220
+ throw new Error("Matrix channel not started.");
221
+ }
222
+ const chunks = this.splitReplies ? splitText(text, this.chunkSize) : [text];
223
+ for (const chunk of chunks) {
224
+ await this.client.sendTextMessage(conversationId, chunk);
225
+ }
226
+ }
227
+ async sendNotice(conversationId, text) {
228
+ if (!this.started) {
229
+ throw new Error("Matrix channel not started.");
230
+ }
231
+ const chunks = this.splitReplies ? splitText(text, this.chunkSize) : [text];
232
+ for (const chunk of chunks) {
233
+ await this.client.sendNotice(conversationId, chunk);
234
+ }
235
+ }
236
+ async upsertProgressNotice(conversationId, text, replaceEventId) {
237
+ if (!this.started) {
238
+ throw new Error("Matrix channel not started.");
239
+ }
240
+ const normalized = (this.splitReplies ? splitText(text, this.chunkSize)[0] : text) ?? "";
241
+ if (!normalized.trim()) {
242
+ throw new Error("Progress notice cannot be empty.");
243
+ }
244
+ if (!replaceEventId) {
245
+ const response2 = await this.client.sendNotice(conversationId, normalized);
246
+ return response2.event_id;
247
+ }
248
+ const content = {
249
+ msgtype: "m.notice",
250
+ body: `* ${normalized}`,
251
+ "m.new_content": {
252
+ msgtype: "m.notice",
253
+ body: normalized
254
+ },
255
+ "m.relates_to": {
256
+ rel_type: "m.replace",
257
+ event_id: replaceEventId
258
+ }
259
+ };
260
+ const sendEditEvent = this.client.sendEvent;
261
+ const response = await sendEditEvent(conversationId, import_matrix_js_sdk.EventType.RoomMessage, content);
262
+ return response.event_id;
263
+ }
264
+ async setTyping(conversationId, isTyping, timeoutMs) {
265
+ if (!this.started) {
266
+ throw new Error("Matrix channel not started.");
267
+ }
268
+ const safeTimeout = Math.max(0, timeoutMs);
269
+ await this.client.sendTyping(conversationId, isTyping, safeTimeout);
270
+ }
271
+ async stop() {
272
+ this.client.removeListener(import_matrix_js_sdk.RoomEvent.Timeline, this.onTimeline);
273
+ this.client.removeListener(import_matrix_js_sdk.RoomMemberEvent.Membership, this.onMembership);
274
+ this.client.stopClient();
275
+ this.started = false;
276
+ }
277
+ onMembership = (_event, member) => {
278
+ if (!member || member.membership !== "invite") {
279
+ return;
280
+ }
281
+ if (member.userId !== this.config.matrixUserId) {
282
+ return;
283
+ }
284
+ if (!member.roomId) {
285
+ return;
286
+ }
287
+ void this.joinInvitedRoom(member.roomId);
288
+ };
289
+ onTimeline = (event, room, toStartOfTimeline) => {
290
+ if (!this.handler || !room || toStartOfTimeline) {
291
+ return;
292
+ }
293
+ if (event.getType() !== "m.room.message") {
294
+ return;
295
+ }
296
+ const senderId = event.getSender();
297
+ if (!senderId || senderId === this.config.matrixUserId) {
298
+ return;
299
+ }
300
+ const content = event.getContent();
301
+ if (!content || typeof content !== "object") {
302
+ return;
303
+ }
304
+ const msgtype = typeof content.msgtype === "string" ? content.msgtype : "";
305
+ const acceptedMsgtypes = /* @__PURE__ */ new Set(["m.text", "m.image", "m.file", "m.audio", "m.video"]);
306
+ if (!acceptedMsgtypes.has(msgtype)) {
307
+ return;
308
+ }
309
+ const eventId = event.getId();
310
+ if (!eventId || typeof eventId !== "string") {
311
+ return;
312
+ }
313
+ const body = typeof content.body === "string" ? content.body : "";
314
+ const text = this.preserveWhitespace ? body : body.trim();
315
+ const attachments = extractAttachments(content);
316
+ if (!text.trim() && attachments.length === 0) {
317
+ return;
318
+ }
319
+ const isDirectMessage = isDirectRoom(room);
320
+ const mentionsBot = checkMentionsBot(content, text, this.config.matrixUserId);
321
+ const repliesToBot = checkRepliesToBot(content, room, this.config.matrixUserId);
322
+ void this.dispatchInbound({
323
+ senderId,
324
+ roomId: room.roomId,
325
+ eventId,
326
+ text,
327
+ attachments,
328
+ isDirectMessage,
329
+ mentionsBot,
330
+ repliesToBot
331
+ });
332
+ };
333
+ async dispatchInbound(params) {
334
+ if (!this.handler) {
335
+ return;
336
+ }
337
+ const hydratedAttachments = await this.hydrateAttachments(params.attachments, params.eventId);
338
+ const inbound = {
339
+ requestId: buildRequestId(params.eventId),
340
+ channel: "matrix",
341
+ conversationId: params.roomId,
342
+ senderId: params.senderId,
343
+ eventId: params.eventId,
344
+ text: params.text,
345
+ attachments: hydratedAttachments,
346
+ isDirectMessage: params.isDirectMessage,
347
+ mentionsBot: params.mentionsBot,
348
+ repliesToBot: params.repliesToBot
349
+ };
350
+ try {
351
+ await this.handler(inbound);
352
+ } catch (error) {
353
+ this.logger.error("Unhandled inbound processing error", error);
354
+ }
355
+ }
356
+ async waitUntilReady(timeoutMs = 6e4) {
357
+ await new Promise((resolve, reject) => {
358
+ const currentState = this.client.getSyncState();
359
+ if (currentState === "PREPARED" || currentState === "SYNCING") {
360
+ resolve();
361
+ return;
362
+ }
363
+ const timer = setTimeout(() => {
364
+ cleanup();
365
+ reject(new Error("Matrix sync timeout."));
366
+ }, timeoutMs);
367
+ const onSync = (state) => {
368
+ if (state === "PREPARED" || state === "SYNCING") {
369
+ cleanup();
370
+ resolve();
371
+ } else if (state === "ERROR") {
372
+ cleanup();
373
+ reject(new Error("Matrix sync error."));
374
+ }
375
+ };
376
+ const cleanup = () => {
377
+ clearTimeout(timer);
378
+ this.client.removeListener(import_matrix_js_sdk.ClientEvent.Sync, onSync);
379
+ };
380
+ this.client.on(import_matrix_js_sdk.ClientEvent.Sync, onSync);
381
+ });
382
+ }
383
+ async joinInvitedRoom(roomId) {
384
+ try {
385
+ this.logger.info("Received room invite, joining", { roomId });
386
+ await this.client.joinRoom(roomId);
387
+ this.logger.info("Joined room", { roomId });
388
+ } catch (error) {
389
+ this.logger.error("Failed to join invited room", { roomId, error });
390
+ }
391
+ }
392
+ async joinPendingInvites() {
393
+ const rooms = this.client.getRooms();
394
+ for (const room of rooms) {
395
+ if (room.getMyMembership() !== "invite") {
396
+ continue;
397
+ }
398
+ await this.joinInvitedRoom(room.roomId);
399
+ }
400
+ }
401
+ async hydrateAttachments(attachments, eventId) {
402
+ if (!this.fetchMedia || attachments.length === 0) {
403
+ return attachments;
404
+ }
405
+ const hydrated = await Promise.all(
406
+ attachments.map(async (attachment, index) => {
407
+ if (attachment.kind !== "image" || !attachment.mxcUrl) {
408
+ return attachment;
409
+ }
410
+ try {
411
+ const localPath = await this.downloadMxcAttachment(
412
+ attachment.mxcUrl,
413
+ attachment.name,
414
+ attachment.mimeType,
415
+ eventId,
416
+ index
417
+ );
418
+ return {
419
+ ...attachment,
420
+ localPath
421
+ };
422
+ } catch (error) {
423
+ this.logger.warn("Failed to hydrate attachment", {
424
+ eventId,
425
+ mxcUrl: attachment.mxcUrl,
426
+ error
427
+ });
428
+ return attachment;
429
+ }
430
+ })
431
+ );
432
+ return hydrated;
433
+ }
434
+ async downloadMxcAttachment(mxcUrl, fileName, mimeType, eventId, index) {
435
+ const parsed = parseMxcUrl(mxcUrl);
436
+ if (!parsed) {
437
+ throw new Error(`Unsupported MXC URL: ${mxcUrl}`);
438
+ }
439
+ const mediaUrls = [
440
+ `${this.config.matrixHomeserver}/_matrix/media/v3/download/${encodeURIComponent(parsed.serverName)}/${encodeURIComponent(parsed.mediaId)}`,
441
+ `${this.config.matrixHomeserver}/_matrix/media/r0/download/${encodeURIComponent(parsed.serverName)}/${encodeURIComponent(parsed.mediaId)}`
442
+ ];
443
+ const headers = {
444
+ Authorization: `Bearer ${this.config.matrixAccessToken}`
445
+ };
446
+ let response = null;
447
+ for (const url of mediaUrls) {
448
+ const candidate = await fetch(url, { headers });
449
+ if (candidate.ok) {
450
+ response = candidate;
451
+ break;
452
+ }
453
+ }
454
+ if (!response) {
455
+ throw new Error(`Failed to download media for ${mxcUrl}`);
456
+ }
457
+ const bytes = Buffer.from(await response.arrayBuffer());
458
+ const extension = resolveFileExtension(fileName, mimeType);
459
+ const directory = import_node_path.default.join(import_node_os.default.tmpdir(), "codeharbor-media");
460
+ await import_promises.default.mkdir(directory, { recursive: true });
461
+ const safeEventId = sanitizeFilename(eventId);
462
+ const targetPath = import_node_path.default.join(directory, `${safeEventId}-${index}${extension}`);
463
+ await import_promises.default.writeFile(targetPath, bytes);
464
+ return targetPath;
465
+ }
466
+ };
467
+ function buildRequestId(eventId) {
468
+ const suffix = Math.random().toString(36).slice(2, 8);
469
+ return `${eventId}:${suffix}`;
470
+ }
471
+ function isDirectRoom(room) {
472
+ return room.getJoinedMemberCount() <= 2;
473
+ }
474
+ function checkMentionsBot(content, body, botUserId) {
475
+ const mentions = content["m.mentions"];
476
+ if (mentions && typeof mentions === "object") {
477
+ const userIds = mentions.user_ids;
478
+ if (Array.isArray(userIds) && userIds.some((userId) => userId === botUserId)) {
479
+ return true;
480
+ }
481
+ }
482
+ return body.includes(botUserId);
483
+ }
484
+ function checkRepliesToBot(content, room, botUserId) {
485
+ const relatesTo = content["m.relates_to"];
486
+ if (!relatesTo || typeof relatesTo !== "object") {
487
+ return false;
488
+ }
489
+ const inReplyTo = relatesTo["m.in_reply_to"];
490
+ if (!inReplyTo || typeof inReplyTo !== "object") {
491
+ return false;
492
+ }
493
+ const eventId = inReplyTo.event_id;
494
+ if (typeof eventId !== "string" || !eventId) {
495
+ return false;
496
+ }
497
+ const repliedEvent = room.findEventById(eventId);
498
+ return repliedEvent?.getSender() === botUserId;
499
+ }
500
+ function extractAttachments(content) {
501
+ const msgtype = typeof content.msgtype === "string" ? content.msgtype : "";
502
+ const mapping = {
503
+ "m.image": "image",
504
+ "m.file": "file",
505
+ "m.audio": "audio",
506
+ "m.video": "video"
507
+ };
508
+ const kind = mapping[msgtype];
509
+ if (!kind) {
510
+ return [];
511
+ }
512
+ const body = typeof content.body === "string" && content.body.trim() ? content.body.trim() : "attachment";
513
+ const info = content.info && typeof content.info === "object" ? content.info : {};
514
+ const mimeType = typeof info.mimetype === "string" ? info.mimetype : null;
515
+ const sizeBytes = typeof info.size === "number" ? info.size : null;
516
+ const directUrl = typeof content.url === "string" ? content.url : null;
517
+ const encryptedFile = content.file && typeof content.file === "object" ? content.file : {};
518
+ const encryptedUrl = typeof encryptedFile.url === "string" ? encryptedFile.url : null;
519
+ return [
520
+ {
521
+ kind,
522
+ name: body,
523
+ mxcUrl: directUrl ?? encryptedUrl,
524
+ mimeType,
525
+ sizeBytes,
526
+ localPath: null
527
+ }
528
+ ];
529
+ }
530
+ function parseMxcUrl(mxcUrl) {
531
+ if (!mxcUrl.startsWith("mxc://")) {
532
+ return null;
533
+ }
534
+ const stripped = mxcUrl.slice("mxc://".length);
535
+ const slashIndex = stripped.indexOf("/");
536
+ if (slashIndex <= 0 || slashIndex === stripped.length - 1) {
537
+ return null;
538
+ }
539
+ const serverName = stripped.slice(0, slashIndex);
540
+ const mediaId = stripped.slice(slashIndex + 1);
541
+ return { serverName, mediaId };
542
+ }
543
+ function sanitizeFilename(value) {
544
+ return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80);
545
+ }
546
+ function resolveFileExtension(fileName, mimeType) {
547
+ const ext = import_node_path.default.extname(fileName).trim();
548
+ if (ext) {
549
+ return ext;
550
+ }
551
+ if (mimeType === "image/png") {
552
+ return ".png";
553
+ }
554
+ if (mimeType === "image/jpeg") {
555
+ return ".jpg";
556
+ }
557
+ if (mimeType === "image/webp") {
558
+ return ".webp";
559
+ }
560
+ return ".bin";
561
+ }
562
+
563
+ // src/executor/codex-executor.ts
564
+ var import_node_child_process = require("child_process");
565
+ var import_node_readline = __toESM(require("readline"));
566
+ var CodexExecutionCancelledError = class extends Error {
567
+ constructor(message = "codex execution cancelled") {
568
+ super(message);
569
+ this.name = "CodexExecutionCancelledError";
570
+ }
571
+ };
572
+ var CodexExecutor = class {
573
+ options;
574
+ constructor(options) {
575
+ this.options = options;
576
+ }
577
+ async execute(prompt, sessionId, onProgress, startOptions) {
578
+ return this.startExecution(prompt, sessionId, onProgress, startOptions).result;
579
+ }
580
+ startExecution(prompt, sessionId, onProgress, startOptions) {
581
+ const args = buildCodexArgs(prompt, sessionId, this.options, startOptions);
582
+ const child = (0, import_node_child_process.spawn)(this.options.bin, args, {
583
+ cwd: this.options.workdir,
584
+ env: {
585
+ ...process.env,
586
+ ...this.options.extraEnv
587
+ },
588
+ stdio: ["ignore", "pipe", "pipe"]
589
+ });
590
+ let stderr = "";
591
+ let resolvedThreadId = sessionId;
592
+ let latestMessage = "";
593
+ let timedOut = false;
594
+ let cancelled = false;
595
+ let killTimer = null;
596
+ let completed = false;
597
+ const passThroughRawEvents = startOptions?.passThroughRawEvents ?? false;
598
+ const lineReader = import_node_readline.default.createInterface({ input: child.stdout });
599
+ lineReader.on("line", (line) => {
600
+ const event = parseCodexJsonLine(line);
601
+ if (!event) {
602
+ return;
603
+ }
604
+ if (passThroughRawEvents) {
605
+ onProgress?.({
606
+ stage: "raw_event",
607
+ eventType: typeof event.type === "string" ? event.type : "unknown",
608
+ raw: event,
609
+ message: summarizeRawEvent(event)
610
+ });
611
+ }
612
+ if (event.type === "thread.started" && event.thread_id) {
613
+ resolvedThreadId = event.thread_id;
614
+ onProgress?.({ stage: "thread_started", message: event.thread_id, eventType: event.type, raw: event });
615
+ }
616
+ if (event.type === "turn.started") {
617
+ onProgress?.({ stage: "turn_started", eventType: event.type, raw: event });
618
+ }
619
+ if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.text) {
620
+ latestMessage = event.item.text.trim();
621
+ }
622
+ if (event.type === "item.completed" && event.item?.type === "reasoning" && event.item.text) {
623
+ onProgress?.({
624
+ stage: "reasoning",
625
+ message: event.item.text.trim(),
626
+ eventType: event.type,
627
+ raw: event
628
+ });
629
+ }
630
+ if (event.type === "item.completed" && event.item?.type && event.item?.type !== "agent_message" && event.item?.type !== "reasoning") {
631
+ onProgress?.({
632
+ stage: "item_completed",
633
+ message: event.item.type,
634
+ eventType: event.type,
635
+ raw: event
636
+ });
637
+ }
638
+ if (event.type === "turn.completed") {
639
+ onProgress?.({ stage: "turn_completed", eventType: event.type, raw: event });
640
+ }
641
+ });
642
+ child.stderr.on("data", (chunk) => {
643
+ const chunkText = chunk.toString("utf8");
644
+ stderr += chunkText;
645
+ const normalized = chunkText.replace(/\s+/g, " ").trim();
646
+ if (normalized) {
647
+ onProgress?.({
648
+ stage: "stderr",
649
+ message: normalized
650
+ });
651
+ }
652
+ });
653
+ const terminateProcess = (mode) => {
654
+ if (completed) {
655
+ return;
656
+ }
657
+ if (mode === "timeout") {
658
+ timedOut = true;
659
+ } else {
660
+ cancelled = true;
661
+ }
662
+ child.kill("SIGTERM");
663
+ if (!killTimer) {
664
+ killTimer = setTimeout(() => {
665
+ child.kill("SIGKILL");
666
+ }, 5e3);
667
+ killTimer.unref?.();
668
+ }
669
+ };
670
+ const timeoutTimer = this.options.timeoutMs > 0 ? setTimeout(() => {
671
+ terminateProcess("timeout");
672
+ }, this.options.timeoutMs) : null;
673
+ timeoutTimer?.unref?.();
674
+ const result = (async () => {
675
+ const exitCode = await new Promise((resolve, reject) => {
676
+ child.on("error", reject);
677
+ child.on("close", (code) => resolve(code ?? 1));
678
+ });
679
+ if (timedOut) {
680
+ throw new Error(`codex execution timed out after ${this.options.timeoutMs}ms`);
681
+ }
682
+ if (cancelled) {
683
+ throw new CodexExecutionCancelledError();
684
+ }
685
+ if (exitCode !== 0) {
686
+ throw new Error(`codex exited with code ${exitCode}: ${stderr.trim() || "<no stderr output>"}`);
687
+ }
688
+ if (!resolvedThreadId) {
689
+ throw new Error("codex did not return thread_id.");
690
+ }
691
+ if (!latestMessage) {
692
+ throw new Error("codex did not return a final assistant message.");
693
+ }
694
+ return {
695
+ sessionId: resolvedThreadId,
696
+ reply: latestMessage
697
+ };
698
+ })().finally(() => {
699
+ completed = true;
700
+ if (timeoutTimer) {
701
+ clearTimeout(timeoutTimer);
702
+ }
703
+ if (killTimer) {
704
+ clearTimeout(killTimer);
705
+ }
706
+ lineReader.close();
707
+ });
708
+ return {
709
+ result,
710
+ cancel: () => {
711
+ terminateProcess("cancel");
712
+ }
713
+ };
714
+ }
715
+ };
716
+ function parseCodexJsonLine(line) {
717
+ const trimmed = line.trim();
718
+ if (!trimmed) {
719
+ return null;
720
+ }
721
+ try {
722
+ return JSON.parse(trimmed);
723
+ } catch {
724
+ return null;
725
+ }
726
+ }
727
+ function buildCodexArgs(prompt, sessionId, options, startOptions) {
728
+ const args = [];
729
+ if (sessionId) {
730
+ args.push("exec", "resume", "--json", "--skip-git-repo-check", sessionId, prompt);
731
+ } else {
732
+ args.push("exec", "--json", "--skip-git-repo-check", prompt);
733
+ }
734
+ if (options.model) {
735
+ args.push("--model", options.model);
736
+ }
737
+ if (options.sandboxMode) {
738
+ args.push("--sandbox", options.sandboxMode);
739
+ }
740
+ if (options.approvalPolicy) {
741
+ args.push("--ask-for-approval", options.approvalPolicy);
742
+ }
743
+ for (const imagePath of startOptions?.imagePaths ?? []) {
744
+ if (imagePath.trim()) {
745
+ args.push("--image", imagePath);
746
+ }
747
+ }
748
+ if (options.extraArgs.length > 0) {
749
+ args.push(...options.extraArgs);
750
+ }
751
+ if (options.dangerousBypass) {
752
+ args.push("--dangerously-bypass-approvals-and-sandbox");
753
+ }
754
+ return args;
755
+ }
756
+ function summarizeRawEvent(event) {
757
+ const type = typeof event.type === "string" ? event.type : "unknown";
758
+ const itemType = event.item?.type ? ` item=${event.item.type}` : "";
759
+ return `event=${type}${itemType}`;
760
+ }
761
+
762
+ // src/logger.ts
763
+ var ORDER = {
764
+ debug: 10,
765
+ info: 20,
766
+ warn: 30,
767
+ error: 40
768
+ };
769
+ var Logger = class {
770
+ level;
771
+ constructor(level) {
772
+ this.level = level;
773
+ }
774
+ debug(message, ...args) {
775
+ this.log("debug", message, ...args);
776
+ }
777
+ info(message, ...args) {
778
+ this.log("info", message, ...args);
779
+ }
780
+ warn(message, ...args) {
781
+ this.log("warn", message, ...args);
782
+ }
783
+ error(message, ...args) {
784
+ this.log("error", message, ...args);
785
+ }
786
+ log(level, message, ...args) {
787
+ if (ORDER[level] < ORDER[this.level]) {
788
+ return;
789
+ }
790
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
791
+ const payload = args.length > 0 ? ` ${args.map(stringify).join(" ")}` : "";
792
+ const line = `${timestamp} ${level.toUpperCase()} ${message}${payload}`;
793
+ if (level === "error") {
794
+ process.stderr.write(`${line}
795
+ `);
796
+ return;
797
+ }
798
+ process.stdout.write(`${line}
799
+ `);
800
+ }
801
+ };
802
+ function stringify(value) {
803
+ if (value instanceof Error) {
804
+ return `${value.name}: ${value.message}`;
805
+ }
806
+ try {
807
+ return JSON.stringify(value);
808
+ } catch {
809
+ return String(value);
810
+ }
811
+ }
812
+
813
+ // src/orchestrator.ts
814
+ var import_async_mutex = require("async-mutex");
815
+ var import_promises2 = __toESM(require("fs/promises"));
816
+
817
+ // src/compat/cli-compat-recorder.ts
818
+ var import_node_fs = __toESM(require("fs"));
819
+ var import_node_path2 = __toESM(require("path"));
820
+ var CliCompatRecorder = class {
821
+ filePath;
822
+ chain = Promise.resolve();
823
+ constructor(filePath) {
824
+ this.filePath = import_node_path2.default.resolve(filePath);
825
+ import_node_fs.default.mkdirSync(import_node_path2.default.dirname(this.filePath), { recursive: true });
826
+ }
827
+ append(entry) {
828
+ const payload = `${JSON.stringify(entry)}
829
+ `;
830
+ this.chain = this.chain.then(async () => {
831
+ await import_node_fs.default.promises.appendFile(this.filePath, payload, "utf8");
832
+ });
833
+ return this.chain;
834
+ }
835
+ };
836
+
837
+ // src/executor/codex-session-runtime.ts
838
+ var CodexSessionRuntime = class {
839
+ executor;
840
+ idleTtlMs;
841
+ workers = /* @__PURE__ */ new Map();
842
+ constructor(executor, options) {
843
+ this.executor = executor;
844
+ this.idleTtlMs = options?.idleTtlMs ?? 30 * 60 * 1e3;
845
+ }
846
+ startExecution(sessionKey, prompt, persistedSessionId, onProgress, startOptions) {
847
+ this.prune(Date.now());
848
+ const worker = this.getOrCreateWorker(sessionKey, persistedSessionId);
849
+ const effectiveSessionId = worker.lastSessionId ?? persistedSessionId;
850
+ worker.lastUsedAt = Date.now();
851
+ const handle = this.executor.startExecution(prompt, effectiveSessionId, onProgress, startOptions);
852
+ worker.runningHandle = handle;
853
+ const result = handle.result.then((executionResult) => {
854
+ worker.lastSessionId = executionResult.sessionId;
855
+ worker.lastUsedAt = Date.now();
856
+ return executionResult;
857
+ }).finally(() => {
858
+ if (worker.runningHandle === handle) {
859
+ worker.runningHandle = null;
860
+ }
861
+ });
862
+ return {
863
+ result,
864
+ cancel: () => {
865
+ handle.cancel();
866
+ }
867
+ };
868
+ }
869
+ clearSession(sessionKey) {
870
+ const worker = this.workers.get(sessionKey);
871
+ if (!worker) {
872
+ return;
873
+ }
874
+ worker.lastSessionId = null;
875
+ worker.lastUsedAt = Date.now();
876
+ }
877
+ cancelRunningExecution(sessionKey) {
878
+ const worker = this.workers.get(sessionKey);
879
+ if (!worker?.runningHandle) {
880
+ return false;
881
+ }
882
+ worker.runningHandle.cancel();
883
+ worker.lastUsedAt = Date.now();
884
+ return true;
885
+ }
886
+ getRuntimeStats() {
887
+ let runningCount = 0;
888
+ for (const worker of this.workers.values()) {
889
+ if (worker.runningHandle) {
890
+ runningCount += 1;
891
+ }
892
+ }
893
+ return {
894
+ workerCount: this.workers.size,
895
+ runningCount
896
+ };
897
+ }
898
+ getOrCreateWorker(sessionKey, persistedSessionId) {
899
+ const existing = this.workers.get(sessionKey);
900
+ if (existing) {
901
+ if (persistedSessionId && !existing.lastSessionId) {
902
+ existing.lastSessionId = persistedSessionId;
903
+ }
904
+ return existing;
905
+ }
906
+ const created = {
907
+ lastUsedAt: Date.now(),
908
+ lastSessionId: persistedSessionId,
909
+ runningHandle: null
910
+ };
911
+ this.workers.set(sessionKey, created);
912
+ return created;
913
+ }
914
+ prune(now) {
915
+ const expireBefore = now - this.idleTtlMs;
916
+ for (const [key, worker] of this.workers.entries()) {
917
+ if (worker.lastUsedAt >= expireBefore) {
918
+ continue;
919
+ }
920
+ if (worker.runningHandle) {
921
+ continue;
922
+ }
923
+ this.workers.delete(key);
924
+ }
925
+ }
926
+ };
927
+
928
+ // src/rate-limiter.ts
929
+ var RateLimiter = class {
930
+ options;
931
+ userRequests = /* @__PURE__ */ new Map();
932
+ roomRequests = /* @__PURE__ */ new Map();
933
+ userConcurrent = /* @__PURE__ */ new Map();
934
+ roomConcurrent = /* @__PURE__ */ new Map();
935
+ globalConcurrent = 0;
936
+ constructor(options) {
937
+ this.options = options;
938
+ }
939
+ tryAcquire(params, now = Date.now()) {
940
+ const userTimestamps = this.pruneAndGetWindow(this.userRequests, params.userId, now);
941
+ if (this.options.maxRequestsPerUser > 0 && userTimestamps.length >= this.options.maxRequestsPerUser) {
942
+ return {
943
+ allowed: false,
944
+ reason: "user_requests_per_window",
945
+ retryAfterMs: computeRetryAfter(userTimestamps, this.options.windowMs, now)
946
+ };
947
+ }
948
+ const roomTimestamps = this.pruneAndGetWindow(this.roomRequests, params.roomId, now);
949
+ if (this.options.maxRequestsPerRoom > 0 && roomTimestamps.length >= this.options.maxRequestsPerRoom) {
950
+ return {
951
+ allowed: false,
952
+ reason: "room_requests_per_window",
953
+ retryAfterMs: computeRetryAfter(roomTimestamps, this.options.windowMs, now)
954
+ };
955
+ }
956
+ if (this.options.maxConcurrentGlobal > 0 && this.globalConcurrent >= this.options.maxConcurrentGlobal) {
957
+ return {
958
+ allowed: false,
959
+ reason: "global_concurrency"
960
+ };
961
+ }
962
+ const activeForUser = this.userConcurrent.get(params.userId) ?? 0;
963
+ if (this.options.maxConcurrentPerUser > 0 && activeForUser >= this.options.maxConcurrentPerUser) {
964
+ return {
965
+ allowed: false,
966
+ reason: "user_concurrency"
967
+ };
968
+ }
969
+ const activeForRoom = this.roomConcurrent.get(params.roomId) ?? 0;
970
+ if (this.options.maxConcurrentPerRoom > 0 && activeForRoom >= this.options.maxConcurrentPerRoom) {
971
+ return {
972
+ allowed: false,
973
+ reason: "room_concurrency"
974
+ };
975
+ }
976
+ userTimestamps.push(now);
977
+ roomTimestamps.push(now);
978
+ this.userRequests.set(params.userId, userTimestamps);
979
+ this.roomRequests.set(params.roomId, roomTimestamps);
980
+ this.globalConcurrent += 1;
981
+ this.userConcurrent.set(params.userId, activeForUser + 1);
982
+ this.roomConcurrent.set(params.roomId, activeForRoom + 1);
983
+ let released = false;
984
+ return {
985
+ allowed: true,
986
+ release: () => {
987
+ if (released) {
988
+ return;
989
+ }
990
+ released = true;
991
+ this.globalConcurrent = Math.max(0, this.globalConcurrent - 1);
992
+ this.decrementCounter(this.userConcurrent, params.userId);
993
+ this.decrementCounter(this.roomConcurrent, params.roomId);
994
+ }
995
+ };
996
+ }
997
+ snapshot() {
998
+ return {
999
+ activeGlobal: this.globalConcurrent,
1000
+ activeUsers: this.userConcurrent.size,
1001
+ activeRooms: this.roomConcurrent.size
1002
+ };
1003
+ }
1004
+ pruneAndGetWindow(container, key, now) {
1005
+ const existing = container.get(key);
1006
+ if (!existing) {
1007
+ return [];
1008
+ }
1009
+ const threshold = now - this.options.windowMs;
1010
+ const pruned = existing.filter((ts) => ts > threshold);
1011
+ if (pruned.length === 0) {
1012
+ container.delete(key);
1013
+ return [];
1014
+ }
1015
+ container.set(key, pruned);
1016
+ return pruned;
1017
+ }
1018
+ decrementCounter(container, key) {
1019
+ const current = container.get(key) ?? 0;
1020
+ if (current <= 1) {
1021
+ container.delete(key);
1022
+ return;
1023
+ }
1024
+ container.set(key, current - 1);
1025
+ }
1026
+ };
1027
+ function computeRetryAfter(timestamps, windowMs, now) {
1028
+ const oldest = timestamps[0];
1029
+ if (typeof oldest !== "number") {
1030
+ return windowMs;
1031
+ }
1032
+ return Math.max(0, oldest + windowMs - now);
1033
+ }
1034
+
1035
+ // src/orchestrator.ts
1036
+ var RequestMetrics = class {
1037
+ total = 0;
1038
+ success = 0;
1039
+ failed = 0;
1040
+ timeout = 0;
1041
+ cancelled = 0;
1042
+ rateLimited = 0;
1043
+ ignored = 0;
1044
+ duplicate = 0;
1045
+ totalQueueMs = 0;
1046
+ totalExecMs = 0;
1047
+ totalSendMs = 0;
1048
+ record(outcome, queueMs, execMs, sendMs) {
1049
+ this.total += 1;
1050
+ this.totalQueueMs += Math.max(0, queueMs);
1051
+ this.totalExecMs += Math.max(0, execMs);
1052
+ this.totalSendMs += Math.max(0, sendMs);
1053
+ if (outcome === "success") {
1054
+ this.success += 1;
1055
+ return;
1056
+ }
1057
+ if (outcome === "failed") {
1058
+ this.failed += 1;
1059
+ return;
1060
+ }
1061
+ if (outcome === "timeout") {
1062
+ this.timeout += 1;
1063
+ return;
1064
+ }
1065
+ if (outcome === "cancelled") {
1066
+ this.cancelled += 1;
1067
+ return;
1068
+ }
1069
+ if (outcome === "rate_limited") {
1070
+ this.rateLimited += 1;
1071
+ return;
1072
+ }
1073
+ if (outcome === "ignored") {
1074
+ this.ignored += 1;
1075
+ return;
1076
+ }
1077
+ this.duplicate += 1;
1078
+ }
1079
+ snapshot(activeExecutions) {
1080
+ const divisor = this.total > 0 ? this.total : 1;
1081
+ return {
1082
+ total: this.total,
1083
+ success: this.success,
1084
+ failed: this.failed,
1085
+ timeout: this.timeout,
1086
+ cancelled: this.cancelled,
1087
+ rateLimited: this.rateLimited,
1088
+ ignored: this.ignored,
1089
+ duplicate: this.duplicate,
1090
+ activeExecutions,
1091
+ avgQueueMs: Math.round(this.totalQueueMs / divisor),
1092
+ avgExecMs: Math.round(this.totalExecMs / divisor),
1093
+ avgSendMs: Math.round(this.totalSendMs / divisor)
1094
+ };
1095
+ }
1096
+ };
1097
+ var Orchestrator = class {
1098
+ channel;
1099
+ executor;
1100
+ sessionRuntime;
1101
+ stateStore;
1102
+ logger;
1103
+ sessionLocks = /* @__PURE__ */ new Map();
1104
+ runningExecutions = /* @__PURE__ */ new Map();
1105
+ lockTtlMs;
1106
+ lockPruneIntervalMs;
1107
+ progressUpdatesEnabled;
1108
+ progressMinIntervalMs;
1109
+ typingTimeoutMs;
1110
+ commandPrefix;
1111
+ matrixUserId;
1112
+ sessionActiveWindowMs;
1113
+ defaultGroupTriggerPolicy;
1114
+ roomTriggerPolicies;
1115
+ rateLimiter;
1116
+ cliCompat;
1117
+ cliCompatRecorder;
1118
+ metrics = new RequestMetrics();
1119
+ lastLockPruneAt = 0;
1120
+ constructor(channel, executor, stateStore, logger, options) {
1121
+ this.channel = channel;
1122
+ this.executor = executor;
1123
+ this.stateStore = stateStore;
1124
+ this.logger = logger;
1125
+ this.lockTtlMs = options?.lockTtlMs ?? 30 * 60 * 1e3;
1126
+ this.lockPruneIntervalMs = options?.lockPruneIntervalMs ?? 5 * 60 * 1e3;
1127
+ this.progressUpdatesEnabled = options?.progressUpdatesEnabled ?? false;
1128
+ this.cliCompat = options?.cliCompat ?? {
1129
+ enabled: false,
1130
+ passThroughEvents: false,
1131
+ preserveWhitespace: false,
1132
+ disableReplyChunkSplit: false,
1133
+ progressThrottleMs: 300,
1134
+ fetchMedia: false,
1135
+ recordPath: null
1136
+ };
1137
+ this.cliCompatRecorder = this.cliCompat.recordPath ? new CliCompatRecorder(this.cliCompat.recordPath) : null;
1138
+ const defaultProgressInterval = options?.progressMinIntervalMs ?? 2500;
1139
+ this.progressMinIntervalMs = this.cliCompat.enabled ? this.cliCompat.progressThrottleMs : defaultProgressInterval;
1140
+ this.typingTimeoutMs = options?.typingTimeoutMs ?? 1e4;
1141
+ this.commandPrefix = (options?.commandPrefix ?? "").trim();
1142
+ this.matrixUserId = options?.matrixUserId ?? "";
1143
+ const sessionActiveWindowMinutes = options?.sessionActiveWindowMinutes ?? 20;
1144
+ this.sessionActiveWindowMs = Math.max(1, sessionActiveWindowMinutes) * 6e4;
1145
+ this.defaultGroupTriggerPolicy = options?.defaultGroupTriggerPolicy ?? {
1146
+ allowMention: true,
1147
+ allowReply: true,
1148
+ allowActiveWindow: true,
1149
+ allowPrefix: true
1150
+ };
1151
+ this.roomTriggerPolicies = options?.roomTriggerPolicies ?? {};
1152
+ this.rateLimiter = new RateLimiter(
1153
+ options?.rateLimiterOptions ?? {
1154
+ windowMs: 6e4,
1155
+ maxRequestsPerUser: 20,
1156
+ maxRequestsPerRoom: 120,
1157
+ maxConcurrentGlobal: 8,
1158
+ maxConcurrentPerUser: 1,
1159
+ maxConcurrentPerRoom: 4
1160
+ }
1161
+ );
1162
+ this.sessionRuntime = new CodexSessionRuntime(this.executor);
1163
+ }
1164
+ async handleMessage(message) {
1165
+ const receivedAt = Date.now();
1166
+ const requestId = message.requestId || message.eventId;
1167
+ const sessionKey = buildSessionKey(message);
1168
+ const directCommand = parseControlCommand(message.text.trim());
1169
+ if (directCommand === "stop") {
1170
+ if (this.stateStore.hasProcessedEvent(sessionKey, message.eventId)) {
1171
+ this.metrics.record("duplicate", 0, 0, 0);
1172
+ this.logger.debug("Duplicate stop command ignored", { requestId, eventId: message.eventId, sessionKey });
1173
+ return;
1174
+ }
1175
+ await this.handleStopCommand(sessionKey, message, requestId);
1176
+ this.stateStore.markEventProcessed(sessionKey, message.eventId);
1177
+ return;
1178
+ }
1179
+ const lock = this.getLock(sessionKey);
1180
+ await lock.runExclusive(async () => {
1181
+ const queueWaitMs = Date.now() - receivedAt;
1182
+ if (this.stateStore.hasProcessedEvent(sessionKey, message.eventId)) {
1183
+ this.metrics.record("duplicate", queueWaitMs, 0, 0);
1184
+ this.logger.debug("Duplicate event ignored", { requestId, eventId: message.eventId, sessionKey, queueWaitMs });
1185
+ return;
1186
+ }
1187
+ const route = this.routeMessage(message, sessionKey);
1188
+ if (route.kind === "ignore") {
1189
+ this.metrics.record("ignored", queueWaitMs, 0, 0);
1190
+ this.logger.debug("Message ignored by routing policy", {
1191
+ requestId,
1192
+ sessionKey,
1193
+ isDirectMessage: message.isDirectMessage,
1194
+ mentionsBot: message.mentionsBot,
1195
+ repliesToBot: message.repliesToBot
1196
+ });
1197
+ return;
1198
+ }
1199
+ if (route.kind === "command") {
1200
+ await this.handleControlCommand(route.command, sessionKey, message, requestId);
1201
+ this.stateStore.markEventProcessed(sessionKey, message.eventId);
1202
+ return;
1203
+ }
1204
+ const rateDecision = this.rateLimiter.tryAcquire({
1205
+ userId: message.senderId,
1206
+ roomId: message.conversationId
1207
+ });
1208
+ if (!rateDecision.allowed) {
1209
+ this.metrics.record("rate_limited", queueWaitMs, 0, 0);
1210
+ await this.channel.sendNotice(message.conversationId, buildRateLimitNotice(rateDecision));
1211
+ this.stateStore.markEventProcessed(sessionKey, message.eventId);
1212
+ this.logger.warn("Request rejected by rate limiter", {
1213
+ requestId,
1214
+ sessionKey,
1215
+ reason: rateDecision.reason,
1216
+ retryAfterMs: rateDecision.retryAfterMs ?? null,
1217
+ queueWaitMs
1218
+ });
1219
+ return;
1220
+ }
1221
+ this.stateStore.activateSession(sessionKey, this.sessionActiveWindowMs);
1222
+ const previousCodexSessionId = this.stateStore.getCodexSessionId(sessionKey);
1223
+ const executionPrompt = this.buildExecutionPrompt(route.prompt, message);
1224
+ const imagePaths = collectImagePaths(message);
1225
+ let lastProgressAt = 0;
1226
+ let lastProgressText = "";
1227
+ let progressNoticeEventId = null;
1228
+ let progressChain = Promise.resolve();
1229
+ let executionHandle = null;
1230
+ let executionDurationMs = 0;
1231
+ let sendDurationMs = 0;
1232
+ const requestStartedAt = Date.now();
1233
+ let cancelRequested = false;
1234
+ this.runningExecutions.set(sessionKey, {
1235
+ requestId,
1236
+ startedAt: requestStartedAt,
1237
+ cancel: () => {
1238
+ cancelRequested = true;
1239
+ executionHandle?.cancel();
1240
+ }
1241
+ });
1242
+ await this.recordCliCompatPrompt({
1243
+ requestId,
1244
+ sessionKey,
1245
+ conversationId: message.conversationId,
1246
+ senderId: message.senderId,
1247
+ prompt: executionPrompt,
1248
+ imageCount: imagePaths.length
1249
+ });
1250
+ this.logger.info("Processing message", {
1251
+ requestId,
1252
+ sessionKey,
1253
+ hasCodexSession: Boolean(previousCodexSessionId),
1254
+ queueWaitMs,
1255
+ attachmentCount: message.attachments.length,
1256
+ isDirectMessage: message.isDirectMessage,
1257
+ mentionsBot: message.mentionsBot,
1258
+ repliesToBot: message.repliesToBot
1259
+ });
1260
+ const stopTyping = this.startTypingHeartbeat(message.conversationId);
1261
+ try {
1262
+ const executionStartedAt = Date.now();
1263
+ executionHandle = this.sessionRuntime.startExecution(
1264
+ sessionKey,
1265
+ executionPrompt,
1266
+ previousCodexSessionId,
1267
+ (progress) => {
1268
+ progressChain = progressChain.then(
1269
+ () => this.handleProgress(
1270
+ message.conversationId,
1271
+ message.isDirectMessage,
1272
+ progress,
1273
+ () => lastProgressAt,
1274
+ (next) => {
1275
+ lastProgressAt = next;
1276
+ },
1277
+ () => lastProgressText,
1278
+ (next) => {
1279
+ lastProgressText = next;
1280
+ },
1281
+ () => progressNoticeEventId,
1282
+ (next) => {
1283
+ progressNoticeEventId = next;
1284
+ }
1285
+ )
1286
+ ).catch((progressError) => {
1287
+ this.logger.debug("Failed to process progress callback", { progressError });
1288
+ });
1289
+ },
1290
+ {
1291
+ passThroughRawEvents: this.cliCompat.enabled && this.cliCompat.passThroughEvents,
1292
+ imagePaths
1293
+ }
1294
+ );
1295
+ const running = this.runningExecutions.get(sessionKey);
1296
+ if (running?.requestId === requestId) {
1297
+ running.startedAt = executionStartedAt;
1298
+ running.cancel = () => {
1299
+ cancelRequested = true;
1300
+ executionHandle?.cancel();
1301
+ };
1302
+ }
1303
+ if (cancelRequested) {
1304
+ executionHandle.cancel();
1305
+ }
1306
+ const result = await executionHandle.result;
1307
+ executionDurationMs = Date.now() - executionStartedAt;
1308
+ await progressChain;
1309
+ const sendStartedAt = Date.now();
1310
+ await this.channel.sendMessage(message.conversationId, result.reply);
1311
+ await this.finishProgress(
1312
+ {
1313
+ conversationId: message.conversationId,
1314
+ isDirectMessage: message.isDirectMessage,
1315
+ getProgressNoticeEventId: () => progressNoticeEventId,
1316
+ setProgressNoticeEventId: (next) => {
1317
+ progressNoticeEventId = next;
1318
+ }
1319
+ },
1320
+ `\u5904\u7406\u5B8C\u6210\uFF08\u8017\u65F6 ${formatDurationMs(Date.now() - requestStartedAt)}\uFF09`
1321
+ );
1322
+ sendDurationMs = Date.now() - sendStartedAt;
1323
+ this.stateStore.commitExecutionSuccess(sessionKey, message.eventId, result.sessionId);
1324
+ this.metrics.record("success", queueWaitMs, executionDurationMs, sendDurationMs);
1325
+ this.logger.info("Request completed", {
1326
+ requestId,
1327
+ sessionKey,
1328
+ status: "success",
1329
+ queueWaitMs,
1330
+ executionDurationMs,
1331
+ sendDurationMs,
1332
+ totalDurationMs: Date.now() - receivedAt
1333
+ });
1334
+ } catch (error) {
1335
+ const status = classifyExecutionOutcome(error);
1336
+ executionDurationMs = Date.now() - requestStartedAt;
1337
+ await progressChain;
1338
+ await this.finishProgress(
1339
+ {
1340
+ conversationId: message.conversationId,
1341
+ isDirectMessage: message.isDirectMessage,
1342
+ getProgressNoticeEventId: () => progressNoticeEventId,
1343
+ setProgressNoticeEventId: (next) => {
1344
+ progressNoticeEventId = next;
1345
+ }
1346
+ },
1347
+ buildFailureProgressSummary(status, requestStartedAt, error)
1348
+ );
1349
+ if (status !== "cancelled") {
1350
+ try {
1351
+ await this.channel.sendMessage(
1352
+ message.conversationId,
1353
+ `[CodeHarbor] Failed to process request: ${formatError(error)}`
1354
+ );
1355
+ } catch (sendError) {
1356
+ this.logger.error("Failed to send error reply to Matrix", sendError);
1357
+ }
1358
+ }
1359
+ this.stateStore.commitExecutionHandled(sessionKey, message.eventId);
1360
+ this.metrics.record(status, queueWaitMs, executionDurationMs, sendDurationMs);
1361
+ this.logger.error("Request failed", {
1362
+ requestId,
1363
+ sessionKey,
1364
+ status,
1365
+ queueWaitMs,
1366
+ executionDurationMs,
1367
+ totalDurationMs: Date.now() - receivedAt,
1368
+ error: formatError(error)
1369
+ });
1370
+ } finally {
1371
+ const running = this.runningExecutions.get(sessionKey);
1372
+ if (running?.requestId === requestId) {
1373
+ this.runningExecutions.delete(sessionKey);
1374
+ }
1375
+ rateDecision.release?.();
1376
+ await stopTyping();
1377
+ await cleanupAttachmentFiles(imagePaths);
1378
+ }
1379
+ });
1380
+ }
1381
+ routeMessage(message, sessionKey) {
1382
+ const incomingRaw = message.text;
1383
+ const incomingTrimmed = incomingRaw.trim();
1384
+ if (!incomingTrimmed && message.attachments.length === 0) {
1385
+ return { kind: "ignore" };
1386
+ }
1387
+ const groupPolicy = message.isDirectMessage ? null : this.resolveGroupPolicy(message.conversationId);
1388
+ const prefixAllowed = message.isDirectMessage || Boolean(groupPolicy?.allowPrefix);
1389
+ const prefixTriggered = prefixAllowed && this.commandPrefix.length > 0;
1390
+ const prefixedText = prefixTriggered ? extractCommandText(incomingTrimmed, this.commandPrefix) : null;
1391
+ const activeSession = message.isDirectMessage || groupPolicy?.allowActiveWindow ? this.stateStore.isSessionActive(sessionKey) : false;
1392
+ const conversationalTrigger = message.isDirectMessage || Boolean(groupPolicy?.allowMention) && message.mentionsBot || Boolean(groupPolicy?.allowReply) && message.repliesToBot || activeSession;
1393
+ if (!conversationalTrigger && prefixedText === null) {
1394
+ return { kind: "ignore" };
1395
+ }
1396
+ let normalized = prefixedText ?? (this.cliCompat.preserveWhitespace ? incomingRaw : incomingTrimmed);
1397
+ if (prefixedText === null && message.mentionsBot && !this.cliCompat.enabled) {
1398
+ normalized = stripLeadingBotMention(normalized, this.matrixUserId);
1399
+ }
1400
+ const normalizedTrimmed = normalized.trim();
1401
+ if (!normalizedTrimmed && message.attachments.length === 0) {
1402
+ return { kind: "ignore" };
1403
+ }
1404
+ const command = parseControlCommand(normalizedTrimmed);
1405
+ if (command) {
1406
+ return { kind: "command", command };
1407
+ }
1408
+ if (!this.cliCompat.preserveWhitespace || prefixedText !== null) {
1409
+ normalized = normalizedTrimmed;
1410
+ }
1411
+ return { kind: "execute", prompt: normalized };
1412
+ }
1413
+ async handleControlCommand(command, sessionKey, message, requestId) {
1414
+ if (command === "stop") {
1415
+ await this.handleStopCommand(sessionKey, message, requestId);
1416
+ return;
1417
+ }
1418
+ if (command === "reset") {
1419
+ this.stateStore.clearCodexSessionId(sessionKey);
1420
+ this.stateStore.activateSession(sessionKey, this.sessionActiveWindowMs);
1421
+ this.sessionRuntime.clearSession(sessionKey);
1422
+ await this.channel.sendNotice(
1423
+ message.conversationId,
1424
+ "[CodeHarbor] \u4E0A\u4E0B\u6587\u5DF2\u91CD\u7F6E\u3002\u4F60\u53EF\u4EE5\u7EE7\u7EED\u76F4\u63A5\u53D1\u9001\u65B0\u9700\u6C42\u3002"
1425
+ );
1426
+ return;
1427
+ }
1428
+ const status = this.stateStore.getSessionStatus(sessionKey);
1429
+ const scope = message.isDirectMessage ? "\u79C1\u804A\uFF08\u514D\u524D\u7F00\uFF09" : "\u7FA4\u804A\uFF08\u6309\u623F\u95F4\u89E6\u53D1\u7B56\u7565\uFF09";
1430
+ const activeUntil = status.activeUntil ?? "\u672A\u6FC0\u6D3B";
1431
+ const metrics = this.metrics.snapshot(this.runningExecutions.size);
1432
+ const limiter = this.rateLimiter.snapshot();
1433
+ const runtime = this.sessionRuntime.getRuntimeStats();
1434
+ await this.channel.sendNotice(
1435
+ message.conversationId,
1436
+ `[CodeHarbor] \u5F53\u524D\u72B6\u6001
1437
+ - \u4F1A\u8BDD\u7C7B\u578B: ${scope}
1438
+ - \u6FC0\u6D3B\u4E2D: ${status.isActive ? "\u662F" : "\u5426"}
1439
+ - activeUntil: ${activeUntil}
1440
+ - \u5DF2\u7ED1\u5B9A Codex \u4F1A\u8BDD: ${status.hasCodexSession ? "\u662F" : "\u5426"}
1441
+ - \u8FD0\u884C\u4E2D\u4EFB\u52A1: ${metrics.activeExecutions}
1442
+ - \u6307\u6807: total=${metrics.total}, success=${metrics.success}, failed=${metrics.failed}, timeout=${metrics.timeout}, cancelled=${metrics.cancelled}, rate_limited=${metrics.rateLimited}
1443
+ - \u5E73\u5747\u8017\u65F6: queue=${metrics.avgQueueMs}ms, exec=${metrics.avgExecMs}ms, send=${metrics.avgSendMs}ms
1444
+ - \u9650\u6D41\u5E76\u53D1: global=${limiter.activeGlobal}, users=${limiter.activeUsers}, rooms=${limiter.activeRooms}
1445
+ - CLI runtime: workers=${runtime.workerCount}, running=${runtime.runningCount}, compat_mode=${this.cliCompat.enabled ? "on" : "off"}`
1446
+ );
1447
+ }
1448
+ async handleStopCommand(sessionKey, message, requestId) {
1449
+ this.stateStore.deactivateSession(sessionKey);
1450
+ this.stateStore.clearCodexSessionId(sessionKey);
1451
+ this.sessionRuntime.clearSession(sessionKey);
1452
+ const running = this.runningExecutions.get(sessionKey);
1453
+ if (running) {
1454
+ this.sessionRuntime.cancelRunningExecution(sessionKey);
1455
+ running.cancel();
1456
+ await this.channel.sendNotice(
1457
+ message.conversationId,
1458
+ "[CodeHarbor] \u5DF2\u8BF7\u6C42\u505C\u6B62\u5F53\u524D\u4EFB\u52A1\uFF0C\u5E76\u5DF2\u6E05\u7406\u4F1A\u8BDD\u4E0A\u4E0B\u6587\u3002"
1459
+ );
1460
+ this.logger.info("Stop command cancelled running execution", {
1461
+ requestId,
1462
+ sessionKey,
1463
+ targetRequestId: running.requestId,
1464
+ runningForMs: Date.now() - running.startedAt
1465
+ });
1466
+ return;
1467
+ }
1468
+ await this.channel.sendNotice(
1469
+ message.conversationId,
1470
+ "[CodeHarbor] \u4F1A\u8BDD\u5DF2\u505C\u6B62\u3002\u540E\u7EED\u5728\u7FA4\u804A\u4E2D\u8BF7\u63D0\u53CA/\u56DE\u590D\u6211\uFF0C\u6216\u5728\u79C1\u804A\u76F4\u63A5\u53D1\u9001\u6D88\u606F\u3002"
1471
+ );
1472
+ }
1473
+ startTypingHeartbeat(conversationId) {
1474
+ let stopped = false;
1475
+ const refreshIntervalMs = Math.max(1e3, Math.floor(this.typingTimeoutMs / 2));
1476
+ const sendTyping = async (isTyping) => {
1477
+ try {
1478
+ await this.channel.setTyping(conversationId, isTyping, isTyping ? this.typingTimeoutMs : 0);
1479
+ } catch (error) {
1480
+ this.logger.debug("Failed to update typing state", { conversationId, isTyping, error });
1481
+ }
1482
+ };
1483
+ void sendTyping(true);
1484
+ const timer = setInterval(() => {
1485
+ if (stopped) {
1486
+ return;
1487
+ }
1488
+ void sendTyping(true);
1489
+ }, refreshIntervalMs);
1490
+ timer.unref?.();
1491
+ return async () => {
1492
+ if (stopped) {
1493
+ return;
1494
+ }
1495
+ stopped = true;
1496
+ clearInterval(timer);
1497
+ await sendTyping(false);
1498
+ };
1499
+ }
1500
+ async handleProgress(conversationId, isDirectMessage, progress, getLastProgressAt, setLastProgressAt, getLastProgressText, setLastProgressText, getProgressNoticeEventId, setProgressNoticeEventId) {
1501
+ if (!this.progressUpdatesEnabled) {
1502
+ return;
1503
+ }
1504
+ const progressText = mapProgressText(progress, this.cliCompat.enabled);
1505
+ if (!progressText) {
1506
+ return;
1507
+ }
1508
+ const now = Date.now();
1509
+ if (now - getLastProgressAt() < this.progressMinIntervalMs && progress.stage !== "turn_started") {
1510
+ return;
1511
+ }
1512
+ if (progressText === getLastProgressText()) {
1513
+ return;
1514
+ }
1515
+ setLastProgressAt(now);
1516
+ setLastProgressText(progressText);
1517
+ await this.sendProgressUpdate(
1518
+ {
1519
+ conversationId,
1520
+ isDirectMessage,
1521
+ getProgressNoticeEventId,
1522
+ setProgressNoticeEventId
1523
+ },
1524
+ `[CodeHarbor] ${progressText}`
1525
+ );
1526
+ }
1527
+ async finishProgress(ctx, summary) {
1528
+ if (!this.progressUpdatesEnabled) {
1529
+ return;
1530
+ }
1531
+ await this.sendProgressUpdate(ctx, `[CodeHarbor] ${summary}`);
1532
+ }
1533
+ async sendProgressUpdate(ctx, text) {
1534
+ try {
1535
+ if (ctx.isDirectMessage) {
1536
+ await this.channel.sendNotice(ctx.conversationId, text);
1537
+ return;
1538
+ }
1539
+ const eventId = await this.channel.upsertProgressNotice(
1540
+ ctx.conversationId,
1541
+ text,
1542
+ ctx.getProgressNoticeEventId()
1543
+ );
1544
+ ctx.setProgressNoticeEventId(eventId);
1545
+ } catch (error) {
1546
+ this.logger.debug("Failed to send progress update", {
1547
+ conversationId: ctx.conversationId,
1548
+ text,
1549
+ error
1550
+ });
1551
+ }
1552
+ }
1553
+ resolveGroupPolicy(conversationId) {
1554
+ const override = this.roomTriggerPolicies[conversationId] ?? {};
1555
+ return {
1556
+ allowMention: override.allowMention ?? this.defaultGroupTriggerPolicy.allowMention,
1557
+ allowReply: override.allowReply ?? this.defaultGroupTriggerPolicy.allowReply,
1558
+ allowActiveWindow: override.allowActiveWindow ?? this.defaultGroupTriggerPolicy.allowActiveWindow,
1559
+ allowPrefix: override.allowPrefix ?? this.defaultGroupTriggerPolicy.allowPrefix
1560
+ };
1561
+ }
1562
+ buildExecutionPrompt(prompt, message) {
1563
+ if (message.attachments.length === 0) {
1564
+ return prompt;
1565
+ }
1566
+ const attachmentSummary = message.attachments.map((attachment) => {
1567
+ const size = attachment.sizeBytes === null ? "unknown" : `${attachment.sizeBytes}`;
1568
+ const mime = attachment.mimeType ?? "unknown";
1569
+ const source = attachment.mxcUrl ?? "none";
1570
+ const local = attachment.localPath ?? "none";
1571
+ return `- kind=${attachment.kind} name=${attachment.name} mime=${mime} size=${size} source=${source} local=${local}`;
1572
+ }).join("\n");
1573
+ const promptBody = prompt.trim() ? prompt : "(no text body)";
1574
+ return `${promptBody}
1575
+
1576
+ [attachments]
1577
+ ${attachmentSummary}
1578
+ [/attachments]`;
1579
+ }
1580
+ async recordCliCompatPrompt(entry) {
1581
+ if (!this.cliCompatRecorder) {
1582
+ return;
1583
+ }
1584
+ try {
1585
+ await this.cliCompatRecorder.append({
1586
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1587
+ requestId: entry.requestId,
1588
+ sessionKey: entry.sessionKey,
1589
+ conversationId: entry.conversationId,
1590
+ senderId: entry.senderId,
1591
+ prompt: entry.prompt,
1592
+ imageCount: entry.imageCount
1593
+ });
1594
+ } catch (error) {
1595
+ this.logger.warn("Failed to record cli compat prompt", {
1596
+ requestId: entry.requestId,
1597
+ error
1598
+ });
1599
+ }
1600
+ }
1601
+ getLock(key) {
1602
+ const now = Date.now();
1603
+ if (now - this.lastLockPruneAt >= this.lockPruneIntervalMs) {
1604
+ this.lastLockPruneAt = now;
1605
+ this.pruneSessionLocks(now);
1606
+ }
1607
+ let entry = this.sessionLocks.get(key);
1608
+ if (!entry) {
1609
+ entry = {
1610
+ mutex: new import_async_mutex.Mutex(),
1611
+ lastUsedAt: now
1612
+ };
1613
+ this.sessionLocks.set(key, entry);
1614
+ }
1615
+ entry.lastUsedAt = now;
1616
+ return entry.mutex;
1617
+ }
1618
+ pruneSessionLocks(now) {
1619
+ const expireBefore = now - this.lockTtlMs;
1620
+ for (const [sessionKey, entry] of this.sessionLocks.entries()) {
1621
+ if (entry.lastUsedAt >= expireBefore) {
1622
+ continue;
1623
+ }
1624
+ if (entry.mutex.isLocked()) {
1625
+ continue;
1626
+ }
1627
+ this.sessionLocks.delete(sessionKey);
1628
+ }
1629
+ }
1630
+ };
1631
+ function buildSessionKey(message) {
1632
+ return `${message.channel}:${message.conversationId}:${message.senderId}`;
1633
+ }
1634
+ function formatError(error) {
1635
+ if (error instanceof Error) {
1636
+ return error.message;
1637
+ }
1638
+ return String(error);
1639
+ }
1640
+ function collectImagePaths(message) {
1641
+ const seen = /* @__PURE__ */ new Set();
1642
+ for (const attachment of message.attachments) {
1643
+ if (attachment.kind !== "image" || !attachment.localPath) {
1644
+ continue;
1645
+ }
1646
+ seen.add(attachment.localPath);
1647
+ }
1648
+ return [...seen];
1649
+ }
1650
+ async function cleanupAttachmentFiles(imagePaths) {
1651
+ await Promise.all(
1652
+ imagePaths.map(async (imagePath) => {
1653
+ try {
1654
+ await import_promises2.default.unlink(imagePath);
1655
+ } catch {
1656
+ }
1657
+ })
1658
+ );
1659
+ }
1660
+ function mapProgressText(progress, cliCompatMode) {
1661
+ if (progress.stage === "turn_started") {
1662
+ return "\u5F00\u59CB\u5904\u7406\u8BF7\u6C42\uFF0C\u6B63\u5728\u601D\u8003...";
1663
+ }
1664
+ if (progress.stage === "reasoning" && progress.message) {
1665
+ const normalized = progress.message.replace(/\s+/g, " ").trim();
1666
+ if (!normalized) {
1667
+ return null;
1668
+ }
1669
+ const maxLen = 180;
1670
+ return normalized.length > maxLen ? `\u601D\u8003\u4E2D: ${normalized.slice(0, maxLen)}...` : `\u601D\u8003\u4E2D: ${normalized}`;
1671
+ }
1672
+ if (progress.stage === "item_completed" && progress.message) {
1673
+ return `\u9636\u6BB5\u5B8C\u6210: ${progress.message}`;
1674
+ }
1675
+ if (cliCompatMode && progress.stage === "stderr" && progress.message) {
1676
+ const text = progress.message.length > 220 ? `${progress.message.slice(0, 220)}...` : progress.message;
1677
+ return `stderr: ${text}`;
1678
+ }
1679
+ if (cliCompatMode && progress.stage === "raw_event") {
1680
+ if (!progress.message) {
1681
+ return null;
1682
+ }
1683
+ return `\u4E8B\u4EF6: ${progress.message}`;
1684
+ }
1685
+ return null;
1686
+ }
1687
+ function parseControlCommand(text) {
1688
+ const command = text.split(/\s+/, 1)[0].toLowerCase();
1689
+ if (command === "/status") {
1690
+ return "status";
1691
+ }
1692
+ if (command === "/stop") {
1693
+ return "stop";
1694
+ }
1695
+ if (command === "/reset") {
1696
+ return "reset";
1697
+ }
1698
+ return null;
1699
+ }
1700
+ function stripLeadingBotMention(text, matrixUserId) {
1701
+ if (!matrixUserId) {
1702
+ return text;
1703
+ }
1704
+ const escapedUserId = escapeRegex(matrixUserId);
1705
+ const mentionPattern = new RegExp(`^\\s*(?:<)?${escapedUserId}(?:>)?[\\s,:\uFF0C\uFF1A-]*`, "i");
1706
+ return text.replace(mentionPattern, "").trim();
1707
+ }
1708
+ function escapeRegex(value) {
1709
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1710
+ }
1711
+ function formatDurationMs(durationMs) {
1712
+ if (durationMs < 1e3) {
1713
+ return `${durationMs}ms`;
1714
+ }
1715
+ if (durationMs < 6e4) {
1716
+ return `${(durationMs / 1e3).toFixed(1)}s`;
1717
+ }
1718
+ const minutes = Math.floor(durationMs / 6e4);
1719
+ const seconds = (durationMs % 6e4 / 1e3).toFixed(1);
1720
+ return `${minutes}m${seconds}s`;
1721
+ }
1722
+ function buildRateLimitNotice(decision) {
1723
+ if (decision.reason === "user_requests_per_window" || decision.reason === "room_requests_per_window") {
1724
+ const retrySec = Math.max(1, Math.ceil((decision.retryAfterMs ?? 1e3) / 1e3));
1725
+ return `[CodeHarbor] \u8BF7\u6C42\u8FC7\u4E8E\u9891\u7E41\uFF0C\u8BF7\u5728 ${retrySec} \u79D2\u540E\u91CD\u8BD5\u3002`;
1726
+ }
1727
+ return "[CodeHarbor] \u5F53\u524D\u4EFB\u52A1\u5E76\u53D1\u8F83\u9AD8\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002";
1728
+ }
1729
+ function classifyExecutionOutcome(error) {
1730
+ if (error instanceof CodexExecutionCancelledError) {
1731
+ return "cancelled";
1732
+ }
1733
+ const message = formatError(error).toLowerCase();
1734
+ if (message.includes("timed out")) {
1735
+ return "timeout";
1736
+ }
1737
+ return "failed";
1738
+ }
1739
+ function buildFailureProgressSummary(status, startedAt, error) {
1740
+ const elapsed = formatDurationMs(Date.now() - startedAt);
1741
+ if (status === "cancelled") {
1742
+ return `\u5904\u7406\u5DF2\u53D6\u6D88\uFF08\u8017\u65F6 ${elapsed}\uFF09`;
1743
+ }
1744
+ if (status === "timeout") {
1745
+ return `\u5904\u7406\u8D85\u65F6\uFF08\u8017\u65F6 ${elapsed}\uFF09: ${formatError(error)}`;
1746
+ }
1747
+ return `\u5904\u7406\u5931\u8D25\uFF08\u8017\u65F6 ${elapsed}\uFF09: ${formatError(error)}`;
1748
+ }
1749
+
1750
+ // src/store/state-store.ts
1751
+ var import_node_fs2 = __toESM(require("fs"));
1752
+ var import_node_path3 = __toESM(require("path"));
1753
+ var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
1754
+ var PRUNE_INTERVAL_MS = 5 * 60 * 1e3;
1755
+ var SQLITE_MODULE_ID = `node:${"sqlite"}`;
1756
+ function loadDatabaseSync() {
1757
+ const sqliteModule = require(SQLITE_MODULE_ID);
1758
+ if (!sqliteModule.DatabaseSync) {
1759
+ throw new Error(`Failed to load ${SQLITE_MODULE_ID} DatabaseSync`);
1760
+ }
1761
+ return sqliteModule.DatabaseSync;
1762
+ }
1763
+ var DatabaseSync = loadDatabaseSync();
1764
+ var StateStore = class {
1765
+ dbPath;
1766
+ legacyJsonPath;
1767
+ maxProcessedEventsPerSession;
1768
+ maxSessionAgeMs;
1769
+ maxSessions;
1770
+ db;
1771
+ lastPruneAt = 0;
1772
+ constructor(dbPath, legacyJsonPath, maxProcessedEventsPerSession, maxSessionAgeDays, maxSessions) {
1773
+ this.dbPath = dbPath;
1774
+ this.legacyJsonPath = legacyJsonPath;
1775
+ this.maxProcessedEventsPerSession = maxProcessedEventsPerSession;
1776
+ this.maxSessionAgeMs = maxSessionAgeDays * ONE_DAY_MS;
1777
+ this.maxSessions = maxSessions;
1778
+ import_node_fs2.default.mkdirSync(import_node_path3.default.dirname(this.dbPath), { recursive: true });
1779
+ this.db = new DatabaseSync(this.dbPath);
1780
+ this.initializeSchema();
1781
+ this.importLegacyStateIfNeeded();
1782
+ if (this.pruneSessions()) {
1783
+ this.touchDatabase();
1784
+ }
1785
+ }
1786
+ getCodexSessionId(sessionKey) {
1787
+ this.maybePruneExpiredSessions();
1788
+ const row = this.db.prepare("SELECT codex_session_id FROM sessions WHERE session_key = ?1").get(sessionKey);
1789
+ return row?.codex_session_id ?? null;
1790
+ }
1791
+ setCodexSessionId(sessionKey, codexSessionId) {
1792
+ this.maybePruneExpiredSessions();
1793
+ this.ensureSession(sessionKey);
1794
+ this.db.prepare(
1795
+ "UPDATE sessions SET codex_session_id = ?2, updated_at = ?3 WHERE session_key = ?1"
1796
+ ).run(sessionKey, codexSessionId, Date.now());
1797
+ }
1798
+ clearCodexSessionId(sessionKey) {
1799
+ this.maybePruneExpiredSessions();
1800
+ this.ensureSession(sessionKey);
1801
+ this.db.prepare("UPDATE sessions SET codex_session_id = NULL, updated_at = ?2 WHERE session_key = ?1").run(sessionKey, Date.now());
1802
+ }
1803
+ isSessionActive(sessionKey, now = Date.now()) {
1804
+ this.maybePruneExpiredSessions();
1805
+ const row = this.db.prepare("SELECT active_until FROM sessions WHERE session_key = ?1").get(sessionKey);
1806
+ if (!row || row.active_until === null) {
1807
+ return false;
1808
+ }
1809
+ return now <= row.active_until;
1810
+ }
1811
+ activateSession(sessionKey, activeWindowMs) {
1812
+ this.maybePruneExpiredSessions();
1813
+ this.ensureSession(sessionKey);
1814
+ const now = Date.now();
1815
+ this.db.prepare(
1816
+ "UPDATE sessions SET active_until = ?2, updated_at = ?3 WHERE session_key = ?1"
1817
+ ).run(sessionKey, now + Math.max(0, activeWindowMs), now);
1818
+ }
1819
+ deactivateSession(sessionKey) {
1820
+ this.maybePruneExpiredSessions();
1821
+ this.ensureSession(sessionKey);
1822
+ this.db.prepare("UPDATE sessions SET active_until = NULL, updated_at = ?2 WHERE session_key = ?1").run(sessionKey, Date.now());
1823
+ }
1824
+ getSessionStatus(sessionKey) {
1825
+ this.maybePruneExpiredSessions();
1826
+ const row = this.db.prepare("SELECT codex_session_id, active_until FROM sessions WHERE session_key = ?1").get(sessionKey);
1827
+ if (!row) {
1828
+ return {
1829
+ hasCodexSession: false,
1830
+ activeUntil: null,
1831
+ isActive: false
1832
+ };
1833
+ }
1834
+ const activeUntilIso = row.active_until === null ? null : new Date(row.active_until).toISOString();
1835
+ return {
1836
+ hasCodexSession: Boolean(row.codex_session_id),
1837
+ activeUntil: activeUntilIso,
1838
+ isActive: row.active_until !== null ? Date.now() <= row.active_until : false
1839
+ };
1840
+ }
1841
+ hasProcessedEvent(sessionKey, eventId) {
1842
+ this.maybePruneExpiredSessions();
1843
+ const row = this.db.prepare("SELECT 1 FROM processed_events WHERE session_key = ?1 AND event_id = ?2").get(sessionKey, eventId);
1844
+ return Boolean(row);
1845
+ }
1846
+ markEventProcessed(sessionKey, eventId) {
1847
+ this.maybePruneExpiredSessions();
1848
+ this.ensureSession(sessionKey);
1849
+ this.insertProcessedEventAndTrim(sessionKey, eventId, Date.now());
1850
+ }
1851
+ commitExecutionSuccess(sessionKey, eventId, codexSessionId) {
1852
+ this.maybePruneExpiredSessions();
1853
+ const now = Date.now();
1854
+ this.ensureSession(sessionKey);
1855
+ this.db.exec("BEGIN");
1856
+ try {
1857
+ this.db.prepare("UPDATE sessions SET codex_session_id = ?2, updated_at = ?3 WHERE session_key = ?1").run(sessionKey, codexSessionId, now);
1858
+ this.insertProcessedEventAndTrim(sessionKey, eventId, now);
1859
+ this.db.exec("COMMIT");
1860
+ } catch (error) {
1861
+ this.db.exec("ROLLBACK");
1862
+ throw error;
1863
+ }
1864
+ }
1865
+ commitExecutionHandled(sessionKey, eventId) {
1866
+ this.maybePruneExpiredSessions();
1867
+ this.ensureSession(sessionKey);
1868
+ this.db.exec("BEGIN");
1869
+ try {
1870
+ this.insertProcessedEventAndTrim(sessionKey, eventId, Date.now());
1871
+ this.db.exec("COMMIT");
1872
+ } catch (error) {
1873
+ this.db.exec("ROLLBACK");
1874
+ throw error;
1875
+ }
1876
+ }
1877
+ async flush() {
1878
+ this.touchDatabase();
1879
+ }
1880
+ insertProcessedEventAndTrim(sessionKey, eventId, now) {
1881
+ this.db.prepare("INSERT OR IGNORE INTO processed_events (session_key, event_id, created_at) VALUES (?1, ?2, ?3)").run(sessionKey, eventId, now);
1882
+ if (this.maxProcessedEventsPerSession > 0) {
1883
+ this.db.prepare(
1884
+ `DELETE FROM processed_events
1885
+ WHERE rowid IN (
1886
+ SELECT rowid
1887
+ FROM processed_events
1888
+ WHERE session_key = ?1
1889
+ ORDER BY rowid DESC
1890
+ LIMIT -1 OFFSET ?2
1891
+ )`
1892
+ ).run(sessionKey, this.maxProcessedEventsPerSession);
1893
+ }
1894
+ this.db.prepare("UPDATE sessions SET updated_at = ?2 WHERE session_key = ?1").run(sessionKey, now);
1895
+ }
1896
+ ensureSession(sessionKey) {
1897
+ this.db.prepare(
1898
+ "INSERT INTO sessions (session_key, codex_session_id, active_until, updated_at) VALUES (?1, NULL, NULL, ?2) ON CONFLICT(session_key) DO NOTHING"
1899
+ ).run(sessionKey, Date.now());
1900
+ }
1901
+ initializeSchema() {
1902
+ this.db.exec("PRAGMA journal_mode = WAL");
1903
+ this.db.exec("PRAGMA foreign_keys = ON");
1904
+ this.db.exec(`
1905
+ CREATE TABLE IF NOT EXISTS sessions (
1906
+ session_key TEXT PRIMARY KEY,
1907
+ codex_session_id TEXT,
1908
+ active_until INTEGER,
1909
+ updated_at INTEGER NOT NULL
1910
+ );
1911
+
1912
+ CREATE TABLE IF NOT EXISTS processed_events (
1913
+ session_key TEXT NOT NULL,
1914
+ event_id TEXT NOT NULL,
1915
+ created_at INTEGER NOT NULL,
1916
+ PRIMARY KEY (session_key, event_id),
1917
+ FOREIGN KEY (session_key) REFERENCES sessions(session_key) ON DELETE CASCADE
1918
+ );
1919
+
1920
+ CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at);
1921
+ CREATE INDEX IF NOT EXISTS idx_events_created_at ON processed_events(created_at);
1922
+ `);
1923
+ }
1924
+ importLegacyStateIfNeeded() {
1925
+ if (!this.legacyJsonPath || !import_node_fs2.default.existsSync(this.legacyJsonPath)) {
1926
+ return;
1927
+ }
1928
+ const countRow = this.db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
1929
+ if ((countRow?.count ?? 0) > 0) {
1930
+ return;
1931
+ }
1932
+ const legacy = loadLegacyState(this.legacyJsonPath);
1933
+ if (!legacy) {
1934
+ return;
1935
+ }
1936
+ const insertSession = this.db.prepare(
1937
+ "INSERT OR REPLACE INTO sessions (session_key, codex_session_id, active_until, updated_at) VALUES (?1, ?2, ?3, ?4)"
1938
+ );
1939
+ const insertEvent = this.db.prepare(
1940
+ "INSERT OR IGNORE INTO processed_events (session_key, event_id, created_at) VALUES (?1, ?2, ?3)"
1941
+ );
1942
+ this.db.exec("BEGIN");
1943
+ try {
1944
+ for (const [sessionKey, session] of Object.entries(legacy.sessions)) {
1945
+ const updatedAt = parseUpdatedAt(session.updatedAt);
1946
+ const activeUntil = parseOptionalTimestamp(session.activeUntil);
1947
+ insertSession.run(sessionKey, session.codexSessionId, activeUntil, updatedAt);
1948
+ let eventTs = updatedAt;
1949
+ for (const eventId of session.processedEventIds) {
1950
+ eventTs += 1;
1951
+ insertEvent.run(sessionKey, eventId, eventTs);
1952
+ }
1953
+ }
1954
+ this.db.exec("COMMIT");
1955
+ } catch (error) {
1956
+ this.db.exec("ROLLBACK");
1957
+ throw error;
1958
+ }
1959
+ }
1960
+ maybePruneExpiredSessions() {
1961
+ const now = Date.now();
1962
+ if (now - this.lastPruneAt < PRUNE_INTERVAL_MS) {
1963
+ return;
1964
+ }
1965
+ this.lastPruneAt = now;
1966
+ if (this.pruneSessions(now)) {
1967
+ this.touchDatabase();
1968
+ }
1969
+ }
1970
+ pruneSessions(now = Date.now()) {
1971
+ let changed = false;
1972
+ if (this.pruneExpiredSessions(now)) {
1973
+ changed = true;
1974
+ }
1975
+ if (this.pruneExcessSessions()) {
1976
+ changed = true;
1977
+ }
1978
+ return changed;
1979
+ }
1980
+ pruneExpiredSessions(now) {
1981
+ if (this.maxSessionAgeMs <= 0) {
1982
+ return false;
1983
+ }
1984
+ const result = this.db.prepare("DELETE FROM sessions WHERE updated_at < ?1").run(now - this.maxSessionAgeMs);
1985
+ return (result.changes ?? 0) > 0;
1986
+ }
1987
+ pruneExcessSessions() {
1988
+ if (this.maxSessions <= 0) {
1989
+ return false;
1990
+ }
1991
+ const row = this.db.prepare("SELECT COUNT(*) AS count FROM sessions").get();
1992
+ const count = row?.count ?? 0;
1993
+ if (count <= this.maxSessions) {
1994
+ return false;
1995
+ }
1996
+ const removeCount = count - this.maxSessions;
1997
+ const result = this.db.prepare(
1998
+ "DELETE FROM sessions WHERE session_key IN (SELECT session_key FROM sessions ORDER BY updated_at ASC LIMIT ?1)"
1999
+ ).run(removeCount);
2000
+ return (result.changes ?? 0) > 0;
2001
+ }
2002
+ touchDatabase() {
2003
+ this.db.exec("PRAGMA wal_checkpoint(PASSIVE)");
2004
+ }
2005
+ };
2006
+ function loadLegacyState(filePath) {
2007
+ try {
2008
+ const raw = import_node_fs2.default.readFileSync(filePath, "utf8");
2009
+ const parsed = JSON.parse(raw);
2010
+ if (!parsed.sessions || typeof parsed.sessions !== "object") {
2011
+ return null;
2012
+ }
2013
+ normalizeLegacyState(parsed);
2014
+ return parsed;
2015
+ } catch {
2016
+ return null;
2017
+ }
2018
+ }
2019
+ function parseUpdatedAt(updatedAt) {
2020
+ const timestamp = Date.parse(updatedAt);
2021
+ return Number.isFinite(timestamp) ? timestamp : Date.now();
2022
+ }
2023
+ function parseOptionalTimestamp(value) {
2024
+ if (!value) {
2025
+ return null;
2026
+ }
2027
+ const ts = Date.parse(value);
2028
+ return Number.isFinite(ts) ? ts : null;
2029
+ }
2030
+ function normalizeLegacyState(state) {
2031
+ for (const session of Object.values(state.sessions)) {
2032
+ if (!Array.isArray(session.processedEventIds)) {
2033
+ session.processedEventIds = [];
2034
+ }
2035
+ if (typeof session.codexSessionId !== "string" && session.codexSessionId !== null) {
2036
+ session.codexSessionId = null;
2037
+ }
2038
+ if (typeof session.updatedAt !== "string") {
2039
+ session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2040
+ }
2041
+ if (typeof session.activeUntil !== "string" && session.activeUntil !== null) {
2042
+ session.activeUntil = null;
2043
+ }
2044
+ }
2045
+ }
2046
+
2047
+ // src/app.ts
2048
+ var execFileAsync = (0, import_node_util.promisify)(import_node_child_process2.execFile);
2049
+ var CodeHarborApp = class {
2050
+ config;
2051
+ logger;
2052
+ stateStore;
2053
+ channel;
2054
+ orchestrator;
2055
+ constructor(config) {
2056
+ this.config = config;
2057
+ this.logger = new Logger(config.logLevel);
2058
+ this.stateStore = new StateStore(
2059
+ config.stateDbPath,
2060
+ config.legacyStateJsonPath,
2061
+ config.maxProcessedEventsPerSession,
2062
+ config.maxSessionAgeDays,
2063
+ config.maxSessions
2064
+ );
2065
+ const executor = new CodexExecutor({
2066
+ bin: config.codexBin,
2067
+ model: config.codexModel,
2068
+ workdir: config.codexWorkdir,
2069
+ dangerousBypass: config.codexDangerousBypass,
2070
+ timeoutMs: config.codexExecTimeoutMs,
2071
+ sandboxMode: config.codexSandboxMode,
2072
+ approvalPolicy: config.codexApprovalPolicy,
2073
+ extraArgs: config.codexExtraArgs,
2074
+ extraEnv: config.codexExtraEnv
2075
+ });
2076
+ this.channel = new MatrixChannel(config, this.logger);
2077
+ this.orchestrator = new Orchestrator(this.channel, executor, this.stateStore, this.logger, {
2078
+ progressUpdatesEnabled: config.matrixProgressUpdates,
2079
+ progressMinIntervalMs: config.matrixProgressMinIntervalMs,
2080
+ typingTimeoutMs: config.matrixTypingTimeoutMs,
2081
+ commandPrefix: config.matrixCommandPrefix,
2082
+ matrixUserId: config.matrixUserId,
2083
+ sessionActiveWindowMinutes: config.sessionActiveWindowMinutes,
2084
+ defaultGroupTriggerPolicy: config.defaultGroupTriggerPolicy,
2085
+ roomTriggerPolicies: config.roomTriggerPolicies,
2086
+ rateLimiterOptions: config.rateLimiter,
2087
+ cliCompat: config.cliCompat
2088
+ });
2089
+ }
2090
+ async start() {
2091
+ this.logger.info("CodeHarbor starting", {
2092
+ matrixHomeserver: this.config.matrixHomeserver,
2093
+ workdir: this.config.codexWorkdir,
2094
+ prefix: this.config.matrixCommandPrefix || "<none>"
2095
+ });
2096
+ await this.channel.start(this.orchestrator.handleMessage.bind(this.orchestrator));
2097
+ this.logger.info("CodeHarbor is running.");
2098
+ }
2099
+ async stop() {
2100
+ this.logger.info("CodeHarbor stopping.");
2101
+ try {
2102
+ await this.channel.stop();
2103
+ } finally {
2104
+ await this.stateStore.flush();
2105
+ }
2106
+ }
2107
+ };
2108
+ async function runDoctor(config) {
2109
+ const logger = new Logger(config.logLevel);
2110
+ logger.info("Doctor check started");
2111
+ try {
2112
+ const { stdout } = await execFileAsync(config.codexBin, ["--version"]);
2113
+ logger.info("codex available", { version: stdout.trim() });
2114
+ } catch (error) {
2115
+ logger.error("codex unavailable", error);
2116
+ throw error;
2117
+ }
2118
+ try {
2119
+ const controller = new AbortController();
2120
+ const timer = setTimeout(() => controller.abort(), config.doctorHttpTimeoutMs);
2121
+ timer.unref?.();
2122
+ const response = await fetch(`${config.matrixHomeserver}/_matrix/client/versions`, {
2123
+ signal: controller.signal
2124
+ }).finally(() => {
2125
+ clearTimeout(timer);
2126
+ });
2127
+ if (!response.ok) {
2128
+ throw new Error(`HTTP ${response.status}`);
2129
+ }
2130
+ const body = await response.json();
2131
+ logger.info("matrix reachable", { versions: body.versions ?? [] });
2132
+ } catch (error) {
2133
+ logger.error("matrix unreachable", error);
2134
+ throw error;
2135
+ }
2136
+ logger.info("Doctor check passed");
2137
+ }
2138
+
2139
+ // src/config.ts
2140
+ var import_node_fs3 = __toESM(require("fs"));
2141
+ var import_node_path4 = __toESM(require("path"));
2142
+ var import_dotenv = __toESM(require("dotenv"));
2143
+ var import_zod = require("zod");
2144
+ import_dotenv.default.config();
2145
+ var configSchema = import_zod.z.object({
2146
+ MATRIX_HOMESERVER: import_zod.z.string().url(),
2147
+ MATRIX_USER_ID: import_zod.z.string().min(1),
2148
+ MATRIX_ACCESS_TOKEN: import_zod.z.string().min(1),
2149
+ MATRIX_COMMAND_PREFIX: import_zod.z.string().default("!code"),
2150
+ CODEX_BIN: import_zod.z.string().default("codex"),
2151
+ CODEX_MODEL: import_zod.z.string().optional(),
2152
+ CODEX_WORKDIR: import_zod.z.string().default(process.cwd()),
2153
+ CODEX_DANGEROUS_BYPASS: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
2154
+ CODEX_EXEC_TIMEOUT_MS: import_zod.z.string().default("600000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
2155
+ CODEX_SANDBOX_MODE: import_zod.z.string().optional(),
2156
+ CODEX_APPROVAL_POLICY: import_zod.z.string().optional(),
2157
+ CODEX_EXTRA_ARGS: import_zod.z.string().default(""),
2158
+ CODEX_EXTRA_ENV_JSON: import_zod.z.string().default(""),
2159
+ STATE_DB_PATH: import_zod.z.string().default("data/state.db"),
2160
+ STATE_PATH: import_zod.z.string().default("data/state.json"),
2161
+ MAX_PROCESSED_EVENTS_PER_SESSION: import_zod.z.string().default("200").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
2162
+ MAX_SESSION_AGE_DAYS: import_zod.z.string().default("30").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
2163
+ MAX_SESSIONS: import_zod.z.string().default("5000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
2164
+ REPLY_CHUNK_SIZE: import_zod.z.string().default("3500").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
2165
+ MATRIX_PROGRESS_UPDATES: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
2166
+ MATRIX_PROGRESS_MIN_INTERVAL_MS: import_zod.z.string().default("2500").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
2167
+ MATRIX_TYPING_TIMEOUT_MS: import_zod.z.string().default("10000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
2168
+ SESSION_ACTIVE_WINDOW_MINUTES: import_zod.z.string().default("20").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
2169
+ GROUP_TRIGGER_ALLOW_MENTION: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
2170
+ GROUP_TRIGGER_ALLOW_REPLY: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
2171
+ GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
2172
+ GROUP_TRIGGER_ALLOW_PREFIX: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
2173
+ ROOM_TRIGGER_POLICY_JSON: import_zod.z.string().default(""),
2174
+ RATE_LIMIT_WINDOW_SECONDS: import_zod.z.string().default("60").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
2175
+ RATE_LIMIT_MAX_REQUESTS_PER_USER: import_zod.z.string().default("20").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
2176
+ RATE_LIMIT_MAX_REQUESTS_PER_ROOM: import_zod.z.string().default("120").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
2177
+ RATE_LIMIT_MAX_CONCURRENT_GLOBAL: import_zod.z.string().default("8").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
2178
+ RATE_LIMIT_MAX_CONCURRENT_PER_USER: import_zod.z.string().default("1").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
2179
+ RATE_LIMIT_MAX_CONCURRENT_PER_ROOM: import_zod.z.string().default("4").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
2180
+ CLI_COMPAT_MODE: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
2181
+ CLI_COMPAT_PASSTHROUGH_EVENTS: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
2182
+ CLI_COMPAT_PRESERVE_WHITESPACE: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
2183
+ CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT: import_zod.z.string().default("false").transform((v) => v.toLowerCase() === "true"),
2184
+ CLI_COMPAT_PROGRESS_THROTTLE_MS: import_zod.z.string().default("300").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().nonnegative()),
2185
+ CLI_COMPAT_FETCH_MEDIA: import_zod.z.string().default("true").transform((v) => v.toLowerCase() === "true"),
2186
+ CLI_COMPAT_RECORD_PATH: import_zod.z.string().default(""),
2187
+ DOCTOR_HTTP_TIMEOUT_MS: import_zod.z.string().default("10000").transform((v) => Number.parseInt(v, 10)).pipe(import_zod.z.number().int().positive()),
2188
+ LOG_LEVEL: import_zod.z.enum(["debug", "info", "warn", "error"]).default("info")
2189
+ }).transform((v) => ({
2190
+ matrixHomeserver: v.MATRIX_HOMESERVER,
2191
+ matrixUserId: v.MATRIX_USER_ID,
2192
+ matrixAccessToken: v.MATRIX_ACCESS_TOKEN,
2193
+ matrixCommandPrefix: v.MATRIX_COMMAND_PREFIX,
2194
+ codexBin: v.CODEX_BIN,
2195
+ codexModel: v.CODEX_MODEL?.trim() || null,
2196
+ codexWorkdir: import_node_path4.default.resolve(v.CODEX_WORKDIR),
2197
+ codexDangerousBypass: v.CODEX_DANGEROUS_BYPASS,
2198
+ codexExecTimeoutMs: v.CODEX_EXEC_TIMEOUT_MS,
2199
+ codexSandboxMode: v.CODEX_SANDBOX_MODE?.trim() || null,
2200
+ codexApprovalPolicy: v.CODEX_APPROVAL_POLICY?.trim() || null,
2201
+ codexExtraArgs: parseExtraArgs(v.CODEX_EXTRA_ARGS),
2202
+ codexExtraEnv: parseExtraEnv(v.CODEX_EXTRA_ENV_JSON),
2203
+ stateDbPath: import_node_path4.default.resolve(v.STATE_DB_PATH),
2204
+ legacyStateJsonPath: v.STATE_PATH.trim() ? import_node_path4.default.resolve(v.STATE_PATH) : null,
2205
+ maxProcessedEventsPerSession: v.MAX_PROCESSED_EVENTS_PER_SESSION,
2206
+ maxSessionAgeDays: v.MAX_SESSION_AGE_DAYS,
2207
+ maxSessions: v.MAX_SESSIONS,
2208
+ replyChunkSize: v.REPLY_CHUNK_SIZE,
2209
+ matrixProgressUpdates: v.MATRIX_PROGRESS_UPDATES,
2210
+ matrixProgressMinIntervalMs: v.MATRIX_PROGRESS_MIN_INTERVAL_MS,
2211
+ matrixTypingTimeoutMs: v.MATRIX_TYPING_TIMEOUT_MS,
2212
+ sessionActiveWindowMinutes: v.SESSION_ACTIVE_WINDOW_MINUTES,
2213
+ defaultGroupTriggerPolicy: {
2214
+ allowMention: v.GROUP_TRIGGER_ALLOW_MENTION,
2215
+ allowReply: v.GROUP_TRIGGER_ALLOW_REPLY,
2216
+ allowActiveWindow: v.GROUP_TRIGGER_ALLOW_ACTIVE_WINDOW,
2217
+ allowPrefix: v.GROUP_TRIGGER_ALLOW_PREFIX
2218
+ },
2219
+ roomTriggerPolicies: parseRoomTriggerPolicyOverrides(v.ROOM_TRIGGER_POLICY_JSON),
2220
+ rateLimiter: {
2221
+ windowMs: v.RATE_LIMIT_WINDOW_SECONDS * 1e3,
2222
+ maxRequestsPerUser: v.RATE_LIMIT_MAX_REQUESTS_PER_USER,
2223
+ maxRequestsPerRoom: v.RATE_LIMIT_MAX_REQUESTS_PER_ROOM,
2224
+ maxConcurrentGlobal: v.RATE_LIMIT_MAX_CONCURRENT_GLOBAL,
2225
+ maxConcurrentPerUser: v.RATE_LIMIT_MAX_CONCURRENT_PER_USER,
2226
+ maxConcurrentPerRoom: v.RATE_LIMIT_MAX_CONCURRENT_PER_ROOM
2227
+ },
2228
+ cliCompat: {
2229
+ enabled: v.CLI_COMPAT_MODE,
2230
+ passThroughEvents: v.CLI_COMPAT_PASSTHROUGH_EVENTS,
2231
+ preserveWhitespace: v.CLI_COMPAT_PRESERVE_WHITESPACE,
2232
+ disableReplyChunkSplit: v.CLI_COMPAT_DISABLE_REPLY_CHUNK_SPLIT,
2233
+ progressThrottleMs: v.CLI_COMPAT_PROGRESS_THROTTLE_MS,
2234
+ fetchMedia: v.CLI_COMPAT_FETCH_MEDIA,
2235
+ recordPath: v.CLI_COMPAT_RECORD_PATH.trim() ? import_node_path4.default.resolve(v.CLI_COMPAT_RECORD_PATH) : null
2236
+ },
2237
+ doctorHttpTimeoutMs: v.DOCTOR_HTTP_TIMEOUT_MS,
2238
+ logLevel: v.LOG_LEVEL
2239
+ }));
2240
+ function loadConfig(env = process.env) {
2241
+ const parsed = configSchema.safeParse(env);
2242
+ if (!parsed.success) {
2243
+ const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ");
2244
+ throw new Error(`Invalid configuration: ${message}`);
2245
+ }
2246
+ import_node_fs3.default.mkdirSync(import_node_path4.default.dirname(parsed.data.stateDbPath), { recursive: true });
2247
+ if (parsed.data.legacyStateJsonPath) {
2248
+ import_node_fs3.default.mkdirSync(import_node_path4.default.dirname(parsed.data.legacyStateJsonPath), { recursive: true });
2249
+ }
2250
+ return parsed.data;
2251
+ }
2252
+ function parseRoomTriggerPolicyOverrides(raw) {
2253
+ const trimmed = raw.trim();
2254
+ if (!trimmed) {
2255
+ return {};
2256
+ }
2257
+ let parsed;
2258
+ try {
2259
+ parsed = JSON.parse(trimmed);
2260
+ } catch {
2261
+ throw new Error("ROOM_TRIGGER_POLICY_JSON must be valid JSON.");
2262
+ }
2263
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2264
+ throw new Error("ROOM_TRIGGER_POLICY_JSON must be an object keyed by room id.");
2265
+ }
2266
+ const output = {};
2267
+ for (const [roomId, value] of Object.entries(parsed)) {
2268
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
2269
+ throw new Error(`ROOM_TRIGGER_POLICY_JSON[${roomId}] must be an object.`);
2270
+ }
2271
+ const item = value;
2272
+ const override = {};
2273
+ for (const key of ["allowMention", "allowReply", "allowActiveWindow", "allowPrefix"]) {
2274
+ if (item[key] === void 0) {
2275
+ continue;
2276
+ }
2277
+ if (typeof item[key] !== "boolean") {
2278
+ throw new Error(`ROOM_TRIGGER_POLICY_JSON[${roomId}].${key} must be boolean.`);
2279
+ }
2280
+ override[key] = item[key];
2281
+ }
2282
+ output[roomId] = override;
2283
+ }
2284
+ return output;
2285
+ }
2286
+ function parseExtraArgs(raw) {
2287
+ const trimmed = raw.trim();
2288
+ if (!trimmed) {
2289
+ return [];
2290
+ }
2291
+ return trimmed.split(/\s+/).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
2292
+ }
2293
+ function parseExtraEnv(raw) {
2294
+ const trimmed = raw.trim();
2295
+ if (!trimmed) {
2296
+ return {};
2297
+ }
2298
+ let parsed;
2299
+ try {
2300
+ parsed = JSON.parse(trimmed);
2301
+ } catch {
2302
+ throw new Error("CODEX_EXTRA_ENV_JSON must be valid JSON object.");
2303
+ }
2304
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2305
+ throw new Error("CODEX_EXTRA_ENV_JSON must be a key/value object.");
2306
+ }
2307
+ const output = {};
2308
+ for (const [key, value] of Object.entries(parsed)) {
2309
+ if (typeof value !== "string") {
2310
+ throw new Error(`CODEX_EXTRA_ENV_JSON[${key}] must be string.`);
2311
+ }
2312
+ output[key] = value;
2313
+ }
2314
+ return output;
2315
+ }
2316
+
2317
+ // src/cli.ts
2318
+ var program = new import_commander.Command();
2319
+ program.name("codeharbor").description("Instant-messaging bridge for Codex CLI sessions").version("0.1.0");
2320
+ program.command("start").description("Start CodeHarbor service").action(async () => {
2321
+ const config = loadConfig();
2322
+ const app = new CodeHarborApp(config);
2323
+ await app.start();
2324
+ const stop = async () => {
2325
+ await app.stop();
2326
+ process.exit(0);
2327
+ };
2328
+ process.once("SIGINT", () => {
2329
+ void stop();
2330
+ });
2331
+ process.once("SIGTERM", () => {
2332
+ void stop();
2333
+ });
2334
+ });
2335
+ program.command("doctor").description("Check codex and matrix connectivity").action(async () => {
2336
+ const config = loadConfig();
2337
+ await runDoctor(config);
2338
+ });
2339
+ if (process.argv.length <= 2) {
2340
+ process.argv.push("start");
2341
+ }
2342
+ void program.parseAsync(process.argv);