@delt/claude-alarm 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -357,7 +357,7 @@ var HubClient = class {
357
357
  };
358
358
 
359
359
  // src/codex/transport.ts
360
- import { spawn } from "child_process";
360
+ import { execFile, spawn } from "child_process";
361
361
  import fs3 from "fs";
362
362
  import path3 from "path";
363
363
  import { Duplex } from "stream";
@@ -387,14 +387,144 @@ function findCodex(command, platform = process.platform, env = process.env) {
387
387
  ];
388
388
  return candidates.find((file) => !!file && fs3.existsSync(file));
389
389
  }
390
+ var shellSpawns = /* @__PURE__ */ new WeakSet();
391
+ var spawnedThroughShell = (child) => shellSpawns.has(child);
390
392
  var defaultSpawn = (command, args) => {
391
393
  const { file, shell } = resolveCommand(command);
392
- if (shell) return spawn(`"${file}" ${args.join(" ")}`, { stdio: "pipe", windowsHide: true, shell: true });
393
- 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;
394
398
  };
399
+ var POWERSHELL_TIMEOUT_MS = 5e3;
400
+ var powershell = (script, env) => new Promise((resolve, reject) => {
401
+ execFile("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, timeout: POWERSHELL_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, env }, (err, stdout) => {
402
+ if (err) reject(err);
403
+ else resolve(stdout);
404
+ });
405
+ });
406
+ var QUERY_SCRIPT = "@(Get-CimInstance Win32_Process | Where-Object CreationDate | Select-Object @{ n = 'pid'; e = { [int]$_.ProcessId } }, @{ n = 'parentPid'; e = { [int]$_.ParentProcessId } }, @{ n = 'creationTime'; e = { $_.CreationDate.ToUniversalTime().ToString('o') } }) | ConvertTo-Json -Compress";
407
+ var processQuery = (prelude = "") => async () => {
408
+ const rows = JSON.parse(await powershell(prelude + QUERY_SCRIPT));
409
+ if (!Array.isArray(rows) || rows.some((row) => !Number.isInteger(row.pid) || !Number.isInteger(row.parentPid) || typeof row.creationTime !== "string")) {
410
+ throw new Error("invalid process snapshot");
411
+ }
412
+ return rows;
413
+ };
414
+ var queryProcesses = processQuery();
415
+ var END_SCRIPT = [
416
+ "foreach ($t in ($env:CLAUDE_ALARM_END_TARGETS | ConvertFrom-Json)) {",
417
+ " $p = Get-Process -Id $t.pid -ErrorAction SilentlyContinue",
418
+ // Reading Handle opens the process once, and StartTime, Stop-Process and Wait-Process then use that handle; a getter that throws reads as $null.
419
+ ' if ($null -eq $p -or $null -eq $p.Handle) { "$($t.pid) is gone or cannot be opened"; continue }',
420
+ " $start = $p.StartTime",
421
+ ' if ($null -eq $start) { "$($t.pid) has no readable creation time"; continue }',
422
+ " $start = $start.ToUniversalTime()",
423
+ // Win32_Process creation dates stop at microseconds.
424
+ ` if ($start.AddTicks(-($start.Ticks % 10)).ToString('o') -ne $t.creationTime) { "$($t.pid) now belongs to another process"; continue }`,
425
+ ' try { Stop-Process -InputObject $p -Force -ErrorAction Stop } catch { "$($t.pid) not ended: $($_.FullyQualifiedErrorId)" }',
426
+ // A refused termination usually means the process is already exiting, so it is waited for all the same.
427
+ ' try { Wait-Process -InputObject $p -Timeout 1 -ErrorAction Stop } catch { "$($t.pid) has not exited yet" }',
428
+ "}"
429
+ ].join("\n");
430
+ var processEnder = (prelude = "") => async (targets) => {
431
+ await Promise.all(targets.map(async (target) => {
432
+ try {
433
+ const notes = await powershell(prelude + END_SCRIPT, { ...process.env, CLAUDE_ALARM_END_TARGETS: JSON.stringify([target]) });
434
+ for (const note of notes.split(/\r?\n/)) if (note) logger.debug(`codex process ${note}`);
435
+ } catch (err) {
436
+ logger.debug(`ending codex process ${target.pid} failed: ${err.killed ? "timed out" : String(err)}`);
437
+ }
438
+ }));
439
+ };
440
+ var endProcesses = processEnder();
441
+ function descendantsOf(rootPid, rows) {
442
+ const root = rows.find((row) => row.pid === rootPid);
443
+ if (!root) return void 0;
444
+ const found = [];
445
+ const seen = /* @__PURE__ */ new Set([root.pid]);
446
+ let level = [root];
447
+ for (let depth = 1; level.length; depth++) {
448
+ const next = [];
449
+ for (const parent of level) {
450
+ for (const row of rows) {
451
+ if (row.parentPid !== parent.pid || seen.has(row.pid) || row.creationTime < parent.creationTime) continue;
452
+ seen.add(row.pid);
453
+ next.push(row);
454
+ found.push({ ...row, depth });
455
+ }
456
+ }
457
+ level = next;
458
+ }
459
+ return found;
460
+ }
461
+ var exited = (child) => child.exitCode !== null || child.signalCode !== null;
462
+ var SHELL_EXIT_WAIT_MS = 1e3;
463
+ var PROXY_TREE_CLOSE_BUDGET_MS = 2 * POWERSHELL_TIMEOUT_MS + SHELL_EXIT_WAIT_MS;
464
+ var exitOf = (child) => new Promise((resolve) => {
465
+ if (exited(child)) {
466
+ resolve();
467
+ return;
468
+ }
469
+ const timer = setTimeout(() => {
470
+ logger.debug(`codex proxy shell ${child.pid} has not exited yet`);
471
+ resolve();
472
+ }, SHELL_EXIT_WAIT_MS);
473
+ child.once("exit", () => {
474
+ clearTimeout(timer);
475
+ resolve();
476
+ });
477
+ });
478
+ var merged = (before, found) => [...before, ...found.filter((d) => !before.some((b) => b.pid === d.pid && b.creationTime === d.creationTime))];
479
+ function treeKiller(platform = process.platform, end = endProcesses, query = queryProcesses, throughShell = spawnedThroughShell) {
480
+ const recorded = /* @__PURE__ */ new WeakMap();
481
+ const endByIdentity = async (descendants) => {
482
+ if (!descendants.length) return;
483
+ try {
484
+ await end([...descendants].sort((a, b) => b.depth - a.depth).map(({ depth, ...identity }) => identity));
485
+ } catch (err) {
486
+ logger.debug(`ending codex processes failed: ${String(err)}`);
487
+ }
488
+ };
489
+ const snapshot = async () => {
490
+ try {
491
+ return await query();
492
+ } catch (err) {
493
+ logger.debug(`codex process query failed: ${String(err)}`);
494
+ return void 0;
495
+ }
496
+ };
497
+ const isShellTree = (child) => platform === "win32" && child.pid !== void 0 && throughShell(child);
498
+ const killer = async (child) => {
499
+ if (!isShellTree(child)) {
500
+ if (!exited(child)) child.kill();
501
+ return;
502
+ }
503
+ const [rows, before] = await Promise.all([exited(child) ? void 0 : snapshot(), recorded.get(child)]);
504
+ const found = rows && !exited(child) ? descendantsOf(child.pid, rows) ?? [] : [];
505
+ await endByIdentity(merged(before ?? [], found));
506
+ if (exited(child)) return;
507
+ child.kill();
508
+ await exitOf(child);
509
+ };
510
+ killer.track = async (child) => {
511
+ if (!isShellTree(child) || exited(child)) return;
512
+ const earlier = recorded.get(child);
513
+ const latest = (async () => {
514
+ const rows = await snapshot();
515
+ const found = rows && !exited(child) ? descendantsOf(child.pid, rows) ?? [] : [];
516
+ return merged(await earlier ?? [], found);
517
+ })();
518
+ recorded.set(child, latest);
519
+ await latest;
520
+ };
521
+ return killer;
522
+ }
523
+ var killTree = treeKiller();
395
524
  var HANDSHAKE_TIMEOUT_MS = 1e4;
396
- function connectProxy(command, spawnFn = defaultSpawn, timeoutMs = HANDSHAKE_TIMEOUT_MS) {
525
+ function connectProxy(command, spawnFn = defaultSpawn, timeoutMs = HANDSHAKE_TIMEOUT_MS, killTreeFn = killTree) {
397
526
  const child = spawnFn(command, ["app-server", "proxy"]);
527
+ void killTreeFn.track?.(child);
398
528
  child.stdin?.on("error", () => {
399
529
  });
400
530
  child.stderr?.on("data", (d) => logger.debug(`codex proxy: ${String(d).trim()}`));
@@ -421,10 +551,12 @@ function connectProxy(command, spawnFn = defaultSpawn, timeoutMs = HANDSHAKE_TIM
421
551
  const ws = new WebSocket3("ws://localhost/", {
422
552
  createConnection: (() => stream)
423
553
  });
424
- const close = () => {
554
+ let closing;
555
+ const close = () => closing ??= (async () => {
425
556
  ws.terminate();
426
- child.kill();
427
- };
557
+ child.stdin?.end();
558
+ await killTreeFn(child);
559
+ })();
428
560
  return new Promise((resolve, reject) => {
429
561
  let settled = false;
430
562
  const timer = setTimeout(() => fail(new Error(`codex daemon did not answer within ${timeoutMs / 1e3}s`)), timeoutMs);
@@ -432,8 +564,7 @@ function connectProxy(command, spawnFn = defaultSpawn, timeoutMs = HANDSHAKE_TIM
432
564
  if (settled) return;
433
565
  settled = true;
434
566
  clearTimeout(timer);
435
- close();
436
- reject(err);
567
+ void close().then(() => reject(err), () => reject(err));
437
568
  };
438
569
  child.on("error", fail);
439
570
  child.once("exit", (code) => fail(new Error(`codex proxy exited (code ${code})`)));
@@ -444,6 +575,7 @@ function connectProxy(command, spawnFn = defaultSpawn, timeoutMs = HANDSHAKE_TIM
444
575
  clearTimeout(timer);
445
576
  child.on("error", (err) => logger.warn(`codex proxy error: ${err.message}`));
446
577
  ws.on("error", (err) => logger.warn(`codex daemon socket error: ${err.message}`));
578
+ void killTreeFn.track?.(child);
447
579
  resolve({ ws, close });
448
580
  });
449
581
  });
@@ -653,6 +785,8 @@ var CodexAdapter = class {
653
785
  this.link = new CodexHubLink(opts.hub, { id: randomUUID2(), host: this.hostName }, (call) => this.onCall(call), opts.linkReconnectMs);
654
786
  }
655
787
  conn;
788
+ connecting;
789
+ closing = /* @__PURE__ */ new Set();
656
790
  rpc;
657
791
  threads = /* @__PURE__ */ new Map();
658
792
  approvals = /* @__PURE__ */ new Map();
@@ -668,22 +802,29 @@ var CodexAdapter = class {
668
802
  start() {
669
803
  this.stopped = false;
670
804
  this.link.connect();
671
- void this.connect();
805
+ this.connecting = this.connect();
672
806
  }
673
- stop() {
807
+ async stop() {
674
808
  this.stopped = true;
675
809
  this.link.disconnect();
676
810
  if (this.retryTimer) clearTimeout(this.retryTimer);
677
811
  this.retryTimer = void 0;
678
812
  for (const id of [...this.threads.keys()]) this.drop(id);
679
- this.conn?.close();
813
+ this.closeConn(this.conn);
814
+ await this.connecting;
815
+ await Promise.all(this.closing);
816
+ }
817
+ closeConn(conn) {
818
+ if (!conn) return;
819
+ const closing = conn.close().finally(() => this.closing.delete(closing));
820
+ this.closing.add(closing);
680
821
  }
681
822
  async connect() {
682
823
  let rpc;
683
824
  try {
684
825
  const conn = await connectProxy(this.opts.command, this.opts.spawnFn);
685
826
  if (this.stopped) {
686
- conn.close();
827
+ this.closeConn(conn);
687
828
  return;
688
829
  }
689
830
  const live = new RpcClient(conn.ws, this.opts.rpcTimeoutMs);
@@ -715,7 +856,7 @@ var CodexAdapter = class {
715
856
  });
716
857
  if (rpc && this.rpc === rpc) {
717
858
  this.rpc = void 0;
718
- this.conn?.close();
859
+ this.closeConn(this.conn);
719
860
  this.conn = void 0;
720
861
  }
721
862
  for (const id of [...this.threads.keys()]) this.drop(id);
@@ -725,7 +866,7 @@ var CodexAdapter = class {
725
866
  onDaemonLost() {
726
867
  this.setReady(false);
727
868
  this.rpc = void 0;
728
- this.conn?.close();
869
+ this.closeConn(this.conn);
729
870
  this.conn = void 0;
730
871
  for (const id of [...this.threads.keys()]) this.drop(id);
731
872
  if (!this.stopped) logger.warn("Codex daemon connection lost");
@@ -735,7 +876,7 @@ var CodexAdapter = class {
735
876
  if (this.stopped || this.retryTimer) return;
736
877
  this.retryTimer = setTimeout(() => {
737
878
  this.retryTimer = void 0;
738
- void this.connect();
879
+ this.connecting = this.connect();
739
880
  }, this.delay);
740
881
  this.delay = Math.min(this.delay * 2, this.opts.reconnectMaxMs ?? 6e4);
741
882
  }
@@ -1028,9 +1169,9 @@ var CodexAdapter = class {
1028
1169
  }
1029
1170
  onHubMessage(threadId, msg) {
1030
1171
  if (msg.type === "message_to_session") {
1031
- this.enqueue(threadId, async () => textInput(msg.content, msg.source));
1172
+ this.enqueue(threadId, async () => textInput(msg.content, msg.source), msg.source);
1032
1173
  } else if (msg.type === "image_to_session") {
1033
- this.enqueue(threadId, () => imageInput(msg.imagePath, msg.mimeType, msg.content, msg.source));
1174
+ this.enqueue(threadId, () => imageInput(msg.imagePath, msg.mimeType, msg.content, msg.source), msg.source);
1034
1175
  } else if (msg.type === "permission_response") {
1035
1176
  this.answer(threadId, msg.requestId, msg.choiceId);
1036
1177
  } else if (msg.type === "codex_close") {
@@ -1038,16 +1179,16 @@ var CodexAdapter = class {
1038
1179
  }
1039
1180
  }
1040
1181
  // One message at a time per conversation, so a message right behind another sees the turn the first one started.
1041
- enqueue(threadId, build) {
1182
+ enqueue(threadId, build, source) {
1042
1183
  const t = this.threads.get(threadId);
1043
1184
  if (!t) return;
1044
- t.sending = t.sending.then(() => this.deliver(t, build)).catch((err) => logger.warn(`Codex delivery for ${threadId} failed: ${err.message}`));
1185
+ t.sending = t.sending.then(() => this.deliver(t, build, source)).catch((err) => logger.warn(`Codex delivery for ${threadId} failed: ${err.message}`));
1045
1186
  }
1046
- async deliver(t, build) {
1187
+ async deliver(t, build, source) {
1047
1188
  const threadId = t.thread.id;
1048
1189
  if (this.threads.get(threadId) !== t) return;
1049
- if (hubStatus(t.thread.status) === "waiting_input") {
1050
- this.notify(threadId, "Not delivered", "Codex is waiting for an approval or input. Answer it first, then send the message again.", "warning");
1190
+ if (this.waitingForAnswer(t)) {
1191
+ this.refuseWhileWaiting(threadId);
1051
1192
  return;
1052
1193
  }
1053
1194
  let input;
@@ -1068,20 +1209,37 @@ var CodexAdapter = class {
1068
1209
  const unsubscribed = !t.subscribed;
1069
1210
  const running = await this.runningTurn(rpc, threadId);
1070
1211
  if (this.threads.get(threadId) !== t) return;
1212
+ if (this.waitingForAnswer(t)) {
1213
+ this.abandonDelivery(t, wasUnrelayed);
1214
+ this.refuseWhileWaiting(threadId);
1215
+ return;
1216
+ }
1071
1217
  if (unsubscribed) t.unrelayed = true;
1072
1218
  if (running) {
1073
1219
  await rpc.request("turn/steer", { threadId, expectedTurnId: running, input });
1074
- this.notify(threadId, "Queued", "Queued: Codex will read it after its current step.", "info");
1220
+ this.notify(threadId, "Queued", "Queued: Codex will read it after its current step.", "info", source);
1075
1221
  return;
1076
1222
  }
1077
1223
  await rpc.request("turn/start", { threadId, input });
1078
1224
  } catch (err) {
1079
- t.pendingTurn = false;
1080
- t.unrelayed = wasUnrelayed;
1081
- this.releaseLater(t);
1225
+ this.abandonDelivery(t, wasUnrelayed);
1082
1226
  this.notify(threadId, "Not delivered", `Codex rejected the message: ${err.message}`, "warning");
1083
1227
  }
1084
1228
  }
1229
+ abandonDelivery(t, wasUnrelayed) {
1230
+ t.pendingTurn = false;
1231
+ t.unrelayed = wasUnrelayed;
1232
+ this.releaseLater(t);
1233
+ }
1234
+ // The approval request itself can arrive before the status broadcast that marks the thread as waiting.
1235
+ waitingForAnswer(t) {
1236
+ if (hubStatus(t.thread.status) === "waiting_input") return true;
1237
+ for (const a of this.approvals.values()) if (a.threadId === t.thread.id && !a.answered) return true;
1238
+ return false;
1239
+ }
1240
+ refuseWhileWaiting(threadId) {
1241
+ this.notify(threadId, "Not delivered", "Codex is waiting for an approval or input. Answer it first, then send the message again.", "warning");
1242
+ }
1085
1243
  // The list is unavailable before a new conversation's first turn; starting a turn is safe then, since turn/start steers a running turn.
1086
1244
  async runningTurn(rpc, threadId) {
1087
1245
  try {
@@ -1161,11 +1319,36 @@ var CodexAdapter = class {
1161
1319
  this.approvals.delete(requestId);
1162
1320
  this.threads.get(a.threadId)?.hub.send({ type: "permission_resolved", sessionId: codexSessionId(a.threadId), requestId, state });
1163
1321
  }
1164
- notify(threadId, title, message, level) {
1165
- this.threads.get(threadId)?.hub.send({ type: "notify", sessionId: codexSessionId(threadId), title, message, level });
1322
+ notify(threadId, title, message, level, to) {
1323
+ this.threads.get(threadId)?.hub.send({ type: "notify", sessionId: codexSessionId(threadId), title, message, level, ...to ? { to } : {} });
1166
1324
  }
1167
1325
  };
1168
1326
 
1327
+ // src/codex/shutdown.ts
1328
+ var ADAPTER_SHUTDOWN_MS = 25e3;
1329
+ function createAdapterShutdown(opts) {
1330
+ let exiting = false;
1331
+ const shutdown2 = () => {
1332
+ if (exiting) return;
1333
+ exiting = true;
1334
+ const timer = setTimeout(() => {
1335
+ logger.warn(`Codex adapter shutdown did not finish within ${ADAPTER_SHUTDOWN_MS}ms; exiting`);
1336
+ opts.exit();
1337
+ }, ADAPTER_SHUTDOWN_MS);
1338
+ const stopped = Promise.resolve().then(opts.stop);
1339
+ const released = Promise.resolve().then(opts.release);
1340
+ void Promise.allSettled([stopped, released]).then(() => {
1341
+ clearTimeout(timer);
1342
+ opts.exit();
1343
+ });
1344
+ };
1345
+ opts.signals.on("SIGINT", shutdown2);
1346
+ opts.signals.on("SIGTERM", shutdown2);
1347
+ opts.stdin?.on("end", shutdown2);
1348
+ opts.stdin?.on("close", shutdown2);
1349
+ return shutdown2;
1350
+ }
1351
+
1169
1352
  // src/shared/hub-url.ts
1170
1353
  import net from "net";
1171
1354
  function hubUrlHost(host) {
@@ -1388,23 +1571,14 @@ var supervised = process.argv.includes("--watch-stdin");
1388
1571
  var endpoint = controlEndpoint(CONFIG_DIR);
1389
1572
  var adapter;
1390
1573
  var release;
1391
- var exiting = false;
1392
- var shutdown = () => {
1393
- if (exiting) return;
1394
- exiting = true;
1395
- adapter?.stop();
1396
- const exit = () => process.exit(0);
1397
- setTimeout(exit, 3e3).unref();
1398
- if (release) release().then(exit, exit);
1399
- else exit();
1400
- };
1401
- process.on("SIGINT", shutdown);
1402
- process.on("SIGTERM", shutdown);
1403
- if (supervised) {
1404
- process.stdin.on("end", shutdown);
1405
- process.stdin.on("close", shutdown);
1406
- process.stdin.resume();
1407
- }
1574
+ var shutdown = createAdapterShutdown({
1575
+ signals: process,
1576
+ stdin: supervised ? process.stdin : void 0,
1577
+ stop: () => adapter?.stop(),
1578
+ release: () => release?.(),
1579
+ exit: () => process.exit(0)
1580
+ });
1581
+ if (supervised) process.stdin.resume();
1408
1582
  function send(message, then) {
1409
1583
  if (!process.send || !process.connected) {
1410
1584
  then?.();