@delt/claude-alarm 1.0.1 → 1.1.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.
package/README.md CHANGED
@@ -185,11 +185,13 @@ claude-alarm hub start
185
185
 
186
186
  - Requires the Codex CLI with its app-server daemon (`codex app-server daemon version` shows `running`). Set `"codex": { "command": "C:/path/to/codex.exe" }` in `~/.claude-alarm/config.json` if `codex` is not on `PATH`.
187
187
  - If the hub console keeps printing `Codex daemon connection failed: spawn codex ENOENT`, the hub's terminal cannot find `codex`. A terminal opened before Codex was installed still has the old `PATH`, so open a new terminal or set `codex.command`.
188
- - Every loaded Codex conversation appears as a session with a **Codex** badge. Replies, failures and approval waits are relayed to the dashboard and Telegram.
188
+ - Every loaded Codex conversation appears as a session with a **Codex** badge. Replies, failures and approval requests are relayed to the dashboard and Telegram.
189
189
  - Messages you send reach the conversation only while Codex is idle, and show up in Codex prefixed with `[claude-alarm · Dashboard]` or `[claude-alarm · Telegram]`.
190
- - Approvals still have to be answered in Codex; claude-alarm only tells you one is waiting.
190
+ - Approvals for commands, file changes and MCP tools can be answered from the dashboard or Telegram with the choices Codex offers (for example **Allow once**, **Always allow this command**, **Cancel task**). Whoever answers first wins, in Codex or here; the other buttons close as **Resolved**, which does not say what was chosen. Buttons from before a hub or adapter restart show **Expired**; if Codex is still waiting, a new request appears.
191
+ - After updating claude-alarm, reload open dashboard tabs so they pick up the new approval buttons.
192
+ - Questions Codex asks you (not approvals) still have to be answered in Codex; claude-alarm tells you one is waiting.
191
193
  - A conversation is followed only while Codex is working on it, so a closed Codex window drops off the dashboard about a minute later. If a reply could not be picked up, you get a **Reply not relayed** warning; read it in the Codex window.
192
- - If Codex runs on another PC, run `claude-alarm codex start` there with that PC's config pointing at your hub.
194
+ - If Codex runs on another PC, run `claude-alarm codex start` there with that PC's config pointing at your hub. Keep claude-alarm at the same version on both PCs; an older hub cannot show Codex's choices.
193
195
 
194
196
  ## Permission Relay
195
197
 
@@ -293,7 +295,7 @@ In the dashboard input, type `@<session name>` to have the selected session send
293
295
 
294
296
  - Node.js >= 18
295
297
  - Claude Code with MCP Channels support
296
- - Optional, for Codex sessions: OpenAI Codex CLI with the app-server daemon (tested with 0.159.3)
298
+ - Optional, for Codex sessions: OpenAI Codex CLI with the app-server daemon (tested with 0.159.3; approvals also with app-server 0.160.0)
297
299
 
298
300
  ## License
299
301
 
package/dist/cli.js CHANGED
@@ -412,18 +412,31 @@ var init_session_label = __esm({
412
412
  }
413
413
  });
414
414
 
415
+ // src/shared/permission-key.ts
416
+ function permissionKey(sessionId2, requestId) {
417
+ return `${sessionId2}
418
+ ${requestId}`;
419
+ }
420
+ var init_permission_key = __esm({
421
+ "src/shared/permission-key.ts"() {
422
+ "use strict";
423
+ }
424
+ });
425
+
415
426
  // src/hub/telegram.ts
416
427
  import fs4 from "fs";
417
428
  import path4 from "path";
418
429
  import { randomUUID as randomUUID2 } from "crypto";
419
- var TELEGRAM_API, TelegramBot;
430
+ var TELEGRAM_API, MAX_CHOICE_MESSAGES, TelegramBot;
420
431
  var init_telegram = __esm({
421
432
  "src/hub/telegram.ts"() {
422
433
  "use strict";
423
434
  init_logger();
424
435
  init_constants();
425
436
  init_session_label();
437
+ init_permission_key();
426
438
  TELEGRAM_API = "https://api.telegram.org/bot";
439
+ MAX_CHOICE_MESSAGES = 200;
427
440
  TelegramBot = class _TelegramBot {
428
441
  config;
429
442
  offset = 0;
@@ -437,6 +450,11 @@ var init_telegram = __esm({
437
450
  onImageToSession;
438
451
  // Callback: when a permission verdict arrives from Telegram
439
452
  onPermissionVerdict;
453
+ // Callback: when a Codex approval choice arrives from Telegram
454
+ onChoiceVerdict;
455
+ // Telegram caps callback_data at 64 bytes, so buttons carry a short token instead of the ids.
456
+ choiceTokens = /* @__PURE__ */ new Map();
457
+ choiceMessages = /* @__PURE__ */ new Map();
440
458
  // Callback: get current sessions list
441
459
  getSessions;
442
460
  // Pending messages for session selection
@@ -725,6 +743,71 @@ ${previewHtml}${truncNote}`;
725
743
  };
726
744
  await this.sendMessage(text, void 0, replyMarkup);
727
745
  }
746
+ async sendChoiceRequest(sessionId2, sessionLabel2, requestId, toolName, description, inputPreview, choices) {
747
+ let preview = inputPreview;
748
+ try {
749
+ const p = JSON.parse(inputPreview);
750
+ if (typeof p.command === "string") preview = `$ ${p.command}`;
751
+ else if (typeof p.content === "string") preview = p.content;
752
+ } catch {
753
+ }
754
+ const slice = preview.slice(0, 3e3);
755
+ const html = `\u26A0\uFE0F <b>Permission Request</b> \u2014 ${this.escHtml(sessionLabel2)}
756
+
757
+ \u{1F527} <b>${this.escHtml(toolName)}</b>` + (description ? `
758
+ ${this.escHtml(description)}` : "") + (slice ? `
759
+ <pre>${this.escHtml(slice)}</pre>` : "") + (preview.length > slice.length ? "\n<i>...truncated</i>" : "");
760
+ const entry = { html };
761
+ this.choiceMessages.set(permissionKey(sessionId2, requestId), entry);
762
+ const rows = choices.map((c) => {
763
+ const token = randomUUID2().replace(/-/g, "").slice(0, 16);
764
+ this.choiceTokens.set(token, { sessionId: sessionId2, requestId, choiceId: c.id, label: c.label });
765
+ return [{ text: c.label, callback_data: `pc:${token}` }];
766
+ });
767
+ this.trimChoiceMessages();
768
+ const sent = await this.sendMessage(html, void 0, { inline_keyboard: rows });
769
+ if (!sent) return;
770
+ entry.messageId = sent.message_id;
771
+ if (entry.outcome) await this.editMessageText(this.config.chatId, sent.message_id, html + entry.outcome);
772
+ }
773
+ async resolveChoiceRequest(sessionId2, requestId, state) {
774
+ this.dropChoiceTokens(sessionId2, requestId);
775
+ const key = permissionKey(sessionId2, requestId);
776
+ const entry = this.choiceMessages.get(key);
777
+ if (!entry) return;
778
+ this.choiceMessages.delete(key);
779
+ entry.outcome = state === "resolved" ? "\n\n\u2705 <b>Resolved</b>" : "\n\n\u231B <b>Expired</b>";
780
+ if (entry.messageId !== void 0) await this.editMessageText(this.config.chatId, entry.messageId, entry.html + entry.outcome);
781
+ }
782
+ async handleChoiceCallback(query) {
783
+ const choice = this.choiceTokens.get(query.data.slice("pc:".length));
784
+ if (!choice) {
785
+ await this.answerCallbackQuery(query.id, "Expired");
786
+ return;
787
+ }
788
+ this.dropChoiceTokens(choice.sessionId, choice.requestId);
789
+ this.onChoiceVerdict?.(choice.sessionId, choice.requestId, choice.choiceId);
790
+ await this.answerCallbackQuery(query.id, `Sent: ${choice.label}`);
791
+ const entry = this.choiceMessages.get(permissionKey(choice.sessionId, choice.requestId));
792
+ if (entry?.messageId !== void 0 && !entry.outcome) {
793
+ await this.editMessageText(this.config.chatId, entry.messageId, `${entry.html}
794
+
795
+ \u23F3 <b>Sent: ${this.escHtml(choice.label)}</b>`);
796
+ }
797
+ }
798
+ dropChoiceTokens(sessionId2, requestId) {
799
+ for (const [token, c] of this.choiceTokens) {
800
+ if (c.sessionId === sessionId2 && c.requestId === requestId) this.choiceTokens.delete(token);
801
+ }
802
+ }
803
+ trimChoiceMessages() {
804
+ while (this.choiceMessages.size > MAX_CHOICE_MESSAGES) {
805
+ const oldest = this.choiceMessages.keys().next().value;
806
+ this.choiceMessages.delete(oldest);
807
+ const [sessionId2, requestId] = oldest.split("\n");
808
+ this.dropChoiceTokens(sessionId2, requestId);
809
+ }
810
+ }
728
811
  async handleCallbackQuery(query) {
729
812
  if (!query.data) return;
730
813
  if (String(query.message?.chat.id) !== String(this.config.chatId)) return;
@@ -732,6 +815,10 @@ ${previewHtml}${truncNote}`;
732
815
  await this.handleSessionSelectCallback(query);
733
816
  return;
734
817
  }
818
+ if (query.data.startsWith("pc:")) {
819
+ await this.handleChoiceCallback(query);
820
+ return;
821
+ }
735
822
  if (!query.data.startsWith("perm:")) return;
736
823
  const parts = query.data.split(":");
737
824
  if (parts.length < 4) return;
@@ -1017,6 +1104,11 @@ import path6 from "path";
1017
1104
  import { fileURLToPath } from "url";
1018
1105
  import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
1019
1106
  import { randomUUID as randomUUID3 } from "crypto";
1107
+ function validChoices(choices) {
1108
+ if (!Array.isArray(choices)) return void 0;
1109
+ const valid = choices.filter((c) => typeof c?.id === "string" && typeof c?.label === "string");
1110
+ return valid.length ? valid : void 0;
1111
+ }
1020
1112
  var __dirname, MAX_WS_PAYLOAD, HubServer;
1021
1113
  var init_server = __esm({
1022
1114
  "src/hub/server.ts"() {
@@ -1030,6 +1122,7 @@ var init_server = __esm({
1030
1122
  init_config();
1031
1123
  init_session_label();
1032
1124
  init_crash_guard();
1125
+ init_permission_key();
1033
1126
  init_auth();
1034
1127
  __dirname = path6.dirname(fileURLToPath(import.meta.url));
1035
1128
  MAX_WS_PAYLOAD = 16 * 1024 * 1024;
@@ -1051,6 +1144,8 @@ var init_server = __esm({
1051
1144
  channelAlive = /* @__PURE__ */ new Map();
1052
1145
  // sessionId -> alive flag
1053
1146
  socketOwners = /* @__PURE__ */ new WeakMap();
1147
+ // Codex approvals carry their own choices; a response is forwarded only in the mode of the request it answers.
1148
+ choiceRequests = /* @__PURE__ */ new Map();
1054
1149
  host;
1055
1150
  port;
1056
1151
  token;
@@ -1188,6 +1283,7 @@ var init_server = __esm({
1188
1283
  this.channelSockets.delete(sessionId2);
1189
1284
  this.localChannels.delete(sessionId2);
1190
1285
  this.channelAlive.delete(sessionId2);
1286
+ this.expireChoices(sessionId2);
1191
1287
  if (session) {
1192
1288
  this.broadcastToDashboards({ type: "session_disconnected", sessionId: sessionId2 });
1193
1289
  this.jsonResponse(res, 200, { ok: true });
@@ -1331,6 +1427,7 @@ var init_server = __esm({
1331
1427
  this.channelSockets.delete(sessionId2);
1332
1428
  this.localChannels.delete(sessionId2);
1333
1429
  this.channelAlive.delete(sessionId2);
1430
+ this.expireChoices(sessionId2);
1334
1431
  logger.info(`Channel disconnected: ${sessionId2}`);
1335
1432
  this.broadcastToDashboards({
1336
1433
  type: "session_disconnected",
@@ -1418,7 +1515,22 @@ var init_server = __esm({
1418
1515
  }
1419
1516
  case "permission_request": {
1420
1517
  this.sessions.updateActivity(msg.sessionId);
1518
+ const choices = validChoices(msg.choices);
1421
1519
  logger.info(`Permission request [${msg.requestId}] from ${msg.sessionId}: ${msg.toolName}`);
1520
+ if (choices) {
1521
+ this.choiceRequests.set(permissionKey(msg.sessionId, msg.requestId), {
1522
+ request: {
1523
+ sessionId: msg.sessionId,
1524
+ requestId: msg.requestId,
1525
+ toolName: msg.toolName,
1526
+ description: msg.description,
1527
+ inputPreview: msg.inputPreview,
1528
+ timestamp: msg.timestamp,
1529
+ choices
1530
+ },
1531
+ choiceIds: new Set(choices.map((c) => c.id))
1532
+ });
1533
+ }
1422
1534
  this.broadcastToDashboards({
1423
1535
  type: "permission_request",
1424
1536
  sessionId: msg.sessionId,
@@ -1426,15 +1538,23 @@ var init_server = __esm({
1426
1538
  toolName: msg.toolName,
1427
1539
  description: msg.description,
1428
1540
  inputPreview: msg.inputPreview,
1429
- timestamp: msg.timestamp
1541
+ timestamp: msg.timestamp,
1542
+ ...choices ? { choices } : {}
1430
1543
  });
1431
1544
  if (this.telegramBot) {
1432
- const session = this.sessions.get(msg.sessionId);
1433
- const label = this.getSessionLabel(session);
1434
- this.telegramBot.sendPermissionRequest(msg.sessionId, label, msg.requestId, msg.toolName, msg.description, msg.inputPreview);
1545
+ const label = this.getSessionLabel(this.sessions.get(msg.sessionId));
1546
+ if (choices) {
1547
+ void this.telegramBot.sendChoiceRequest(msg.sessionId, label, msg.requestId, msg.toolName, msg.description, msg.inputPreview, choices);
1548
+ } else {
1549
+ this.telegramBot.sendPermissionRequest(msg.sessionId, label, msg.requestId, msg.toolName, msg.description, msg.inputPreview);
1550
+ }
1435
1551
  }
1436
1552
  break;
1437
1553
  }
1554
+ case "permission_resolved": {
1555
+ this.resolveChoice(msg.sessionId, msg.requestId, msg.state === "expired" ? "expired" : "resolved");
1556
+ break;
1557
+ }
1438
1558
  }
1439
1559
  }
1440
1560
  // --- Dashboard WebSocket ---
@@ -1450,6 +1570,11 @@ var init_server = __esm({
1450
1570
  sessions: this.sessions.getAll()
1451
1571
  };
1452
1572
  ws.send(JSON.stringify(sessionsMsg));
1573
+ const pendingMsg = {
1574
+ type: "permission_pending",
1575
+ requests: [...this.choiceRequests.values()].map((c) => c.request)
1576
+ };
1577
+ ws.send(JSON.stringify(pendingMsg));
1453
1578
  ws.on("message", (data) => {
1454
1579
  try {
1455
1580
  const msg = JSON.parse(data.toString());
@@ -1461,10 +1586,9 @@ var init_server = __esm({
1461
1586
  } else if (msg.type === "image_upload") {
1462
1587
  this.handleImageUpload(msg);
1463
1588
  } else if (msg.type === "permission_response") {
1464
- const channelWs = this.channelSockets.get(msg.sessionId);
1465
- if (channelWs?.readyState === WebSocket2.OPEN) {
1466
- channelWs.send(JSON.stringify(msg));
1467
- logger.info(`Permission verdict [${msg.requestId}]: ${msg.behavior} -> session ${msg.sessionId}`);
1589
+ if (this.forwardPermissionResponse(msg)) {
1590
+ const verdict = msg.choiceId !== void 0 ? `choice ${msg.choiceId}` : msg.behavior;
1591
+ logger.info(`Permission verdict [${msg.requestId}]: ${verdict} -> session ${msg.sessionId}`);
1468
1592
  }
1469
1593
  }
1470
1594
  } catch {
@@ -1477,6 +1601,32 @@ var init_server = __esm({
1477
1601
  });
1478
1602
  }
1479
1603
  // --- Helpers ---
1604
+ forwardPermissionResponse(msg) {
1605
+ const pending = this.choiceRequests.get(permissionKey(msg.sessionId, msg.requestId));
1606
+ let out;
1607
+ if (pending) {
1608
+ if (typeof msg.choiceId !== "string" || !pending.choiceIds.has(msg.choiceId)) return false;
1609
+ out = { type: "permission_response", sessionId: msg.sessionId, requestId: msg.requestId, choiceId: msg.choiceId };
1610
+ } else {
1611
+ if (msg.behavior !== "allow" && msg.behavior !== "deny" || msg.choiceId !== void 0) return false;
1612
+ if (this.sessions.get(msg.sessionId)?.agentKind === "codex") return false;
1613
+ out = { type: "permission_response", sessionId: msg.sessionId, requestId: msg.requestId, behavior: msg.behavior };
1614
+ }
1615
+ const channelWs = this.channelSockets.get(msg.sessionId);
1616
+ if (channelWs?.readyState !== WebSocket2.OPEN) return false;
1617
+ channelWs.send(JSON.stringify(out));
1618
+ return true;
1619
+ }
1620
+ resolveChoice(sessionId2, requestId, state) {
1621
+ if (!this.choiceRequests.delete(permissionKey(sessionId2, requestId))) return;
1622
+ this.broadcastToDashboards({ type: "permission_resolved", sessionId: sessionId2, requestId, state });
1623
+ void this.telegramBot?.resolveChoiceRequest(sessionId2, requestId, state);
1624
+ }
1625
+ expireChoices(sessionId2) {
1626
+ for (const pending of [...this.choiceRequests.values()]) {
1627
+ if (pending.request.sessionId === sessionId2) this.resolveChoice(sessionId2, pending.request.requestId, "expired");
1628
+ }
1629
+ }
1480
1630
  broadcastToDashboards(msg) {
1481
1631
  const payload = JSON.stringify(msg);
1482
1632
  for (const ws of this.dashboardSockets) {
@@ -1560,14 +1710,16 @@ var init_server = __esm({
1560
1710
  }
1561
1711
  };
1562
1712
  this.telegramBot.onPermissionVerdict = (sessionId2, requestId, behavior) => {
1563
- const channelWs = this.channelSockets.get(sessionId2);
1564
- if (channelWs?.readyState === WebSocket2.OPEN) {
1565
- const msg = { type: "permission_response", sessionId: sessionId2, requestId, behavior };
1566
- channelWs.send(JSON.stringify(msg));
1713
+ if (this.forwardPermissionResponse({ sessionId: sessionId2, requestId, behavior })) {
1567
1714
  logger.info(`Telegram permission verdict [${requestId}]: ${behavior} -> session ${sessionId2}`);
1568
1715
  }
1569
1716
  this.broadcastToDashboards({ type: "permission_response", sessionId: sessionId2, requestId, behavior });
1570
1717
  };
1718
+ this.telegramBot.onChoiceVerdict = (sessionId2, requestId, choiceId) => {
1719
+ if (this.forwardPermissionResponse({ sessionId: sessionId2, requestId, choiceId })) {
1720
+ logger.info(`Telegram choice [${requestId}]: ${choiceId} -> session ${sessionId2}`);
1721
+ }
1722
+ };
1571
1723
  this.notifier.configure({ telegramBot: this.telegramBot });
1572
1724
  this.telegramBot.startPolling();
1573
1725
  logger.info("Telegram bot initialized");