@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 +6 -4
- package/dist/cli.js +165 -13
- package/dist/cli.js.map +1 -1
- package/dist/codex/main.js +147 -12
- package/dist/codex/main.js.map +1 -1
- package/dist/dashboard/index.html +101 -7
- package/dist/hub/server.js +157 -12
- package/dist/hub/server.js.map +1 -1
- package/dist/index.d.ts +37 -2
- package/dist/index.js +157 -12
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/dashboard/index.html +101 -7
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
|
|
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
|
|
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
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
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
|
-
|
|
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");
|