@delt/claude-alarm 1.2.0 → 1.3.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
  }
@@ -366,7 +363,8 @@ 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
  }
@@ -394,7 +392,8 @@ var defaultSpawn = (command, args) => {
394
392
  if (shell) return spawn(`"${file}" ${args.join(" ")}`, { stdio: "pipe", windowsHide: true, shell: true });
395
393
  return spawn(file, args, { stdio: "pipe", windowsHide: true });
396
394
  };
397
- function connectProxy(command, spawnFn = defaultSpawn) {
395
+ var HANDSHAKE_TIMEOUT_MS = 1e4;
396
+ function connectProxy(command, spawnFn = defaultSpawn, timeoutMs = HANDSHAKE_TIMEOUT_MS) {
398
397
  const child = spawnFn(command, ["app-server", "proxy"]);
399
398
  child.stdin?.on("error", () => {
400
399
  });
@@ -428,9 +427,11 @@ function connectProxy(command, spawnFn = defaultSpawn) {
428
427
  };
429
428
  return new Promise((resolve, reject) => {
430
429
  let settled = false;
430
+ const timer = setTimeout(() => fail(new Error(`codex daemon did not answer within ${timeoutMs / 1e3}s`)), timeoutMs);
431
431
  const fail = (err) => {
432
432
  if (settled) return;
433
433
  settled = true;
434
+ clearTimeout(timer);
434
435
  close();
435
436
  reject(err);
436
437
  };
@@ -440,6 +441,7 @@ function connectProxy(command, spawnFn = defaultSpawn) {
440
441
  ws.once("open", () => {
441
442
  if (settled) return;
442
443
  settled = true;
444
+ clearTimeout(timer);
443
445
  child.on("error", (err) => logger.warn(`codex proxy error: ${err.message}`));
444
446
  ws.on("error", (err) => logger.warn(`codex daemon socket error: ${err.message}`));
445
447
  resolve({ ws, close });
@@ -749,10 +751,10 @@ var CodexAdapter = class {
749
751
  noticeNotFound() {
750
752
  if (this.notFoundNotice !== "unsent") return;
751
753
  this.notFoundNotice = "sending";
752
- const { host: host2, port: port2, token: token2 } = this.opts.hub;
753
- fetch(`http://${host2}:${port2}/api/notify`, {
754
+ const { host, port, token } = this.opts.hub;
755
+ fetch(`http://${host}:${port}/api/notify`, {
754
756
  method: "POST",
755
- headers: { "Content-Type": "application/json", ...token2 ? { Authorization: `Bearer ${token2}` } : {} },
757
+ headers: { "Content-Type": "application/json", ...token ? { Authorization: `Bearer ${token}` } : {} },
756
758
  body: JSON.stringify({
757
759
  title: "Codex not found",
758
760
  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 +833,18 @@ var CodexAdapter = class {
831
833
  this.drop(thread.id);
832
834
  return;
833
835
  }
834
- const existing2 = this.threads.get(thread.id);
835
- if (existing2) {
836
- existing2.thread = thread;
837
- existing2.hub.reregister();
836
+ const existing = this.threads.get(thread.id);
837
+ if (existing) {
838
+ existing.thread = thread;
839
+ existing.hub.reregister();
838
840
  } else {
839
- const { host: host2, port: port2, token: token2 } = this.opts.hub;
841
+ const { host, port, token } = this.opts.hub;
840
842
  const hub = new HubClient(
841
843
  codexSessionId(thread.id),
842
844
  threadTitle(thread),
843
- host2,
844
- port2,
845
- token2,
845
+ host,
846
+ port,
847
+ token,
846
848
  () => void 0,
847
849
  () => this.registration(thread.id)
848
850
  );
@@ -890,6 +892,7 @@ var CodexAdapter = class {
890
892
  }
891
893
  } catch (err) {
892
894
  logger.debug(`subscription sync for ${threadId} deferred: ${err.message}`);
895
+ if (t.wantSubscribed && !t.subscribed && t.thread.status.type === "active") t.unrelayed = true;
893
896
  }
894
897
  }
895
898
  releaseLater(t) {
@@ -1108,7 +1111,7 @@ var CodexAdapter = class {
1108
1111
  }
1109
1112
  if (!t || !view) {
1110
1113
  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");
1114
+ this.waitingInCodex(t.thread.id);
1112
1115
  } else {
1113
1116
  logger.debug(`Ignoring Codex server request ${method}`);
1114
1117
  }
@@ -1118,17 +1121,26 @@ var CodexAdapter = class {
1118
1121
  if (a.threadId === t.thread.id && a.rpcId === rpcId) return;
1119
1122
  }
1120
1123
  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
- });
1124
+ try {
1125
+ this.approvals.set(requestId, { threadId: t.thread.id, rpcId, turnId: params.turnId, choices: view.choices, answered: false });
1126
+ t.hub.send({
1127
+ type: "permission_request",
1128
+ sessionId: codexSessionId(t.thread.id),
1129
+ requestId,
1130
+ toolName: view.toolName,
1131
+ description: view.description,
1132
+ inputPreview: view.inputPreview,
1133
+ timestamp: Date.now(),
1134
+ choices: view.choices.map((c, i) => ({ id: String(i), label: c.label }))
1135
+ });
1136
+ } catch (err) {
1137
+ this.approvals.delete(requestId);
1138
+ logger.warn(`Codex ${method} could not be relayed: ${err.message}`);
1139
+ this.waitingInCodex(t.thread.id);
1140
+ }
1141
+ }
1142
+ waitingInCodex(threadId) {
1143
+ this.notify(threadId, "Codex is waiting", "Codex asked for input that claude-alarm cannot relay. Handle it in Codex.", "warning");
1132
1144
  }
1133
1145
  answer(threadId, requestId, choiceId) {
1134
1146
  const a = this.approvals.get(requestId);
@@ -1156,10 +1168,10 @@ var CodexAdapter = class {
1156
1168
 
1157
1169
  // src/shared/hub-url.ts
1158
1170
  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;
1171
+ function hubUrlHost(host) {
1172
+ if (host === "" || host === "0.0.0.0") return "127.0.0.1";
1173
+ if (host === "::") return "[::1]";
1174
+ return net.isIPv6(host) ? `[${host}]` : host;
1163
1175
  }
1164
1176
 
1165
1177
  // src/codex/hub-target.ts
@@ -1175,56 +1187,281 @@ function resolveAdapterHub(config2, env = process.env) {
1175
1187
  };
1176
1188
  }
1177
1189
 
1178
- // src/codex/main.ts
1179
- function isRunning(pid) {
1190
+ // src/codex/instance-lock.ts
1191
+ import crypto from "crypto";
1192
+ import fs6 from "fs";
1193
+ import net2 from "net";
1194
+ import path5 from "path";
1195
+ var CONTROL_PROTOCOL = 1;
1196
+ var STALE_GUARD_MS = 1e4;
1197
+ var STOP_PROOF_LABEL = "claude-alarm codex stop";
1198
+ var MAX_LINE_BYTES = 4096;
1199
+ var REQUEST_IDLE_MS = 2e3;
1200
+ var MAX_SOCKET_PATH_BYTES = 103;
1201
+ function stopProof(token) {
1202
+ return crypto.createHmac("sha256", token).update(STOP_PROOF_LABEL).digest("hex");
1203
+ }
1204
+ function controlEndpoint(configDir, platform = process.platform) {
1205
+ if (platform !== "win32") return path5.posix.join(configDir, "codex.sock");
1206
+ let dir;
1180
1207
  try {
1181
- process.kill(pid, 0);
1182
- return true;
1208
+ dir = fs6.realpathSync.native(configDir);
1183
1209
  } catch {
1184
- return false;
1210
+ dir = path5.win32.resolve(configDir);
1211
+ }
1212
+ const hash = crypto.createHash("sha256").update(dir.toLowerCase()).digest("hex").slice(0, 16);
1213
+ return `\\\\.\\pipe\\claude-alarm-codex-${hash}`;
1214
+ }
1215
+ function ask(endpoint2, request, timeoutMs) {
1216
+ return new Promise((resolve) => {
1217
+ let buffer = "";
1218
+ let settled = false;
1219
+ const socket = net2.connect(endpoint2);
1220
+ const finish = (answer) => {
1221
+ if (settled) return;
1222
+ settled = true;
1223
+ clearTimeout(timer);
1224
+ socket.destroy();
1225
+ resolve(answer);
1226
+ };
1227
+ const timer = setTimeout(() => finish({ state: "unknown", error: `no answer within ${timeoutMs}ms` }), timeoutMs);
1228
+ socket.setEncoding("utf8");
1229
+ socket.on("connect", () => socket.write(`${JSON.stringify(request)}
1230
+ `));
1231
+ socket.on("data", (chunk) => {
1232
+ buffer += chunk;
1233
+ const end = buffer.indexOf("\n");
1234
+ if (end < 0) {
1235
+ if (Buffer.byteLength(buffer) > MAX_LINE_BYTES) finish({ state: "unknown", error: "reply too long" });
1236
+ return;
1237
+ }
1238
+ try {
1239
+ finish({ reply: JSON.parse(buffer.slice(0, end)) });
1240
+ } catch {
1241
+ finish({ state: "unknown", error: "unreadable reply" });
1242
+ }
1243
+ });
1244
+ socket.on("end", () => finish({ state: "unknown", error: "closed without a reply" }));
1245
+ socket.on("error", (err) => {
1246
+ if (err.code === "ENOENT" || err.code === "ECONNREFUSED") finish({ state: "absent" });
1247
+ else finish({ state: "unknown", error: err.code ?? err.message });
1248
+ });
1249
+ });
1250
+ }
1251
+ async function queryOwner(endpoint2, timeoutMs = 1e3) {
1252
+ const answer = await ask(endpoint2, { type: "status" }, timeoutMs);
1253
+ if (!("reply" in answer)) return answer;
1254
+ const reply = answer.reply;
1255
+ if (reply?.type === "status" && reply.protocol === CONTROL_PROTOCOL && Number.isInteger(reply.pid)) {
1256
+ return { state: "running", pid: reply.pid };
1257
+ }
1258
+ return { state: "unknown", error: "unexpected reply" };
1259
+ }
1260
+ var line = (message) => `${JSON.stringify(message)}
1261
+ `;
1262
+ function controlServer(owner) {
1263
+ const expectedProof = Buffer.from(stopProof(owner.token));
1264
+ return net2.createServer((socket) => {
1265
+ let buffer = "";
1266
+ socket.setEncoding("utf8");
1267
+ socket.on("error", () => {
1268
+ });
1269
+ const idle = setTimeout(() => socket.destroy(), REQUEST_IDLE_MS);
1270
+ socket.on("close", () => clearTimeout(idle));
1271
+ const onData = (chunk) => {
1272
+ buffer += chunk;
1273
+ const end = buffer.indexOf("\n");
1274
+ if (end < 0) {
1275
+ if (Buffer.byteLength(buffer) > MAX_LINE_BYTES) socket.destroy();
1276
+ return;
1277
+ }
1278
+ socket.off("data", onData);
1279
+ clearTimeout(idle);
1280
+ let request = null;
1281
+ try {
1282
+ request = JSON.parse(buffer.slice(0, end));
1283
+ } catch {
1284
+ }
1285
+ if (request?.type === "status") {
1286
+ socket.end(line({ type: "status", protocol: CONTROL_PROTOCOL, pid: owner.pid }));
1287
+ } else if (request?.type === "stop") {
1288
+ const proof = typeof request.proof === "string" ? Buffer.from(request.proof) : void 0;
1289
+ if (owner.token !== "" && proof && proof.length === expectedProof.length && crypto.timingSafeEqual(proof, expectedProof)) {
1290
+ socket.end(line({ type: "stopping", pid: owner.pid }), () => owner.onStop());
1291
+ } else {
1292
+ socket.end(line({ type: "error", error: "unauthorized" }));
1293
+ }
1294
+ } else {
1295
+ socket.end(line({ type: "error", error: "unknown request" }));
1296
+ }
1297
+ };
1298
+ socket.on("data", onData);
1299
+ });
1300
+ }
1301
+ function listenControl(endpoint2, owner) {
1302
+ const server = controlServer(owner);
1303
+ return new Promise((resolve) => {
1304
+ const onError = (err) => {
1305
+ resolve(err.code === "EADDRINUSE" ? { kind: "busy" } : { kind: "unknown", error: err.code ?? err.message });
1306
+ };
1307
+ server.once("error", onError);
1308
+ server.listen(endpoint2, () => {
1309
+ server.off("error", onError);
1310
+ server.on("error", () => {
1311
+ });
1312
+ resolve({ kind: "owner", close: () => new Promise((done) => server.close(() => done())) });
1313
+ });
1314
+ });
1315
+ }
1316
+ function settle(attempt, busy) {
1317
+ return attempt.kind === "busy" ? { kind: "unknown", error: busy } : attempt;
1318
+ }
1319
+ async function acquireLock(endpoint2, owner, deps = {}) {
1320
+ const platform = deps.platform ?? process.platform;
1321
+ const listen = deps.listen ?? listenControl;
1322
+ const query = deps.queryOwner ?? ((e) => queryOwner(e));
1323
+ if (platform !== "win32" && Buffer.byteLength(endpoint2) > MAX_SOCKET_PATH_BYTES) {
1324
+ return { kind: "unknown", error: `control socket path is too long: ${endpoint2}` };
1325
+ }
1326
+ const first = await listen(endpoint2, owner);
1327
+ if (first.kind !== "busy") return first;
1328
+ const state = await query(endpoint2);
1329
+ if (state.state === "running") return { kind: "held", pid: state.pid };
1330
+ if (state.state === "unknown") return { kind: "unknown", error: state.error };
1331
+ if (platform === "win32") return settle(await listen(endpoint2, owner), "the control pipe is in use but does not answer");
1332
+ return recoverSocket(endpoint2, owner, listen, query, deps.fs ?? fs6, deps.now ?? Date.now);
1333
+ }
1334
+ async function recoverSocket(endpoint2, owner, listen, query, f, now) {
1335
+ const guard = `${endpoint2}.lock`;
1336
+ const blocked = takeGuard(guard, f, now);
1337
+ if (blocked) return { kind: "unknown", error: blocked };
1338
+ try {
1339
+ const state = await query(endpoint2);
1340
+ if (state.state === "running") return { kind: "held", pid: state.pid };
1341
+ if (state.state === "unknown") return { kind: "unknown", error: state.error };
1342
+ try {
1343
+ f.unlinkSync(endpoint2);
1344
+ } catch (err) {
1345
+ if (err.code !== "ENOENT") return { kind: "unknown", error: err.message };
1346
+ }
1347
+ return settle(await listen(endpoint2, owner), "the control socket is in use but does not answer");
1348
+ } finally {
1349
+ try {
1350
+ f.rmdirSync(guard);
1351
+ } catch {
1352
+ }
1353
+ }
1354
+ }
1355
+ function takeGuard(guard, f, now) {
1356
+ for (let attempt = 0; attempt < 2; attempt++) {
1357
+ try {
1358
+ f.mkdirSync(guard);
1359
+ return void 0;
1360
+ } catch (err) {
1361
+ if (err.code !== "EEXIST") return err.message;
1362
+ }
1363
+ let age;
1364
+ try {
1365
+ age = now() - f.statSync(guard).mtimeMs;
1366
+ } catch {
1367
+ continue;
1368
+ }
1369
+ if (age <= STALE_GUARD_MS) return "another Codex adapter is starting";
1370
+ try {
1371
+ f.rmdirSync(guard);
1372
+ } catch {
1373
+ }
1185
1374
  }
1375
+ return "another Codex adapter is starting";
1186
1376
  }
1377
+
1378
+ // src/codex/main.ts
1379
+ var WAIT_MIN_MS = 2e3;
1380
+ var WAIT_MAX_MS = 3e4;
1187
1381
  installCrashGuard("codex adapter");
1188
1382
  process.stdout.on("error", () => {
1189
1383
  });
1190
1384
  process.stderr.on("error", () => {
1191
1385
  });
1192
1386
  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();
1387
+ var supervised = process.argv.includes("--watch-stdin");
1388
+ var endpoint = controlEndpoint(CONFIG_DIR);
1389
+ var adapter;
1390
+ var release;
1212
1391
  var exiting = false;
1213
1392
  var shutdown = () => {
1214
1393
  if (exiting) return;
1215
1394
  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);
1395
+ adapter?.stop();
1396
+ const exit = () => process.exit(0);
1397
+ setTimeout(exit, 3e3).unref();
1398
+ if (release) release().then(exit, exit);
1399
+ else exit();
1222
1400
  };
1223
1401
  process.on("SIGINT", shutdown);
1224
1402
  process.on("SIGTERM", shutdown);
1225
- if (process.argv.includes("--watch-stdin")) {
1403
+ if (supervised) {
1226
1404
  process.stdin.on("end", shutdown);
1227
1405
  process.stdin.on("close", shutdown);
1228
1406
  process.stdin.resume();
1229
1407
  }
1408
+ function send(message, then) {
1409
+ if (!process.send || !process.connected) {
1410
+ then?.();
1411
+ return;
1412
+ }
1413
+ process.send(message, void 0, void 0, (err) => {
1414
+ if (err) logger.debug(`Report to the parent not delivered: ${err.message}`);
1415
+ then?.();
1416
+ });
1417
+ }
1418
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1419
+ async function takeLock() {
1420
+ let delay = WAIT_MIN_MS;
1421
+ let waitReason;
1422
+ for (; ; ) {
1423
+ const lock = await acquireLock(endpoint, {
1424
+ pid: process.pid,
1425
+ token: config.hub.token ?? "",
1426
+ onStop: () => {
1427
+ if (supervised) logger.warn("Stopped by claude-alarm codex stop; the hub will not start the Codex adapter again until the hub restarts");
1428
+ shutdown();
1429
+ }
1430
+ });
1431
+ if (lock.kind === "owner") {
1432
+ release = lock.close;
1433
+ if (waitReason !== void 0) logger.info("Codex adapter took over: the other adapter has stopped");
1434
+ return true;
1435
+ }
1436
+ if (!supervised) {
1437
+ if (lock.kind === "held") {
1438
+ logger.info(`Codex adapter already running (PID: ${lock.pid})`);
1439
+ send({ type: "codex-already-running", pid: lock.pid }, () => process.exit(0));
1440
+ } else {
1441
+ logger.error(`Codex adapter cannot start: ${lock.error}`);
1442
+ send({ type: "codex-lock-failed", error: lock.error }, () => process.exit(1));
1443
+ }
1444
+ return false;
1445
+ }
1446
+ const reason = lock.kind === "held" ? `held:${lock.pid}` : `unknown:${lock.error}`;
1447
+ if (reason !== waitReason) {
1448
+ waitReason = reason;
1449
+ if (lock.kind === "held") logger.info(`Another Codex adapter is running (PID: ${lock.pid}); this one takes over when it stops`);
1450
+ else logger.warn(`Codex adapter control endpoint is unavailable (${lock.error}); retrying`);
1451
+ }
1452
+ await sleep(delay);
1453
+ delay = Math.min(delay * 2, WAIT_MAX_MS);
1454
+ }
1455
+ }
1456
+ void takeLock().then((owned) => {
1457
+ if (!owned) return;
1458
+ logStartup("Codex adapter");
1459
+ const { host, port, token } = resolveAdapterHub(config);
1460
+ adapter = new CodexAdapter({
1461
+ command: config.codex?.command ?? "codex",
1462
+ hub: { host, port, token },
1463
+ onFirstConnect: (outcome) => send({ type: "codex-first-connect", ...outcome })
1464
+ });
1465
+ adapter.start();
1466
+ });
1230
1467
  //# sourceMappingURL=main.js.map