@delt/claude-alarm 1.1.1 → 1.2.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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/codex/main.ts
4
- import fs4 from "fs";
4
+ import fs6 from "fs";
5
5
 
6
6
  // src/shared/config.ts
7
7
  import fs from "fs";
@@ -21,6 +21,7 @@ var UPLOADS_DIR = path.join(CONFIG_DIR, "uploads");
21
21
  var CODEX_PID_FILE = path.join(CONFIG_DIR, "codex.pid");
22
22
  var CODEX_LOG_FILE = path.join(CONFIG_DIR, "codex.log");
23
23
  var WS_PATH_CHANNEL = "/ws/channel";
24
+ var WS_PATH_CODEX = "/ws/codex";
24
25
  var CHANNEL_SERVER_VERSION = "0.1.0";
25
26
 
26
27
  // src/shared/config.ts
@@ -133,11 +134,115 @@ function logStartup(context) {
133
134
  `);
134
135
  }
135
136
 
137
+ // src/codex/adapter.ts
138
+ import fs5 from "fs";
139
+ import os2 from "os";
140
+ import path4 from "path";
141
+
142
+ // src/codex/hub-link.ts
143
+ import WebSocket from "ws";
144
+ var CodexHubLink = class {
145
+ constructor(hub, identity, onCall, reconnectMs = 5e3, pingTimeoutMs = 75e3) {
146
+ this.hub = hub;
147
+ this.identity = identity;
148
+ this.onCall = onCall;
149
+ this.reconnectMs = reconnectMs;
150
+ this.pingTimeoutMs = pingTimeoutMs;
151
+ }
152
+ ws = null;
153
+ retryTimer = null;
154
+ pingTimer = null;
155
+ closed = true;
156
+ ready = false;
157
+ connect() {
158
+ this.closed = false;
159
+ const query = this.hub.token ? `?token=${encodeURIComponent(this.hub.token)}` : "";
160
+ let ws;
161
+ try {
162
+ ws = new WebSocket(`ws://${this.hub.host}:${this.hub.port}${WS_PATH_CODEX}${query}`);
163
+ } catch (err) {
164
+ logger.debug(`Codex hub link failed: ${err.message}`);
165
+ this.scheduleReconnect();
166
+ return;
167
+ }
168
+ this.ws = ws;
169
+ ws.on("open", () => {
170
+ if (this.ws !== ws) return;
171
+ this.armWatchdog(ws);
172
+ this.hello();
173
+ });
174
+ ws.on("ping", () => this.armWatchdog(ws));
175
+ ws.on("message", (data) => {
176
+ if (this.ws === ws) this.onMessage(String(data));
177
+ });
178
+ ws.on("close", () => {
179
+ if (this.ws !== ws) return;
180
+ this.clearWatchdog();
181
+ this.ws = null;
182
+ this.scheduleReconnect();
183
+ });
184
+ ws.on("error", (err) => logger.debug(`Codex hub link error: ${err.message}`));
185
+ }
186
+ setReady(ready) {
187
+ if (this.ready === ready) return;
188
+ this.ready = ready;
189
+ this.hello();
190
+ }
191
+ disconnect() {
192
+ this.closed = true;
193
+ this.clearWatchdog();
194
+ if (this.retryTimer) clearTimeout(this.retryTimer);
195
+ this.retryTimer = null;
196
+ const ws = this.ws;
197
+ this.ws = null;
198
+ ws?.close();
199
+ }
200
+ hello() {
201
+ this.send({ type: "adapter_hello", adapter: { ...this.identity, ready: this.ready } });
202
+ }
203
+ send(msg) {
204
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
205
+ }
206
+ onMessage(text) {
207
+ let msg;
208
+ try {
209
+ msg = JSON.parse(text);
210
+ } catch {
211
+ return;
212
+ }
213
+ if (!msg || typeof msg !== "object") return;
214
+ if (msg.type !== "adapter_call") return;
215
+ const { requestId, call } = msg;
216
+ this.onCall(call).then(
217
+ (data) => this.send({ type: "adapter_result", requestId, ok: true, data }),
218
+ (err) => this.send({ type: "adapter_result", requestId, ok: false, error: err.message })
219
+ );
220
+ }
221
+ clearWatchdog() {
222
+ if (this.pingTimer) clearTimeout(this.pingTimer);
223
+ this.pingTimer = null;
224
+ }
225
+ armWatchdog(ws) {
226
+ if (this.ws !== ws) return;
227
+ this.clearWatchdog();
228
+ this.pingTimer = setTimeout(() => {
229
+ if (this.ws === ws) ws.terminate();
230
+ }, this.pingTimeoutMs);
231
+ }
232
+ scheduleReconnect() {
233
+ if (this.closed || this.retryTimer) return;
234
+ this.retryTimer = setTimeout(() => {
235
+ this.retryTimer = null;
236
+ this.connect();
237
+ }, this.reconnectMs);
238
+ }
239
+ };
240
+
136
241
  // src/codex/adapter.ts
137
242
  import { randomUUID as randomUUID2 } from "crypto";
138
243
 
139
244
  // src/channel/hub-client.ts
140
- import WebSocket from "ws";
245
+ import WebSocket2 from "ws";
141
246
  var HubClient = class {
142
247
  constructor(sessionId, sessionName, hubHost = DEFAULT_HUB_HOST, hubPort = DEFAULT_HUB_PORT, token2, getPeerName = () => void 0, getRegistration = () => ({})) {
143
248
  this.sessionId = sessionId;
@@ -160,7 +265,7 @@ var HubClient = class {
160
265
  const url = `ws://${this.hubHost}:${this.hubPort}${WS_PATH_CHANNEL}${tokenQuery}`;
161
266
  logger.debug(`Connecting to hub at ${url}`);
162
267
  try {
163
- const ws = new WebSocket(url);
268
+ const ws = new WebSocket2(url);
164
269
  this.ws = ws;
165
270
  ws.on("open", () => {
166
271
  if (this.ws !== ws) return;
@@ -200,7 +305,7 @@ var HubClient = class {
200
305
  }
201
306
  }
202
307
  send(msg) {
203
- if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
308
+ if (this.connected && this.ws?.readyState === WebSocket2.OPEN) {
204
309
  this.ws.send(JSON.stringify(msg));
205
310
  } else {
206
311
  if (this.queue.length < 100) {
@@ -225,7 +330,7 @@ var HubClient = class {
225
330
  this.connected = false;
226
331
  }
227
332
  reregister() {
228
- if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
333
+ if (this.connected && this.ws?.readyState === WebSocket2.OPEN) {
229
334
  this.ws.send(JSON.stringify(this.registration()));
230
335
  }
231
336
  }
@@ -259,10 +364,10 @@ import { spawn } from "child_process";
259
364
  import fs3 from "fs";
260
365
  import path3 from "path";
261
366
  import { Duplex } from "stream";
262
- import WebSocket2 from "ws";
367
+ import WebSocket3 from "ws";
263
368
  function resolveCommand(command, platform = process.platform, env = process.env) {
264
369
  if (platform !== "win32" || /[\\/]/.test(command) || path3.extname(command)) return { file: command, shell: false };
265
- const file = findOnPath(command, platform, env);
370
+ const file = findCodex(command, platform, env);
266
371
  return file ? { file, shell: file.endsWith(".cmd") } : { file: command, shell: false };
267
372
  }
268
373
  function findOnPath(command, platform = process.platform, env = process.env) {
@@ -275,6 +380,15 @@ function findOnPath(command, platform = process.platform, env = process.env) {
275
380
  }
276
381
  return void 0;
277
382
  }
383
+ function findCodex(command, platform = process.platform, env = process.env) {
384
+ const onPath = findOnPath(command, platform, env);
385
+ if (onPath || platform !== "win32") return onPath;
386
+ const candidates = [
387
+ env.LOCALAPPDATA && path3.join(env.LOCALAPPDATA, "Programs", "OpenAI", "Codex", "bin", `${command}.exe`),
388
+ env.APPDATA && path3.join(env.APPDATA, "npm", `${command}.cmd`)
389
+ ];
390
+ return candidates.find((file) => !!file && fs3.existsSync(file));
391
+ }
278
392
  var defaultSpawn = (command, args) => {
279
393
  const { file, shell } = resolveCommand(command);
280
394
  if (shell) return spawn(`"${file}" ${args.join(" ")}`, { stdio: "pipe", windowsHide: true, shell: true });
@@ -305,7 +419,7 @@ function connectProxy(command, spawnFn = defaultSpawn) {
305
419
  }, ref() {
306
420
  }, unref() {
307
421
  } });
308
- const ws = new WebSocket2("ws://localhost/", {
422
+ const ws = new WebSocket3("ws://localhost/", {
309
423
  createConnection: (() => stream)
310
424
  });
311
425
  const close = () => {
@@ -333,6 +447,57 @@ function connectProxy(command, spawnFn = defaultSpawn) {
333
447
  });
334
448
  }
335
449
 
450
+ // src/codex/inputs.ts
451
+ import fs4 from "fs/promises";
452
+
453
+ // src/codex/mapping.ts
454
+ var TITLE_MAX = 30;
455
+ var SOURCE_LABEL = { dashboard: "Dashboard", telegram: "Telegram", api: "API" };
456
+ var SOURCE_PREFIX = new RegExp(`^\\[claude-alarm(?: \xB7 (?:${Object.values(SOURCE_LABEL).join("|")}))?\\] ?`);
457
+ function codexSessionId(threadId) {
458
+ return `codex:${threadId}`;
459
+ }
460
+ function isTrackable(thread) {
461
+ return !thread.parentThreadId && !thread.ephemeral && thread.status.type !== "notLoaded";
462
+ }
463
+ function threadTitle(thread) {
464
+ const name = thread.name?.trim();
465
+ if (name) return name;
466
+ const preview = (thread.preview ?? "").replace(/\s+/g, " ").trim().replace(SOURCE_PREFIX, "").trim();
467
+ if (preview) return preview.length > TITLE_MAX ? `${preview.slice(0, TITLE_MAX)}\u2026` : preview;
468
+ return thread.cwd.replace(/^.*[/\\]/, "") || thread.id.slice(0, 8);
469
+ }
470
+ function hubStatus(status) {
471
+ if (status.type !== "active") return "idle";
472
+ return status.activeFlags.some((f) => f === "waitingOnApproval" || f === "waitingOnUserInput") ? "waiting_input" : "working";
473
+ }
474
+ function withSourcePrefix(content, source) {
475
+ return source ? `[claude-alarm \xB7 ${SOURCE_LABEL[source]}] ${content}` : `[claude-alarm] ${content}`;
476
+ }
477
+ function finalAnswer(messages) {
478
+ const finals = messages.filter((m) => m.phase === "final_answer").map((m) => m.text);
479
+ if (finals.length) return finals.join("\n\n");
480
+ return messages.at(-1)?.text ?? null;
481
+ }
482
+ function cleanFolder(input) {
483
+ const s = input.trim();
484
+ return s.length >= 2 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1).trim() : s;
485
+ }
486
+
487
+ // src/codex/inputs.ts
488
+ var IMAGE_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
489
+ function textInput(content, source) {
490
+ return [{ type: "text", text: withSourcePrefix(content, source) }];
491
+ }
492
+ async function imageInput(imagePath, mimeType, caption, source) {
493
+ if (!IMAGE_TYPES.has(mimeType)) throw new Error(`unsupported image type ${mimeType}`);
494
+ const data = await fs4.readFile(imagePath);
495
+ return [
496
+ { type: "text", text: withSourcePrefix(caption?.trim() || "(image)", source) },
497
+ { type: "image", url: `data:${mimeType};base64,${data.toString("base64")}` }
498
+ ];
499
+ }
500
+
336
501
  // src/codex/rpc.ts
337
502
  import { EventEmitter } from "events";
338
503
  var RpcError = class extends Error {
@@ -358,14 +523,14 @@ var RpcClient = class extends EventEmitter {
358
523
  }
359
524
  nextId = 1;
360
525
  pending = /* @__PURE__ */ new Map();
361
- request(method, params) {
526
+ request(method, params, timeoutMs = this.timeoutMs) {
362
527
  if (this.ws.readyState !== this.ws.OPEN) return Promise.reject(new Error("connection closed"));
363
528
  const id = this.nextId++;
364
529
  return new Promise((resolve, reject) => {
365
- const timer = setTimeout(() => {
530
+ const timer = timeoutMs === null ? void 0 : setTimeout(() => {
366
531
  this.pending.delete(id);
367
532
  reject(new Error(`${method} timed out`));
368
- }, this.timeoutMs);
533
+ }, timeoutMs);
369
534
  this.pending.set(id, { resolve, reject, timer });
370
535
  this.ws.send(JSON.stringify(params === void 0 ? { id, method } : { id, method, params }));
371
536
  });
@@ -476,41 +641,14 @@ ${f.diff}` : f.path).join("\n\n") : "The file list is not available here. Check
476
641
  }
477
642
  }
478
643
 
479
- // src/codex/mapping.ts
480
- var TITLE_MAX = 30;
481
- var SOURCE_LABEL = { dashboard: "Dashboard", telegram: "Telegram", api: "API" };
482
- function codexSessionId(threadId) {
483
- return `codex:${threadId}`;
484
- }
485
- function isTrackable(thread) {
486
- return !thread.parentThreadId && !thread.ephemeral && thread.status.type !== "notLoaded";
487
- }
488
- function threadTitle(thread) {
489
- const name = thread.name?.trim();
490
- if (name) return name;
491
- const preview = (thread.preview ?? "").replace(/\s+/g, " ").trim();
492
- if (preview) return preview.length > TITLE_MAX ? `${preview.slice(0, TITLE_MAX)}\u2026` : preview;
493
- return thread.cwd.replace(/^.*[/\\]/, "") || thread.id.slice(0, 8);
494
- }
495
- function hubStatus(status) {
496
- if (status.type !== "active") return "idle";
497
- return status.activeFlags.some((f) => f === "waitingOnApproval" || f === "waitingOnUserInput") ? "waiting_input" : "working";
498
- }
499
- function withSourcePrefix(content, source) {
500
- return source ? `[claude-alarm \xB7 ${SOURCE_LABEL[source]}] ${content}` : `[claude-alarm] ${content}`;
501
- }
502
- function finalAnswer(messages) {
503
- const finals = messages.filter((m) => m.phase === "final_answer").map((m) => m.text);
504
- if (finals.length) return finals.join("\n\n");
505
- return messages.at(-1)?.text ?? null;
506
- }
507
-
508
644
  // src/codex/adapter.ts
509
645
  var USER_REQUESTS = /* @__PURE__ */ new Set(["item/tool/requestUserInput", "item/permissions/requestApproval", "mcpServer/elicitation/request"]);
510
646
  var CodexAdapter = class {
511
647
  constructor(opts) {
512
648
  this.opts = opts;
513
649
  this.delay = opts.reconnectMinMs ?? 2e3;
650
+ this.hostName = opts.hostName ?? os2.hostname();
651
+ this.link = new CodexHubLink(opts.hub, { id: randomUUID2(), host: this.hostName }, (call) => this.onCall(call), opts.linkReconnectMs);
514
652
  }
515
653
  conn;
516
654
  rpc;
@@ -519,12 +657,20 @@ var CodexAdapter = class {
519
657
  stopped = false;
520
658
  retryTimer;
521
659
  delay;
660
+ notFoundNotice = "unsent";
661
+ firstConnectReported = false;
662
+ pins = /* @__PURE__ */ new Set();
663
+ ready = false;
664
+ hostName;
665
+ link;
522
666
  start() {
523
667
  this.stopped = false;
668
+ this.link.connect();
524
669
  void this.connect();
525
670
  }
526
671
  stop() {
527
672
  this.stopped = true;
673
+ this.link.disconnect();
528
674
  if (this.retryTimer) clearTimeout(this.retryTimer);
529
675
  this.retryTimer = void 0;
530
676
  for (const id of [...this.threads.keys()]) this.drop(id);
@@ -538,7 +684,7 @@ var CodexAdapter = class {
538
684
  conn.close();
539
685
  return;
540
686
  }
541
- const live = new RpcClient(conn.ws);
687
+ const live = new RpcClient(conn.ws, this.opts.rpcTimeoutMs);
542
688
  rpc = live;
543
689
  live.on("notification", (method, params) => this.onNotification(method, params));
544
690
  live.on("request", (id, method, params) => this.onServerRequest(id, method, params));
@@ -552,10 +698,19 @@ var CodexAdapter = class {
552
698
  });
553
699
  live.notify("initialized");
554
700
  logger.info(`Connected to Codex daemon (${init.userAgent ?? "unknown version"})`);
701
+ this.reportFirstConnect({ connected: true, ...init.userAgent ? { userAgent: init.userAgent } : {} });
555
702
  await this.discover();
703
+ if (this.rpc === live) this.setReady(true);
556
704
  this.delay = this.opts.reconnectMinMs ?? 2e3;
557
705
  } catch (err) {
706
+ this.setReady(false);
558
707
  logger.warn(`Codex daemon connection failed: ${err.message}`);
708
+ if (err.code === "ENOENT") this.noticeNotFound();
709
+ this.reportFirstConnect({
710
+ connected: false,
711
+ error: err.message,
712
+ notFound: err.code === "ENOENT"
713
+ });
559
714
  if (rpc && this.rpc === rpc) {
560
715
  this.rpc = void 0;
561
716
  this.conn?.close();
@@ -566,6 +721,7 @@ var CodexAdapter = class {
566
721
  }
567
722
  }
568
723
  onDaemonLost() {
724
+ this.setReady(false);
569
725
  this.rpc = void 0;
570
726
  this.conn?.close();
571
727
  this.conn = void 0;
@@ -581,18 +737,86 @@ var CodexAdapter = class {
581
737
  }, this.delay);
582
738
  this.delay = Math.min(this.delay * 2, this.opts.reconnectMaxMs ?? 6e4);
583
739
  }
740
+ reportFirstConnect(outcome) {
741
+ if (this.firstConnectReported || this.stopped) return;
742
+ this.firstConnectReported = true;
743
+ try {
744
+ this.opts.onFirstConnect?.(outcome);
745
+ } catch (err) {
746
+ logger.debug(`First-connect observer failed: ${err.message}`);
747
+ }
748
+ }
749
+ noticeNotFound() {
750
+ if (this.notFoundNotice !== "unsent") return;
751
+ this.notFoundNotice = "sending";
752
+ const { host: host2, port: port2, token: token2 } = this.opts.hub;
753
+ fetch(`http://${host2}:${port2}/api/notify`, {
754
+ method: "POST",
755
+ headers: { "Content-Type": "application/json", ...token2 ? { Authorization: `Bearer ${token2}` } : {} },
756
+ body: JSON.stringify({
757
+ title: "Codex not found",
758
+ message: `The Codex adapter cannot find "${this.opts.command}". Open a new terminal and restart the hub, or set "codex.command" in ~/.claude-alarm/config.json.`,
759
+ level: "warning"
760
+ }),
761
+ signal: AbortSignal.timeout(this.opts.noticeTimeoutMs ?? 5e3)
762
+ }).then((res) => {
763
+ this.notFoundNotice = res.ok ? "sent" : "unsent";
764
+ if (!res.ok) logger.debug(`Codex not-found notice was refused (${res.status})`);
765
+ }).catch((err) => {
766
+ this.notFoundNotice = err.name === "TimeoutError" ? "sent" : "unsent";
767
+ logger.debug(`Codex not-found notice failed: ${err.message}`);
768
+ });
769
+ }
770
+ setReady(ready) {
771
+ this.ready = ready;
772
+ this.link.setReady(ready);
773
+ }
774
+ requireDaemon() {
775
+ if (!this.ready || !this.rpc) throw new Error(`Codex is not connected on ${this.hostName}.`);
776
+ return this.rpc;
777
+ }
778
+ async onCall(call) {
779
+ if (call.kind === "folders") return { folders: await this.folders() };
780
+ return { sessionId: await this.create(call.cwd) };
781
+ }
782
+ async folders() {
783
+ const page = await this.requireDaemon().request("thread/list", { limit: 50, sortKey: "updated_at" });
784
+ return [...new Set(page.data.map((t) => t.cwd).filter(Boolean))].slice(0, 10);
785
+ }
786
+ async create(input) {
787
+ const rpc = this.requireDaemon();
788
+ const cwd = cleanFolder(input);
789
+ const isDir = path4.isAbsolute(cwd) && await fs5.promises.stat(cwd).then((s) => s.isDirectory(), () => false);
790
+ if (!isDir) throw new Error(`Folder not found on ${this.hostName}: ${cwd}`);
791
+ let thread;
792
+ try {
793
+ ({ thread } = await rpc.request("thread/start", { cwd, sandbox: "danger-full-access", approvalPolicy: "never" }, null));
794
+ } catch (err) {
795
+ throw new Error(`Codex could not start the conversation: ${err.message}`);
796
+ }
797
+ this.pins.add(thread.id);
798
+ this.upsert(thread);
799
+ const t = this.threads.get(thread.id);
800
+ if (!t) {
801
+ this.pins.delete(thread.id);
802
+ throw new Error("Codex started a conversation claude-alarm cannot follow.");
803
+ }
804
+ t.subscribed = true;
805
+ return codexSessionId(thread.id);
806
+ }
584
807
  async discover() {
808
+ const rpc = this.rpc;
809
+ const pinned = [...this.pins];
585
810
  const ids = [];
586
811
  let cursor;
587
812
  do {
588
- const page = await this.rpc.request(
589
- "thread/loaded/list",
590
- cursor ? { cursor } : {}
591
- );
813
+ const page = await rpc.request("thread/loaded/list", cursor ? { cursor } : {});
592
814
  ids.push(...page.data);
593
815
  cursor = page.nextCursor;
594
816
  } while (cursor);
595
817
  for (const id of ids) await this.refresh(id);
818
+ if (this.rpc !== rpc) return;
819
+ for (const id of pinned) if (!ids.includes(id)) this.pins.delete(id);
596
820
  }
597
821
  async refresh(threadId) {
598
822
  try {
@@ -629,6 +853,7 @@ var CodexAdapter = class {
629
853
  wantSubscribed: false,
630
854
  sync: Promise.resolve(),
631
855
  pendingTurn: false,
856
+ sending: Promise.resolve(),
632
857
  turns: /* @__PURE__ */ new Map(),
633
858
  unrelayed: false,
634
859
  files: /* @__PURE__ */ new Map()
@@ -642,12 +867,12 @@ var CodexAdapter = class {
642
867
  const t = this.threads.get(threadId);
643
868
  if (!t) return {};
644
869
  const title = threadTitle(t.thread);
645
- return { name: title, title, cwd: t.thread.cwd, agentKind: "codex", status: hubStatus(t.thread.status) };
870
+ return { name: title, title, cwd: t.thread.cwd, agentKind: "codex", status: hubStatus(t.thread.status), closable: this.pins.has(threadId) };
646
871
  }
647
872
  want(threadId, subscribed) {
648
873
  const t = this.threads.get(threadId);
649
874
  if (!t) return Promise.resolve();
650
- t.wantSubscribed = subscribed;
875
+ t.wantSubscribed = subscribed || this.pins.has(threadId);
651
876
  t.sync = t.sync.then(() => this.syncSubscription(t));
652
877
  return t.sync;
653
878
  }
@@ -687,6 +912,25 @@ var CodexAdapter = class {
687
912
  t.releaseTimer = void 0;
688
913
  t.hub.disconnect();
689
914
  }
915
+ busy(t) {
916
+ return t.thread.status.type === "active" || t.pendingTurn;
917
+ }
918
+ async close(threadId) {
919
+ const t = this.threads.get(threadId);
920
+ if (!t || !this.pins.delete(threadId)) return;
921
+ t.hub.reregister();
922
+ if (this.busy(t)) return;
923
+ await this.want(threadId, false);
924
+ if (this.threads.get(threadId) !== t || this.busy(t) || t.wantSubscribed) return;
925
+ if (!t.subscribed) {
926
+ this.drop(threadId);
927
+ return;
928
+ }
929
+ this.pins.add(threadId);
930
+ t.hub.reregister();
931
+ void this.want(threadId, true);
932
+ this.notify(threadId, "Not closed", "Codex did not release the conversation. Try closing it again.", "warning");
933
+ }
690
934
  onNotification(method, params) {
691
935
  switch (method) {
692
936
  case "thread/started":
@@ -706,6 +950,7 @@ var CodexAdapter = class {
706
950
  case "thread/closed":
707
951
  case "thread/archived":
708
952
  case "thread/deleted":
953
+ this.pins.delete(params.threadId);
709
954
  this.drop(params.threadId);
710
955
  break;
711
956
  case "item/started":
@@ -730,6 +975,7 @@ var CodexAdapter = class {
730
975
  return;
731
976
  }
732
977
  if (status.type === "notLoaded") {
978
+ this.pins.delete(threadId);
733
979
  this.drop(threadId);
734
980
  return;
735
981
  }
@@ -763,6 +1009,7 @@ var CodexAdapter = class {
763
1009
  const collected = t.turns.get(turn.id) ?? [];
764
1010
  t.turns.delete(turn.id);
765
1011
  t.files.clear();
1012
+ if (!t.thread.name?.trim() && !t.thread.preview?.trim()) void this.refresh(threadId);
766
1013
  if (t.thread.status.type !== "active") void this.want(threadId, false);
767
1014
  if (turn.status === "failed" || turn.error) {
768
1015
  this.notify(threadId, "Codex task failed", turn.error?.message ?? "The task ended with an error.", "error");
@@ -778,33 +1025,75 @@ var CodexAdapter = class {
778
1025
  }
779
1026
  onHubMessage(threadId, msg) {
780
1027
  if (msg.type === "message_to_session") {
781
- void this.sendTurn(threadId, msg.content, msg.source);
1028
+ this.enqueue(threadId, async () => textInput(msg.content, msg.source));
782
1029
  } else if (msg.type === "image_to_session") {
783
- this.notify(threadId, "Not delivered", "Codex sessions do not accept images yet.", "warning");
1030
+ this.enqueue(threadId, () => imageInput(msg.imagePath, msg.mimeType, msg.content, msg.source));
784
1031
  } else if (msg.type === "permission_response") {
785
1032
  this.answer(threadId, msg.requestId, msg.choiceId);
1033
+ } else if (msg.type === "codex_close") {
1034
+ void this.close(threadId);
786
1035
  }
787
1036
  }
788
- async sendTurn(threadId, content, source) {
1037
+ // One message at a time per conversation, so a message right behind another sees the turn the first one started.
1038
+ enqueue(threadId, build) {
789
1039
  const t = this.threads.get(threadId);
790
1040
  if (!t) return;
791
- if (t.thread.status.type !== "idle" || t.pendingTurn) {
792
- this.notify(threadId, "Not delivered", "Codex is busy, so the message was not delivered. Send it again when the task finishes.", "warning");
1041
+ t.sending = t.sending.then(() => this.deliver(t, build)).catch((err) => logger.warn(`Codex delivery for ${threadId} failed: ${err.message}`));
1042
+ }
1043
+ async deliver(t, build) {
1044
+ const threadId = t.thread.id;
1045
+ if (this.threads.get(threadId) !== t) return;
1046
+ if (hubStatus(t.thread.status) === "waiting_input") {
1047
+ this.notify(threadId, "Not delivered", "Codex is waiting for an approval or input. Answer it first, then send the message again.", "warning");
793
1048
  return;
794
1049
  }
1050
+ let input;
1051
+ try {
1052
+ input = await build();
1053
+ } catch (err) {
1054
+ logger.debug(`Codex input for ${threadId} could not be built: ${err.message}`);
1055
+ this.notify(threadId, "Not delivered", "The image could not be read here, so it was not delivered. Codex may be running on another PC.", "warning");
1056
+ return;
1057
+ }
1058
+ if (this.threads.get(threadId) !== t) return;
795
1059
  t.pendingTurn = true;
1060
+ const wasUnrelayed = t.unrelayed;
796
1061
  try {
797
- if (!this.rpc) throw new Error("not connected to the Codex daemon");
1062
+ const rpc = this.rpc;
1063
+ if (!rpc) throw new Error("not connected to the Codex daemon");
798
1064
  await this.want(threadId, true);
799
- t.unrelayed = !t.subscribed;
800
- await this.rpc.request("turn/start", { threadId, input: [{ type: "text", text: withSourcePrefix(content, source) }] });
1065
+ const unsubscribed = !t.subscribed;
1066
+ const running = await this.runningTurn(rpc, threadId);
1067
+ if (this.threads.get(threadId) !== t) return;
1068
+ if (unsubscribed) t.unrelayed = true;
1069
+ if (running) {
1070
+ await rpc.request("turn/steer", { threadId, expectedTurnId: running, input });
1071
+ this.notify(threadId, "Queued", "Queued: Codex will read it after its current step.", "info");
1072
+ return;
1073
+ }
1074
+ await rpc.request("turn/start", { threadId, input });
801
1075
  } catch (err) {
802
1076
  t.pendingTurn = false;
803
- t.unrelayed = false;
1077
+ t.unrelayed = wasUnrelayed;
804
1078
  this.releaseLater(t);
805
1079
  this.notify(threadId, "Not delivered", `Codex rejected the message: ${err.message}`, "warning");
806
1080
  }
807
1081
  }
1082
+ // The list is unavailable before a new conversation's first turn; starting a turn is safe then, since turn/start steers a running turn.
1083
+ async runningTurn(rpc, threadId) {
1084
+ try {
1085
+ const page = await rpc.request("thread/turns/list", {
1086
+ threadId,
1087
+ limit: 1,
1088
+ sortDirection: "desc"
1089
+ });
1090
+ const turn = page.data?.[0];
1091
+ return turn?.status === "inProgress" ? turn.id : void 0;
1092
+ } catch (err) {
1093
+ logger.debug(`thread/turns/list ${threadId} failed: ${err.message}`);
1094
+ return void 0;
1095
+ }
1096
+ }
808
1097
  onServerRequest(rpcId, method, params) {
809
1098
  const t = params?.threadId ? this.threads.get(params.threadId) : void 0;
810
1099
  let view = null;
@@ -865,6 +1154,27 @@ var CodexAdapter = class {
865
1154
  }
866
1155
  };
867
1156
 
1157
+ // src/shared/hub-url.ts
1158
+ import net from "net";
1159
+ function hubUrlHost(host2) {
1160
+ if (host2 === "" || host2 === "0.0.0.0") return "127.0.0.1";
1161
+ if (host2 === "::") return "[::1]";
1162
+ return net.isIPv6(host2) ? `[${host2}]` : host2;
1163
+ }
1164
+
1165
+ // src/codex/hub-target.ts
1166
+ function resolveAdapterHub(config2, env = process.env) {
1167
+ const envHost = env.CLAUDE_ALARM_HUB_HOST;
1168
+ const envPort = env.CLAUDE_ALARM_HUB_PORT;
1169
+ const envToken = env.CLAUDE_ALARM_HUB_TOKEN;
1170
+ return {
1171
+ host: hubUrlHost(envHost ?? config2.hub.host),
1172
+ port: envPort ? parseInt(envPort, 10) : config2.hub.port,
1173
+ token: envToken ?? config2.hub.token,
1174
+ fromEnv: { host: envHost !== void 0, port: !!envPort, token: envToken !== void 0 }
1175
+ };
1176
+ }
1177
+
868
1178
  // src/codex/main.ts
869
1179
  function isRunning(pid) {
870
1180
  try {
@@ -880,17 +1190,24 @@ process.stdout.on("error", () => {
880
1190
  process.stderr.on("error", () => {
881
1191
  });
882
1192
  var config = loadConfig();
883
- var existing = fs4.existsSync(CODEX_PID_FILE) ? parseInt(fs4.readFileSync(CODEX_PID_FILE, "utf-8").trim(), 10) : NaN;
1193
+ var existing = fs6.existsSync(CODEX_PID_FILE) ? parseInt(fs6.readFileSync(CODEX_PID_FILE, "utf-8").trim(), 10) : NaN;
884
1194
  if (!Number.isNaN(existing) && existing !== process.pid && isRunning(existing)) {
885
1195
  logger.info(`Codex adapter already running (PID: ${existing})`);
886
1196
  process.exit(0);
887
1197
  }
888
- fs4.writeFileSync(CODEX_PID_FILE, String(process.pid), "utf-8");
1198
+ fs6.writeFileSync(CODEX_PID_FILE, String(process.pid), "utf-8");
889
1199
  logStartup("Codex adapter");
890
- var host = process.env.CLAUDE_ALARM_HUB_HOST ?? (config.hub.host === "0.0.0.0" ? DEFAULT_HUB_HOST : config.hub.host);
891
- var port = process.env.CLAUDE_ALARM_HUB_PORT ? parseInt(process.env.CLAUDE_ALARM_HUB_PORT, 10) : config.hub.port;
892
- var token = process.env.CLAUDE_ALARM_HUB_TOKEN ?? config.hub.token;
893
- var adapter = new CodexAdapter({ command: config.codex?.command ?? "codex", hub: { host, port, token } });
1200
+ var { host, port, token } = resolveAdapterHub(config);
1201
+ var adapter = new CodexAdapter({
1202
+ command: config.codex?.command ?? "codex",
1203
+ hub: { host, port, token },
1204
+ onFirstConnect: (outcome) => {
1205
+ if (!process.send || !process.connected) return;
1206
+ process.send({ type: "codex-first-connect", ...outcome }, void 0, void 0, (err) => {
1207
+ if (err) logger.debug(`First-connect report not delivered: ${err.message}`);
1208
+ });
1209
+ }
1210
+ });
894
1211
  adapter.start();
895
1212
  var exiting = false;
896
1213
  var shutdown = () => {
@@ -898,7 +1215,7 @@ var shutdown = () => {
898
1215
  exiting = true;
899
1216
  adapter.stop();
900
1217
  try {
901
- if (fs4.readFileSync(CODEX_PID_FILE, "utf-8").trim() === String(process.pid)) fs4.unlinkSync(CODEX_PID_FILE);
1218
+ if (fs6.readFileSync(CODEX_PID_FILE, "utf-8").trim() === String(process.pid)) fs6.unlinkSync(CODEX_PID_FILE);
902
1219
  } catch {
903
1220
  }
904
1221
  process.exit(0);