@delt/claude-alarm 1.2.0 → 1.4.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,8 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/codex/main.ts
4
- import fs6 from "fs";
5
-
6
3
  // src/shared/config.ts
7
4
  import fs from "fs";
8
5
  import path2 from "path";
@@ -244,12 +241,12 @@ import { randomUUID as randomUUID2 } from "crypto";
244
241
  // src/channel/hub-client.ts
245
242
  import WebSocket2 from "ws";
246
243
  var HubClient = class {
247
- constructor(sessionId, sessionName, hubHost = DEFAULT_HUB_HOST, hubPort = DEFAULT_HUB_PORT, token2, getPeerName = () => void 0, getRegistration = () => ({})) {
244
+ constructor(sessionId, sessionName, hubHost = DEFAULT_HUB_HOST, hubPort = DEFAULT_HUB_PORT, token, getPeerName = () => void 0, getRegistration = () => ({})) {
248
245
  this.sessionId = sessionId;
249
246
  this.sessionName = sessionName;
250
247
  this.hubHost = hubHost;
251
248
  this.hubPort = hubPort;
252
- this.token = token2;
249
+ this.token = token;
253
250
  this.getPeerName = getPeerName;
254
251
  this.getRegistration = getRegistration;
255
252
  }
@@ -360,13 +357,14 @@ var HubClient = class {
360
357
  };
361
358
 
362
359
  // src/codex/transport.ts
363
- import { spawn } from "child_process";
360
+ import { execFile, spawn } from "child_process";
364
361
  import fs3 from "fs";
365
362
  import path3 from "path";
366
363
  import { Duplex } from "stream";
367
364
  import WebSocket3 from "ws";
368
365
  function resolveCommand(command, platform = process.platform, env = process.env) {
369
- if (platform !== "win32" || /[\\/]/.test(command) || path3.extname(command)) return { file: command, shell: false };
366
+ if (platform !== "win32") return { file: command, shell: false };
367
+ if (/[\\/]/.test(command) || path3.extname(command)) return { file: command, shell: /\.(cmd|bat)$/i.test(command) };
370
368
  const file = findCodex(command, platform, env);
371
369
  return file ? { file, shell: file.endsWith(".cmd") } : { file: command, shell: false };
372
370
  }
@@ -389,13 +387,116 @@ function findCodex(command, platform = process.platform, env = process.env) {
389
387
  ];
390
388
  return candidates.find((file) => !!file && fs3.existsSync(file));
391
389
  }
390
+ var shellSpawns = /* @__PURE__ */ new WeakSet();
391
+ var spawnedThroughShell = (child) => shellSpawns.has(child);
392
392
  var defaultSpawn = (command, args) => {
393
393
  const { file, shell } = resolveCommand(command);
394
- if (shell) return spawn(`"${file}" ${args.join(" ")}`, { stdio: "pipe", windowsHide: true, shell: true });
395
- return spawn(file, args, { stdio: "pipe", windowsHide: true });
394
+ if (!shell) return spawn(file, args, { stdio: "pipe", windowsHide: true });
395
+ const child = spawn(`"${file}" ${args.join(" ")}`, { stdio: "pipe", windowsHide: true, shell: true });
396
+ shellSpawns.add(child);
397
+ return child;
398
+ };
399
+ var queryProcesses = () => new Promise((resolve, reject) => {
400
+ const script = "@(Get-CimInstance Win32_Process | ForEach-Object { [pscustomobject]@{ pid = [int]$_.ProcessId; parentPid = [int]$_.ParentProcessId; creationTime = $_.CreationDate.ToUniversalTime().ToString('o') } }) | ConvertTo-Json -Compress";
401
+ execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, timeout: 5e3, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
402
+ if (err) {
403
+ reject(err);
404
+ return;
405
+ }
406
+ try {
407
+ const rows = JSON.parse(stdout);
408
+ if (!Array.isArray(rows) || rows.some((row) => !Number.isInteger(row.pid) || !Number.isInteger(row.parentPid) || typeof row.creationTime !== "string")) {
409
+ throw new Error("invalid process snapshot");
410
+ }
411
+ resolve(rows);
412
+ } catch (error) {
413
+ reject(error);
414
+ }
415
+ });
416
+ });
417
+ var runFile = (file, args, options, callback) => {
418
+ execFile(file, args, options, (err) => callback(err));
396
419
  };
397
- function connectProxy(command, spawnFn = defaultSpawn) {
420
+ function descendantsOf(rootPid, rows) {
421
+ const root = rows.find((row) => row.pid === rootPid);
422
+ if (!root) return void 0;
423
+ const found = [];
424
+ const seen = /* @__PURE__ */ new Set([root.pid]);
425
+ let level = [root];
426
+ for (let depth = 1; level.length; depth++) {
427
+ const next = [];
428
+ for (const parent of level) {
429
+ for (const row of rows) {
430
+ if (row.parentPid !== parent.pid || seen.has(row.pid) || row.creationTime < parent.creationTime) continue;
431
+ seen.add(row.pid);
432
+ next.push(row);
433
+ found.push({ ...row, depth });
434
+ }
435
+ }
436
+ level = next;
437
+ }
438
+ return found;
439
+ }
440
+ var exited = (child) => child.exitCode !== null || child.signalCode !== null;
441
+ function treeKiller(platform = process.platform, run = runFile, query = queryProcesses, throughShell = spawnedThroughShell) {
442
+ const recorded = /* @__PURE__ */ new WeakMap();
443
+ const stop = (pid) => new Promise((resolve) => {
444
+ run("taskkill", ["/PID", String(pid), "/F"], { windowsHide: true }, (err) => {
445
+ if (err) logger.debug(`taskkill ${pid} failed: ${err.message}`);
446
+ resolve();
447
+ });
448
+ });
449
+ const stopDeepestFirst = async (descendants) => {
450
+ for (const descendant of [...descendants].sort((a, b) => b.depth - a.depth)) await stop(descendant.pid);
451
+ };
452
+ const snapshot = async () => {
453
+ try {
454
+ return await query();
455
+ } catch (err) {
456
+ logger.debug(`codex process query failed: ${String(err)}`);
457
+ return void 0;
458
+ }
459
+ };
460
+ const isShellTree = (child) => platform === "win32" && child.pid !== void 0 && throughShell(child);
461
+ const killer = async (child) => {
462
+ if (!isShellTree(child)) {
463
+ if (!exited(child)) child.kill();
464
+ return;
465
+ }
466
+ if (!exited(child)) {
467
+ const rows = await snapshot();
468
+ if (!exited(child)) {
469
+ const descendants2 = rows && descendantsOf(child.pid, rows);
470
+ if (descendants2) await stopDeepestFirst(descendants2);
471
+ child.kill();
472
+ return;
473
+ }
474
+ }
475
+ const descendants = await recorded.get(child) ?? [];
476
+ if (!descendants.length) return;
477
+ const current = await snapshot();
478
+ if (!current) return;
479
+ await stopDeepestFirst(descendants.filter((d) => current.some((row) => row.pid === d.pid && row.creationTime === d.creationTime)));
480
+ };
481
+ killer.track = async (child) => {
482
+ if (!isShellTree(child) || exited(child)) return;
483
+ const earlier = recorded.get(child);
484
+ const latest = (async () => {
485
+ const rows = await snapshot();
486
+ const found = rows && !exited(child) ? descendantsOf(child.pid, rows) ?? [] : [];
487
+ const before = await earlier ?? [];
488
+ return [...before, ...found.filter((d) => !before.some((b) => b.pid === d.pid && b.creationTime === d.creationTime))];
489
+ })();
490
+ recorded.set(child, latest);
491
+ await latest;
492
+ };
493
+ return killer;
494
+ }
495
+ var killTree = treeKiller();
496
+ var HANDSHAKE_TIMEOUT_MS = 1e4;
497
+ function connectProxy(command, spawnFn = defaultSpawn, timeoutMs = HANDSHAKE_TIMEOUT_MS, killTreeFn = killTree) {
398
498
  const child = spawnFn(command, ["app-server", "proxy"]);
499
+ void killTreeFn.track?.(child);
399
500
  child.stdin?.on("error", () => {
400
501
  });
401
502
  child.stderr?.on("data", (d) => logger.debug(`codex proxy: ${String(d).trim()}`));
@@ -422,17 +523,20 @@ function connectProxy(command, spawnFn = defaultSpawn) {
422
523
  const ws = new WebSocket3("ws://localhost/", {
423
524
  createConnection: (() => stream)
424
525
  });
425
- const close = () => {
526
+ let closing;
527
+ const close = () => closing ??= (async () => {
426
528
  ws.terminate();
427
- child.kill();
428
- };
529
+ child.stdin?.end();
530
+ await killTreeFn(child);
531
+ })();
429
532
  return new Promise((resolve, reject) => {
430
533
  let settled = false;
534
+ const timer = setTimeout(() => fail(new Error(`codex daemon did not answer within ${timeoutMs / 1e3}s`)), timeoutMs);
431
535
  const fail = (err) => {
432
536
  if (settled) return;
433
537
  settled = true;
434
- close();
435
- reject(err);
538
+ clearTimeout(timer);
539
+ void close().then(() => reject(err), () => reject(err));
436
540
  };
437
541
  child.on("error", fail);
438
542
  child.once("exit", (code) => fail(new Error(`codex proxy exited (code ${code})`)));
@@ -440,8 +544,10 @@ function connectProxy(command, spawnFn = defaultSpawn) {
440
544
  ws.once("open", () => {
441
545
  if (settled) return;
442
546
  settled = true;
547
+ clearTimeout(timer);
443
548
  child.on("error", (err) => logger.warn(`codex proxy error: ${err.message}`));
444
549
  ws.on("error", (err) => logger.warn(`codex daemon socket error: ${err.message}`));
550
+ void killTreeFn.track?.(child);
445
551
  resolve({ ws, close });
446
552
  });
447
553
  });
@@ -651,6 +757,8 @@ var CodexAdapter = class {
651
757
  this.link = new CodexHubLink(opts.hub, { id: randomUUID2(), host: this.hostName }, (call) => this.onCall(call), opts.linkReconnectMs);
652
758
  }
653
759
  conn;
760
+ connecting;
761
+ closing = /* @__PURE__ */ new Set();
654
762
  rpc;
655
763
  threads = /* @__PURE__ */ new Map();
656
764
  approvals = /* @__PURE__ */ new Map();
@@ -666,22 +774,29 @@ var CodexAdapter = class {
666
774
  start() {
667
775
  this.stopped = false;
668
776
  this.link.connect();
669
- void this.connect();
777
+ this.connecting = this.connect();
670
778
  }
671
- stop() {
779
+ async stop() {
672
780
  this.stopped = true;
673
781
  this.link.disconnect();
674
782
  if (this.retryTimer) clearTimeout(this.retryTimer);
675
783
  this.retryTimer = void 0;
676
784
  for (const id of [...this.threads.keys()]) this.drop(id);
677
- this.conn?.close();
785
+ this.closeConn(this.conn);
786
+ await this.connecting;
787
+ await Promise.all(this.closing);
788
+ }
789
+ closeConn(conn) {
790
+ if (!conn) return;
791
+ const closing = conn.close().finally(() => this.closing.delete(closing));
792
+ this.closing.add(closing);
678
793
  }
679
794
  async connect() {
680
795
  let rpc;
681
796
  try {
682
797
  const conn = await connectProxy(this.opts.command, this.opts.spawnFn);
683
798
  if (this.stopped) {
684
- conn.close();
799
+ this.closeConn(conn);
685
800
  return;
686
801
  }
687
802
  const live = new RpcClient(conn.ws, this.opts.rpcTimeoutMs);
@@ -713,7 +828,7 @@ var CodexAdapter = class {
713
828
  });
714
829
  if (rpc && this.rpc === rpc) {
715
830
  this.rpc = void 0;
716
- this.conn?.close();
831
+ this.closeConn(this.conn);
717
832
  this.conn = void 0;
718
833
  }
719
834
  for (const id of [...this.threads.keys()]) this.drop(id);
@@ -723,7 +838,7 @@ var CodexAdapter = class {
723
838
  onDaemonLost() {
724
839
  this.setReady(false);
725
840
  this.rpc = void 0;
726
- this.conn?.close();
841
+ this.closeConn(this.conn);
727
842
  this.conn = void 0;
728
843
  for (const id of [...this.threads.keys()]) this.drop(id);
729
844
  if (!this.stopped) logger.warn("Codex daemon connection lost");
@@ -733,7 +848,7 @@ var CodexAdapter = class {
733
848
  if (this.stopped || this.retryTimer) return;
734
849
  this.retryTimer = setTimeout(() => {
735
850
  this.retryTimer = void 0;
736
- void this.connect();
851
+ this.connecting = this.connect();
737
852
  }, this.delay);
738
853
  this.delay = Math.min(this.delay * 2, this.opts.reconnectMaxMs ?? 6e4);
739
854
  }
@@ -749,10 +864,10 @@ var CodexAdapter = class {
749
864
  noticeNotFound() {
750
865
  if (this.notFoundNotice !== "unsent") return;
751
866
  this.notFoundNotice = "sending";
752
- const { host: host2, port: port2, token: token2 } = this.opts.hub;
753
- fetch(`http://${host2}:${port2}/api/notify`, {
867
+ const { host, port, token } = this.opts.hub;
868
+ fetch(`http://${host}:${port}/api/notify`, {
754
869
  method: "POST",
755
- headers: { "Content-Type": "application/json", ...token2 ? { Authorization: `Bearer ${token2}` } : {} },
870
+ headers: { "Content-Type": "application/json", ...token ? { Authorization: `Bearer ${token}` } : {} },
756
871
  body: JSON.stringify({
757
872
  title: "Codex not found",
758
873
  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.`,
@@ -831,18 +946,18 @@ var CodexAdapter = class {
831
946
  this.drop(thread.id);
832
947
  return;
833
948
  }
834
- const existing2 = this.threads.get(thread.id);
835
- if (existing2) {
836
- existing2.thread = thread;
837
- existing2.hub.reregister();
949
+ const existing = this.threads.get(thread.id);
950
+ if (existing) {
951
+ existing.thread = thread;
952
+ existing.hub.reregister();
838
953
  } else {
839
- const { host: host2, port: port2, token: token2 } = this.opts.hub;
954
+ const { host, port, token } = this.opts.hub;
840
955
  const hub = new HubClient(
841
956
  codexSessionId(thread.id),
842
957
  threadTitle(thread),
843
- host2,
844
- port2,
845
- token2,
958
+ host,
959
+ port,
960
+ token,
846
961
  () => void 0,
847
962
  () => this.registration(thread.id)
848
963
  );
@@ -890,6 +1005,7 @@ var CodexAdapter = class {
890
1005
  }
891
1006
  } catch (err) {
892
1007
  logger.debug(`subscription sync for ${threadId} deferred: ${err.message}`);
1008
+ if (t.wantSubscribed && !t.subscribed && t.thread.status.type === "active") t.unrelayed = true;
893
1009
  }
894
1010
  }
895
1011
  releaseLater(t) {
@@ -1025,9 +1141,9 @@ var CodexAdapter = class {
1025
1141
  }
1026
1142
  onHubMessage(threadId, msg) {
1027
1143
  if (msg.type === "message_to_session") {
1028
- this.enqueue(threadId, async () => textInput(msg.content, msg.source));
1144
+ this.enqueue(threadId, async () => textInput(msg.content, msg.source), msg.source);
1029
1145
  } else if (msg.type === "image_to_session") {
1030
- this.enqueue(threadId, () => imageInput(msg.imagePath, msg.mimeType, msg.content, msg.source));
1146
+ this.enqueue(threadId, () => imageInput(msg.imagePath, msg.mimeType, msg.content, msg.source), msg.source);
1031
1147
  } else if (msg.type === "permission_response") {
1032
1148
  this.answer(threadId, msg.requestId, msg.choiceId);
1033
1149
  } else if (msg.type === "codex_close") {
@@ -1035,16 +1151,16 @@ var CodexAdapter = class {
1035
1151
  }
1036
1152
  }
1037
1153
  // One message at a time per conversation, so a message right behind another sees the turn the first one started.
1038
- enqueue(threadId, build) {
1154
+ enqueue(threadId, build, source) {
1039
1155
  const t = this.threads.get(threadId);
1040
1156
  if (!t) return;
1041
- t.sending = t.sending.then(() => this.deliver(t, build)).catch((err) => logger.warn(`Codex delivery for ${threadId} failed: ${err.message}`));
1157
+ t.sending = t.sending.then(() => this.deliver(t, build, source)).catch((err) => logger.warn(`Codex delivery for ${threadId} failed: ${err.message}`));
1042
1158
  }
1043
- async deliver(t, build) {
1159
+ async deliver(t, build, source) {
1044
1160
  const threadId = t.thread.id;
1045
1161
  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");
1162
+ if (this.waitingForAnswer(t)) {
1163
+ this.refuseWhileWaiting(threadId);
1048
1164
  return;
1049
1165
  }
1050
1166
  let input;
@@ -1065,20 +1181,37 @@ var CodexAdapter = class {
1065
1181
  const unsubscribed = !t.subscribed;
1066
1182
  const running = await this.runningTurn(rpc, threadId);
1067
1183
  if (this.threads.get(threadId) !== t) return;
1184
+ if (this.waitingForAnswer(t)) {
1185
+ this.abandonDelivery(t, wasUnrelayed);
1186
+ this.refuseWhileWaiting(threadId);
1187
+ return;
1188
+ }
1068
1189
  if (unsubscribed) t.unrelayed = true;
1069
1190
  if (running) {
1070
1191
  await rpc.request("turn/steer", { threadId, expectedTurnId: running, input });
1071
- this.notify(threadId, "Queued", "Queued: Codex will read it after its current step.", "info");
1192
+ this.notify(threadId, "Queued", "Queued: Codex will read it after its current step.", "info", source);
1072
1193
  return;
1073
1194
  }
1074
1195
  await rpc.request("turn/start", { threadId, input });
1075
1196
  } catch (err) {
1076
- t.pendingTurn = false;
1077
- t.unrelayed = wasUnrelayed;
1078
- this.releaseLater(t);
1197
+ this.abandonDelivery(t, wasUnrelayed);
1079
1198
  this.notify(threadId, "Not delivered", `Codex rejected the message: ${err.message}`, "warning");
1080
1199
  }
1081
1200
  }
1201
+ abandonDelivery(t, wasUnrelayed) {
1202
+ t.pendingTurn = false;
1203
+ t.unrelayed = wasUnrelayed;
1204
+ this.releaseLater(t);
1205
+ }
1206
+ // The approval request itself can arrive before the status broadcast that marks the thread as waiting.
1207
+ waitingForAnswer(t) {
1208
+ if (hubStatus(t.thread.status) === "waiting_input") return true;
1209
+ for (const a of this.approvals.values()) if (a.threadId === t.thread.id && !a.answered) return true;
1210
+ return false;
1211
+ }
1212
+ refuseWhileWaiting(threadId) {
1213
+ this.notify(threadId, "Not delivered", "Codex is waiting for an approval or input. Answer it first, then send the message again.", "warning");
1214
+ }
1082
1215
  // 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
1216
  async runningTurn(rpc, threadId) {
1084
1217
  try {
@@ -1108,7 +1241,7 @@ var CodexAdapter = class {
1108
1241
  }
1109
1242
  if (!t || !view) {
1110
1243
  if (t && (broken || USER_REQUESTS.has(method))) {
1111
- this.notify(t.thread.id, "Codex is waiting", "Codex asked for input that claude-alarm cannot relay. Handle it in Codex.", "warning");
1244
+ this.waitingInCodex(t.thread.id);
1112
1245
  } else {
1113
1246
  logger.debug(`Ignoring Codex server request ${method}`);
1114
1247
  }
@@ -1118,17 +1251,26 @@ var CodexAdapter = class {
1118
1251
  if (a.threadId === t.thread.id && a.rpcId === rpcId) return;
1119
1252
  }
1120
1253
  const requestId = randomUUID2();
1121
- this.approvals.set(requestId, { threadId: t.thread.id, rpcId, turnId: params.turnId, choices: view.choices, answered: false });
1122
- t.hub.send({
1123
- type: "permission_request",
1124
- sessionId: codexSessionId(t.thread.id),
1125
- requestId,
1126
- toolName: view.toolName,
1127
- description: view.description,
1128
- inputPreview: view.inputPreview,
1129
- timestamp: Date.now(),
1130
- choices: view.choices.map((c, i) => ({ id: String(i), label: c.label }))
1131
- });
1254
+ try {
1255
+ this.approvals.set(requestId, { threadId: t.thread.id, rpcId, turnId: params.turnId, choices: view.choices, answered: false });
1256
+ t.hub.send({
1257
+ type: "permission_request",
1258
+ sessionId: codexSessionId(t.thread.id),
1259
+ requestId,
1260
+ toolName: view.toolName,
1261
+ description: view.description,
1262
+ inputPreview: view.inputPreview,
1263
+ timestamp: Date.now(),
1264
+ choices: view.choices.map((c, i) => ({ id: String(i), label: c.label }))
1265
+ });
1266
+ } catch (err) {
1267
+ this.approvals.delete(requestId);
1268
+ logger.warn(`Codex ${method} could not be relayed: ${err.message}`);
1269
+ this.waitingInCodex(t.thread.id);
1270
+ }
1271
+ }
1272
+ waitingInCodex(threadId) {
1273
+ this.notify(threadId, "Codex is waiting", "Codex asked for input that claude-alarm cannot relay. Handle it in Codex.", "warning");
1132
1274
  }
1133
1275
  answer(threadId, requestId, choiceId) {
1134
1276
  const a = this.approvals.get(requestId);
@@ -1149,17 +1291,17 @@ var CodexAdapter = class {
1149
1291
  this.approvals.delete(requestId);
1150
1292
  this.threads.get(a.threadId)?.hub.send({ type: "permission_resolved", sessionId: codexSessionId(a.threadId), requestId, state });
1151
1293
  }
1152
- notify(threadId, title, message, level) {
1153
- this.threads.get(threadId)?.hub.send({ type: "notify", sessionId: codexSessionId(threadId), title, message, level });
1294
+ notify(threadId, title, message, level, to) {
1295
+ this.threads.get(threadId)?.hub.send({ type: "notify", sessionId: codexSessionId(threadId), title, message, level, ...to ? { to } : {} });
1154
1296
  }
1155
1297
  };
1156
1298
 
1157
1299
  // src/shared/hub-url.ts
1158
1300
  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;
1301
+ function hubUrlHost(host) {
1302
+ if (host === "" || host === "0.0.0.0") return "127.0.0.1";
1303
+ if (host === "::") return "[::1]";
1304
+ return net.isIPv6(host) ? `[${host}]` : host;
1163
1305
  }
1164
1306
 
1165
1307
  // src/codex/hub-target.ts
@@ -1175,56 +1317,280 @@ function resolveAdapterHub(config2, env = process.env) {
1175
1317
  };
1176
1318
  }
1177
1319
 
1178
- // src/codex/main.ts
1179
- function isRunning(pid) {
1320
+ // src/codex/instance-lock.ts
1321
+ import crypto from "crypto";
1322
+ import fs6 from "fs";
1323
+ import net2 from "net";
1324
+ import path5 from "path";
1325
+ var CONTROL_PROTOCOL = 1;
1326
+ var STALE_GUARD_MS = 1e4;
1327
+ var STOP_PROOF_LABEL = "claude-alarm codex stop";
1328
+ var MAX_LINE_BYTES = 4096;
1329
+ var REQUEST_IDLE_MS = 2e3;
1330
+ var MAX_SOCKET_PATH_BYTES = 103;
1331
+ function stopProof(token) {
1332
+ return crypto.createHmac("sha256", token).update(STOP_PROOF_LABEL).digest("hex");
1333
+ }
1334
+ function controlEndpoint(configDir, platform = process.platform) {
1335
+ if (platform !== "win32") return path5.posix.join(configDir, "codex.sock");
1336
+ let dir;
1180
1337
  try {
1181
- process.kill(pid, 0);
1182
- return true;
1338
+ dir = fs6.realpathSync.native(configDir);
1183
1339
  } catch {
1184
- return false;
1340
+ dir = path5.win32.resolve(configDir);
1185
1341
  }
1342
+ const hash = crypto.createHash("sha256").update(dir.toLowerCase()).digest("hex").slice(0, 16);
1343
+ return `\\\\.\\pipe\\claude-alarm-codex-${hash}`;
1186
1344
  }
1345
+ function ask(endpoint2, request, timeoutMs) {
1346
+ return new Promise((resolve) => {
1347
+ let buffer = "";
1348
+ let settled = false;
1349
+ const socket = net2.connect(endpoint2);
1350
+ const finish = (answer) => {
1351
+ if (settled) return;
1352
+ settled = true;
1353
+ clearTimeout(timer);
1354
+ socket.destroy();
1355
+ resolve(answer);
1356
+ };
1357
+ const timer = setTimeout(() => finish({ state: "unknown", error: `no answer within ${timeoutMs}ms` }), timeoutMs);
1358
+ socket.setEncoding("utf8");
1359
+ socket.on("connect", () => socket.write(`${JSON.stringify(request)}
1360
+ `));
1361
+ socket.on("data", (chunk) => {
1362
+ buffer += chunk;
1363
+ const end = buffer.indexOf("\n");
1364
+ if (end < 0) {
1365
+ if (Buffer.byteLength(buffer) > MAX_LINE_BYTES) finish({ state: "unknown", error: "reply too long" });
1366
+ return;
1367
+ }
1368
+ try {
1369
+ finish({ reply: JSON.parse(buffer.slice(0, end)) });
1370
+ } catch {
1371
+ finish({ state: "unknown", error: "unreadable reply" });
1372
+ }
1373
+ });
1374
+ socket.on("end", () => finish({ state: "unknown", error: "closed without a reply" }));
1375
+ socket.on("error", (err) => {
1376
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") finish({ state: "absent" });
1377
+ else finish({ state: "unknown", error: err.code ?? err.message });
1378
+ });
1379
+ });
1380
+ }
1381
+ async function queryOwner(endpoint2, timeoutMs = 1e3) {
1382
+ const answer = await ask(endpoint2, { type: "status" }, timeoutMs);
1383
+ if (!("reply" in answer)) return answer;
1384
+ const reply = answer.reply;
1385
+ if (reply?.type === "status" && reply.protocol === CONTROL_PROTOCOL && Number.isInteger(reply.pid)) {
1386
+ return { state: "running", pid: reply.pid };
1387
+ }
1388
+ return { state: "unknown", error: "unexpected reply" };
1389
+ }
1390
+ var line = (message) => `${JSON.stringify(message)}
1391
+ `;
1392
+ function controlServer(owner) {
1393
+ const expectedProof = Buffer.from(stopProof(owner.token));
1394
+ return net2.createServer((socket) => {
1395
+ let buffer = "";
1396
+ socket.setEncoding("utf8");
1397
+ socket.on("error", () => {
1398
+ });
1399
+ const idle = setTimeout(() => socket.destroy(), REQUEST_IDLE_MS);
1400
+ socket.on("close", () => clearTimeout(idle));
1401
+ const onData = (chunk) => {
1402
+ buffer += chunk;
1403
+ const end = buffer.indexOf("\n");
1404
+ if (end < 0) {
1405
+ if (Buffer.byteLength(buffer) > MAX_LINE_BYTES) socket.destroy();
1406
+ return;
1407
+ }
1408
+ socket.off("data", onData);
1409
+ clearTimeout(idle);
1410
+ let request = null;
1411
+ try {
1412
+ request = JSON.parse(buffer.slice(0, end));
1413
+ } catch {
1414
+ }
1415
+ if (request?.type === "status") {
1416
+ socket.end(line({ type: "status", protocol: CONTROL_PROTOCOL, pid: owner.pid }));
1417
+ } else if (request?.type === "stop") {
1418
+ const proof = typeof request.proof === "string" ? Buffer.from(request.proof) : void 0;
1419
+ if (owner.token !== "" && proof && proof.length === expectedProof.length && crypto.timingSafeEqual(proof, expectedProof)) {
1420
+ socket.end(line({ type: "stopping", pid: owner.pid }), () => owner.onStop());
1421
+ } else {
1422
+ socket.end(line({ type: "error", error: "unauthorized" }));
1423
+ }
1424
+ } else {
1425
+ socket.end(line({ type: "error", error: "unknown request" }));
1426
+ }
1427
+ };
1428
+ socket.on("data", onData);
1429
+ });
1430
+ }
1431
+ function listenControl(endpoint2, owner) {
1432
+ const server = controlServer(owner);
1433
+ return new Promise((resolve) => {
1434
+ const onError = (err) => {
1435
+ resolve(err.code === "EADDRINUSE" ? { kind: "busy" } : { kind: "unknown", error: err.code ?? err.message });
1436
+ };
1437
+ server.once("error", onError);
1438
+ server.listen(endpoint2, () => {
1439
+ server.off("error", onError);
1440
+ server.on("error", () => {
1441
+ });
1442
+ resolve({ kind: "owner", close: () => new Promise((done) => server.close(() => done())) });
1443
+ });
1444
+ });
1445
+ }
1446
+ function settle(attempt, busy) {
1447
+ return attempt.kind === "busy" ? { kind: "unknown", error: busy } : attempt;
1448
+ }
1449
+ async function acquireLock(endpoint2, owner, deps = {}) {
1450
+ const platform = deps.platform ?? process.platform;
1451
+ const listen = deps.listen ?? listenControl;
1452
+ const query = deps.queryOwner ?? ((e) => queryOwner(e));
1453
+ if (platform !== "win32" && Buffer.byteLength(endpoint2) > MAX_SOCKET_PATH_BYTES) {
1454
+ return { kind: "unknown", error: `control socket path is too long: ${endpoint2}` };
1455
+ }
1456
+ const first = await listen(endpoint2, owner);
1457
+ if (first.kind !== "busy") return first;
1458
+ const state = await query(endpoint2);
1459
+ if (state.state === "running") return { kind: "held", pid: state.pid };
1460
+ if (state.state === "unknown") return { kind: "unknown", error: state.error };
1461
+ if (platform === "win32") return settle(await listen(endpoint2, owner), "the control pipe is in use but does not answer");
1462
+ return recoverSocket(endpoint2, owner, listen, query, deps.fs ?? fs6, deps.now ?? Date.now);
1463
+ }
1464
+ async function recoverSocket(endpoint2, owner, listen, query, f, now) {
1465
+ const guard = `${endpoint2}.lock`;
1466
+ const blocked = takeGuard(guard, f, now);
1467
+ if (blocked) return { kind: "unknown", error: blocked };
1468
+ try {
1469
+ const state = await query(endpoint2);
1470
+ if (state.state === "running") return { kind: "held", pid: state.pid };
1471
+ if (state.state === "unknown") return { kind: "unknown", error: state.error };
1472
+ try {
1473
+ f.unlinkSync(endpoint2);
1474
+ } catch (err) {
1475
+ if (err.code !== "ENOENT") return { kind: "unknown", error: err.message };
1476
+ }
1477
+ return settle(await listen(endpoint2, owner), "the control socket is in use but does not answer");
1478
+ } finally {
1479
+ try {
1480
+ f.rmdirSync(guard);
1481
+ } catch {
1482
+ }
1483
+ }
1484
+ }
1485
+ function takeGuard(guard, f, now) {
1486
+ for (let attempt = 0; attempt < 2; attempt++) {
1487
+ try {
1488
+ f.mkdirSync(guard);
1489
+ return void 0;
1490
+ } catch (err) {
1491
+ if (err.code !== "EEXIST") return err.message;
1492
+ }
1493
+ let age;
1494
+ try {
1495
+ age = now() - f.statSync(guard).mtimeMs;
1496
+ } catch {
1497
+ continue;
1498
+ }
1499
+ if (age <= STALE_GUARD_MS) return "another Codex adapter is starting";
1500
+ try {
1501
+ f.rmdirSync(guard);
1502
+ } catch {
1503
+ }
1504
+ }
1505
+ return "another Codex adapter is starting";
1506
+ }
1507
+
1508
+ // src/codex/main.ts
1509
+ var WAIT_MIN_MS = 2e3;
1510
+ var WAIT_MAX_MS = 3e4;
1187
1511
  installCrashGuard("codex adapter");
1188
1512
  process.stdout.on("error", () => {
1189
1513
  });
1190
1514
  process.stderr.on("error", () => {
1191
1515
  });
1192
1516
  var config = loadConfig();
1193
- var existing = fs6.existsSync(CODEX_PID_FILE) ? parseInt(fs6.readFileSync(CODEX_PID_FILE, "utf-8").trim(), 10) : NaN;
1194
- if (!Number.isNaN(existing) && existing !== process.pid && isRunning(existing)) {
1195
- logger.info(`Codex adapter already running (PID: ${existing})`);
1196
- process.exit(0);
1197
- }
1198
- fs6.writeFileSync(CODEX_PID_FILE, String(process.pid), "utf-8");
1199
- logStartup("Codex adapter");
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
- });
1211
- adapter.start();
1517
+ var supervised = process.argv.includes("--watch-stdin");
1518
+ var endpoint = controlEndpoint(CONFIG_DIR);
1519
+ var adapter;
1520
+ var release;
1212
1521
  var exiting = false;
1213
1522
  var shutdown = () => {
1214
1523
  if (exiting) return;
1215
1524
  exiting = true;
1216
- adapter.stop();
1217
- try {
1218
- if (fs6.readFileSync(CODEX_PID_FILE, "utf-8").trim() === String(process.pid)) fs6.unlinkSync(CODEX_PID_FILE);
1219
- } catch {
1220
- }
1221
- process.exit(0);
1525
+ const stopped = adapter?.stop();
1526
+ const exit = () => process.exit(0);
1527
+ setTimeout(exit, 3e3).unref();
1528
+ Promise.all([stopped, release?.()]).then(exit, exit);
1222
1529
  };
1223
1530
  process.on("SIGINT", shutdown);
1224
1531
  process.on("SIGTERM", shutdown);
1225
- if (process.argv.includes("--watch-stdin")) {
1532
+ if (supervised) {
1226
1533
  process.stdin.on("end", shutdown);
1227
1534
  process.stdin.on("close", shutdown);
1228
1535
  process.stdin.resume();
1229
1536
  }
1537
+ function send(message, then) {
1538
+ if (!process.send || !process.connected) {
1539
+ then?.();
1540
+ return;
1541
+ }
1542
+ process.send(message, void 0, void 0, (err) => {
1543
+ if (err) logger.debug(`Report to the parent not delivered: ${err.message}`);
1544
+ then?.();
1545
+ });
1546
+ }
1547
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1548
+ async function takeLock() {
1549
+ let delay = WAIT_MIN_MS;
1550
+ let waitReason;
1551
+ for (; ; ) {
1552
+ const lock = await acquireLock(endpoint, {
1553
+ pid: process.pid,
1554
+ token: config.hub.token ?? "",
1555
+ onStop: () => {
1556
+ if (supervised) logger.warn("Stopped by claude-alarm codex stop; the hub will not start the Codex adapter again until the hub restarts");
1557
+ shutdown();
1558
+ }
1559
+ });
1560
+ if (lock.kind === "owner") {
1561
+ release = lock.close;
1562
+ if (waitReason !== void 0) logger.info("Codex adapter took over: the other adapter has stopped");
1563
+ return true;
1564
+ }
1565
+ if (!supervised) {
1566
+ if (lock.kind === "held") {
1567
+ logger.info(`Codex adapter already running (PID: ${lock.pid})`);
1568
+ send({ type: "codex-already-running", pid: lock.pid }, () => process.exit(0));
1569
+ } else {
1570
+ logger.error(`Codex adapter cannot start: ${lock.error}`);
1571
+ send({ type: "codex-lock-failed", error: lock.error }, () => process.exit(1));
1572
+ }
1573
+ return false;
1574
+ }
1575
+ const reason = lock.kind === "held" ? `held:${lock.pid}` : `unknown:${lock.error}`;
1576
+ if (reason !== waitReason) {
1577
+ waitReason = reason;
1578
+ if (lock.kind === "held") logger.info(`Another Codex adapter is running (PID: ${lock.pid}); this one takes over when it stops`);
1579
+ else logger.warn(`Codex adapter control endpoint is unavailable (${lock.error}); retrying`);
1580
+ }
1581
+ await sleep(delay);
1582
+ delay = Math.min(delay * 2, WAIT_MAX_MS);
1583
+ }
1584
+ }
1585
+ void takeLock().then((owned) => {
1586
+ if (!owned) return;
1587
+ logStartup("Codex adapter");
1588
+ const { host, port, token } = resolveAdapterHub(config);
1589
+ adapter = new CodexAdapter({
1590
+ command: config.codex?.command ?? "codex",
1591
+ hub: { host, port, token },
1592
+ onFirstConnect: (outcome) => send({ type: "codex-first-connect", ...outcome })
1593
+ });
1594
+ adapter.start();
1595
+ });
1230
1596
  //# sourceMappingURL=main.js.map