telecodex 0.1.3 → 0.1.5

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/dist/bot/auth.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { replyError, replyNotice } from "../telegram/formatted.js";
1
2
  export function authMiddleware(input) {
2
3
  return async (ctx, next) => {
3
4
  const userId = ctx.from?.id;
@@ -10,7 +11,7 @@ export function authMiddleware(input) {
10
11
  hasTextMessage: Boolean(ctx.message?.text),
11
12
  });
12
13
  if (ctx.message?.text && ctx.chat?.type !== "private") {
13
- await ctx.reply("This message was sent as the group identity or as an anonymous admin. telecodex cannot verify the operator. Send it from your personal account instead.");
14
+ await replyError(ctx, "This message was sent as the group identity or as an anonymous admin. telecodex cannot verify the operator. Send it from your personal account instead.");
14
15
  }
15
16
  return;
16
17
  }
@@ -31,7 +32,7 @@ export function authMiddleware(input) {
31
32
  store: input.store,
32
33
  success: async () => {
33
34
  input.store.rebindAuthorizedUserId(userId);
34
- await ctx.reply("Admin handoff succeeded. This Telegram account is now authorized to use telecodex.");
35
+ await replyNotice(ctx, "Admin handoff succeeded. This Telegram account is now authorized to use telecodex.");
35
36
  },
36
37
  mismatchLabel: "Admin handoff code did not match.",
37
38
  exhaustedLabel: "Admin handoff code exhausted its attempt limit and was invalidated. Issue a new one from the currently authorized account.",
@@ -68,7 +69,7 @@ export function authMiddleware(input) {
68
69
  const claimedUserId = input.store.claimAuthorizedUserId(userId);
69
70
  if (claimedUserId === userId) {
70
71
  input.onAdminBound?.(userId);
71
- await ctx.reply("Admin binding succeeded. Only this Telegram account can use this bot from now on.");
72
+ await replyNotice(ctx, "Admin binding succeeded. Only this Telegram account can use this bot from now on.");
72
73
  return;
73
74
  }
74
75
  await deny(ctx, "An admin account has already claimed this bot.");
@@ -79,7 +80,7 @@ export function authMiddleware(input) {
79
80
  if (handled)
80
81
  return;
81
82
  }
82
- await ctx.reply("This bot is not initialized yet. Send the binding code shown in the startup logs to complete the one-time admin binding.");
83
+ await replyNotice(ctx, "This bot is not initialized yet. Send the binding code shown in the startup logs to complete the one-time admin binding.");
83
84
  };
84
85
  }
85
86
  async function handleBindingCodeMessage(input) {
@@ -92,14 +93,14 @@ async function handleBindingCodeMessage(input) {
92
93
  }
93
94
  const attempt = input.store.recordBindingCodeFailure();
94
95
  if (!attempt) {
95
- await input.ctx.reply("The binding code is no longer active. Issue a new one and try again.");
96
+ await replyError(input.ctx, "The binding code is no longer active. Issue a new one and try again.");
96
97
  return true;
97
98
  }
98
99
  if (attempt.exhausted) {
99
- await input.ctx.reply(input.exhaustedLabel);
100
+ await replyError(input.ctx, input.exhaustedLabel);
100
101
  return true;
101
102
  }
102
- await input.ctx.reply(`${input.mismatchLabel}\nRemaining attempts: ${attempt.remaining}`);
103
+ await replyError(input.ctx, input.mismatchLabel, `Remaining attempts: ${attempt.remaining}`);
103
104
  return true;
104
105
  }
105
106
  async function deny(ctx, text) {
@@ -108,6 +109,6 @@ async function deny(ctx, text) {
108
109
  return;
109
110
  }
110
111
  if (ctx.chat?.type === "private") {
111
- await ctx.reply(text);
112
+ await replyError(ctx, text);
112
113
  }
113
114
  }
@@ -2,7 +2,7 @@ import { realpathSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { APPROVAL_POLICIES, MODE_PRESETS, REASONING_EFFORTS, SANDBOX_MODES, } from "../config.js";
4
4
  import { makeSessionKey } from "../store/sessions.js";
5
- import { sendPlainChunks } from "../telegram/delivery.js";
5
+ import { replyNotice, sendReplyNotice } from "../telegram/formatted.js";
6
6
  import { numericChatId, numericMessageThreadId, sessionFromContext } from "./session.js";
7
7
  import { truncateSingleLine } from "./sessionFlow.js";
8
8
  export function getProjectForContext(ctx, projects) {
@@ -12,20 +12,12 @@ export function getProjectForContext(ctx, projects) {
12
12
  return projects.get(String(chatId));
13
13
  }
14
14
  export function getScopedSession(ctx, store, projects, config, options) {
15
- if (isPrivateChat(ctx)) {
16
- void ctx.reply("Private chat is only for admin binding and project overview. Do actual work inside project supergroup topics.");
17
- return null;
18
- }
19
15
  const project = getProjectForContext(ctx, projects);
20
- if (!project) {
21
- void ctx.reply("This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.");
16
+ if (!project || isPrivateChat(ctx))
22
17
  return null;
23
- }
24
18
  const requireTopic = options?.requireTopic ?? true;
25
- if (requireTopic && !hasTopicContext(ctx)) {
26
- void ctx.reply("Use this inside a forum topic. The root chat is only for project-level commands; work happens inside topics.");
19
+ if (requireTopic && !hasTopicContext(ctx))
27
20
  return null;
28
- }
29
21
  const session = sessionFromContext(ctx, store, config);
30
22
  if (!isPathWithinRoot(session.cwd, project.cwd)) {
31
23
  store.setCwd(session.sessionKey, project.cwd);
@@ -33,6 +25,13 @@ export function getScopedSession(ctx, store, projects, config, options) {
33
25
  }
34
26
  return session;
35
27
  }
28
+ export async function requireScopedSession(ctx, store, projects, config, options) {
29
+ const session = getScopedSession(ctx, store, projects, config, options);
30
+ if (session)
31
+ return session;
32
+ await replyScopedSessionRequirement(ctx, projects, options);
33
+ return null;
34
+ }
36
35
  export function formatHelpText(ctx, projects) {
37
36
  if (isPrivateChat(ctx)) {
38
37
  return [
@@ -163,11 +162,10 @@ export function ensureTopicSession(input) {
163
162
  return input.store.get(session.sessionKey) ?? session;
164
163
  }
165
164
  export async function postTopicReadyMessage(bot, session, text, logger) {
166
- await sendPlainChunks(bot, {
165
+ await sendReplyNotice(bot, {
167
166
  chatId: numericChatId(session),
168
167
  messageThreadId: numericMessageThreadId(session),
169
- text,
170
- }, logger);
168
+ }, text, logger);
171
169
  }
172
170
  export function parseSubcommand(input) {
173
171
  const trimmed = input.trim();
@@ -252,3 +250,18 @@ function canonicalizeBoundaryPath(input) {
252
250
  return resolved;
253
251
  }
254
252
  }
253
+ async function replyScopedSessionRequirement(ctx, projects, options) {
254
+ if (isPrivateChat(ctx)) {
255
+ await replyNotice(ctx, "Private chat is only for admin binding and project overview. Do actual work inside project supergroup topics.");
256
+ return;
257
+ }
258
+ const project = getProjectForContext(ctx, projects);
259
+ if (!project) {
260
+ await replyNotice(ctx, "This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.");
261
+ return;
262
+ }
263
+ const requireTopic = options?.requireTopic ?? true;
264
+ if (requireTopic && !hasTopicContext(ctx)) {
265
+ await replyNotice(ctx, "Use this inside a forum topic. The root chat is only for project-level commands; work happens inside topics.");
266
+ }
267
+ }
@@ -1,9 +1,11 @@
1
- import { contextLogFields, getScopedSession, } from "../commandSupport.js";
1
+ import { contextLogFields, requireScopedSession, } from "../commandSupport.js";
2
2
  import { handleUserInput, handleUserText } from "../inputService.js";
3
3
  import { telegramImageMessageToCodexInput } from "../../telegram/attachments.js";
4
+ import { replyError, replyNotice } from "../../telegram/formatted.js";
5
+ import { wrapUserFacingHandler } from "../userFacingErrors.js";
4
6
  export function registerMessageHandlers(deps) {
5
7
  const { bot, config, store, projects, codex, buffers, logger } = deps;
6
- bot.on("message:text", async (ctx) => {
8
+ bot.on("message:text", wrapUserFacingHandler("message:text", logger, async (ctx) => {
7
9
  const text = ctx.message.text;
8
10
  logger?.info("received telegram text message", {
9
11
  ...contextLogFields(ctx),
@@ -12,7 +14,7 @@ export function registerMessageHandlers(deps) {
12
14
  });
13
15
  if (text.startsWith("/"))
14
16
  return;
15
- const session = getScopedSession(ctx, store, projects, config);
17
+ const session = await requireScopedSession(ctx, store, projects, config);
16
18
  if (!session) {
17
19
  logger?.warn("ignored telegram text message because no scoped session was available", {
18
20
  ...contextLogFields(ctx),
@@ -29,9 +31,9 @@ export function registerMessageHandlers(deps) {
29
31
  bot,
30
32
  ...(logger ? { logger } : {}),
31
33
  });
32
- });
33
- bot.on(["message:photo", "message:document"], async (ctx) => {
34
- const session = getScopedSession(ctx, store, projects, config);
34
+ }));
35
+ bot.on(["message:photo", "message:document"], wrapUserFacingHandler("message:attachment", logger, async (ctx) => {
36
+ const session = await requireScopedSession(ctx, store, projects, config);
35
37
  if (!session) {
36
38
  logger?.warn("ignored telegram attachment because no scoped session was available", {
37
39
  ...contextLogFields(ctx),
@@ -47,7 +49,7 @@ export function registerMessageHandlers(deps) {
47
49
  message: ctx.message,
48
50
  });
49
51
  if (!prompt) {
50
- await ctx.reply("Only image attachments are supported.");
52
+ await replyNotice(ctx, "Only image attachments are supported.");
51
53
  return;
52
54
  }
53
55
  await handleUserInput({
@@ -65,7 +67,7 @@ export function registerMessageHandlers(deps) {
65
67
  ...contextLogFields(ctx),
66
68
  error,
67
69
  });
68
- await ctx.reply(error instanceof Error ? error.message : String(error));
70
+ await replyError(ctx, error instanceof Error ? error.message : String(error));
69
71
  }
70
- });
72
+ }));
71
73
  }
@@ -2,34 +2,38 @@ import { presetFromProfile } from "../../config.js";
2
2
  import { generateBindingCode } from "../../runtime/bindingCodes.js";
3
3
  import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
4
4
  import { refreshSessionIfActiveTurnIsStale } from "../inputService.js";
5
- import { contextLogFields, formatHelpText, formatPrivateStatus, formatProjectStatus, formatReasoningEffort, getProjectForContext, getScopedSession, hasTopicContext, isPrivateChat, parseSubcommand, } from "../commandSupport.js";
5
+ import { contextLogFields, formatHelpText, formatPrivateStatus, formatProjectStatus, formatReasoningEffort, getProjectForContext, hasTopicContext, isPrivateChat, parseSubcommand, requireScopedSession, } from "../commandSupport.js";
6
6
  import { formatIsoTimestamp, sessionLogFields } from "../sessionFlow.js";
7
+ import { codeField, replyDocument, replyError, replyNotice, replyUsage, textField } from "../../telegram/formatted.js";
8
+ import { wrapUserFacingHandler } from "../userFacingErrors.js";
7
9
  export function registerOperationalHandlers(deps) {
8
10
  const { bot, config, store, projects, codex, logger } = deps;
9
- bot.command(["start", "help"], async (ctx) => {
10
- await ctx.reply(formatHelpText(ctx, projects));
11
- });
12
- bot.command("admin", async (ctx) => {
11
+ bot.command(["start", "help"], wrapUserFacingHandler("help", logger, async (ctx) => {
12
+ await replyNotice(ctx, formatHelpText(ctx, projects));
13
+ }));
14
+ bot.command("admin", wrapUserFacingHandler("admin", logger, async (ctx) => {
13
15
  if (!isPrivateChat(ctx)) {
14
- await ctx.reply("Use /admin in the bot private chat.");
16
+ await replyNotice(ctx, "Use /admin in the bot private chat.");
15
17
  return;
16
18
  }
17
19
  const authorizedUserId = store.getAuthorizedUserId();
18
20
  if (authorizedUserId == null) {
19
- await ctx.reply("Admin binding is not completed yet.");
21
+ await replyNotice(ctx, "Admin binding is not completed yet.");
20
22
  return;
21
23
  }
22
24
  const { command } = parseSubcommand(ctx.match.trim());
23
25
  const binding = store.getBindingCodeState();
24
26
  if (!command || command === "status") {
25
- await ctx.reply([
26
- "Admin status",
27
- `authorized telegram user id: ${authorizedUserId}`,
28
- binding?.mode === "rebind"
29
- ? `pending handoff: active until ${formatIsoTimestamp(binding.expiresAt)} (${binding.maxAttempts - binding.attempts} attempts remaining)`
30
- : "pending handoff: none",
31
- "Usage: /admin | /admin rebind | /admin cancel",
32
- ].join("\n"));
27
+ await replyDocument(ctx, {
28
+ title: "Admin status",
29
+ fields: [
30
+ textField("authorized telegram user id", authorizedUserId),
31
+ textField("pending handoff", binding?.mode === "rebind"
32
+ ? `active until ${formatIsoTimestamp(binding.expiresAt)} (${binding.maxAttempts - binding.attempts} attempts remaining)`
33
+ : "none"),
34
+ ],
35
+ footer: "Usage: /admin | /admin rebind | /admin cancel",
36
+ });
33
37
  return;
34
38
  }
35
39
  if (command === "rebind") {
@@ -38,124 +42,133 @@ export function registerOperationalHandlers(deps) {
38
42
  mode: "rebind",
39
43
  issuedByUserId: authorizedUserId,
40
44
  });
41
- await ctx.reply([
42
- "Admin handoff code created.",
43
- `expires at: ${formatIsoTimestamp(next.expiresAt)}`,
44
- `max failed attempts: ${next.maxAttempts}`,
45
- "",
46
- next.code,
47
- "",
48
- "Send this code from the target Telegram account in this bot's private chat to transfer control.",
49
- ].join("\n"));
45
+ await replyDocument(ctx, {
46
+ title: "Admin handoff code created.",
47
+ fields: [
48
+ textField("expires at", formatIsoTimestamp(next.expiresAt)),
49
+ textField("max failed attempts", next.maxAttempts),
50
+ codeField("code", next.code),
51
+ ],
52
+ footer: "Send this code from the target Telegram account in this bot's private chat to transfer control.",
53
+ });
50
54
  return;
51
55
  }
52
56
  if (command === "cancel") {
53
57
  if (binding?.mode !== "rebind") {
54
- await ctx.reply("No pending admin handoff.");
58
+ await replyNotice(ctx, "No pending admin handoff.");
55
59
  return;
56
60
  }
57
61
  store.clearBindingCode();
58
- await ctx.reply("Cancelled the pending admin handoff.");
62
+ await replyNotice(ctx, "Cancelled the pending admin handoff.");
59
63
  return;
60
64
  }
61
- await ctx.reply("Usage: /admin | /admin rebind | /admin cancel");
62
- });
63
- bot.command("status", async (ctx) => {
65
+ await replyUsage(ctx, "/admin | /admin rebind | /admin cancel");
66
+ }));
67
+ bot.command("status", wrapUserFacingHandler("status", logger, async (ctx) => {
64
68
  if (isPrivateChat(ctx)) {
65
- await ctx.reply(formatPrivateStatus(store, projects));
69
+ await replyNotice(ctx, formatPrivateStatus(store, projects));
66
70
  return;
67
71
  }
68
72
  const project = getProjectForContext(ctx, projects);
69
73
  if (!project) {
70
- await ctx.reply("This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.");
74
+ await replyNotice(ctx, "This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.");
71
75
  return;
72
76
  }
73
77
  if (!hasTopicContext(ctx)) {
74
- await ctx.reply(formatProjectStatus(project));
78
+ await replyNotice(ctx, formatProjectStatus(project));
75
79
  return;
76
80
  }
77
- const session = getScopedSession(ctx, store, projects, config);
81
+ const session = await requireScopedSession(ctx, store, projects, config);
78
82
  if (!session)
79
83
  return;
80
84
  const latestSession = await refreshSessionIfActiveTurnIsStale(session, store, codex, bot, logger);
81
85
  const queueDepth = store.getQueuedInputCount(latestSession.sessionKey);
82
86
  const queuedPreview = store.listQueuedInputs(latestSession.sessionKey, 3);
83
87
  const activeRun = codex.getActiveRun(latestSession.sessionKey);
84
- await ctx.reply([
85
- "Status",
86
- `project: ${project.name}`,
87
- `root: ${project.cwd}`,
88
- `thread: ${latestSession.codexThreadId ?? "not created"}`,
89
- `state: ${formatSessionRuntimeStatus(latestSession.runtimeStatus)}`,
90
- `state detail: ${latestSession.runtimeStatusDetail ?? "none"}`,
91
- `state updated: ${formatIsoTimestamp(latestSession.runtimeStatusUpdatedAt)}`,
92
- `active turn: ${latestSession.activeTurnId ?? "none"}`,
93
- `active run: ${activeRun ? formatIsoTimestamp(activeRun.startedAt) : "none"}`,
94
- `active run thread: ${activeRun?.threadId ?? "none"}`,
95
- `active run last event: ${activeRun?.lastEventType ?? "none"}`,
96
- `active run last update: ${activeRun ? formatIsoTimestamp(activeRun.lastEventAt) : "none"}`,
97
- `queue: ${queueDepth}`,
98
- `queue next: ${formatQueuedPreview(queuedPreview)}`,
99
- `cwd: ${latestSession.cwd}`,
100
- `preset: ${presetFromProfile(latestSession)}`,
101
- `sandbox: ${latestSession.sandboxMode}`,
102
- `approval: ${latestSession.approvalPolicy}`,
103
- `network: ${latestSession.networkAccessEnabled ? "on" : "off"}`,
104
- `web: ${latestSession.webSearchMode ?? "codex-default"}`,
105
- `git check: ${latestSession.skipGitRepoCheck ? "skip" : "enforce"}`,
106
- `add dirs: ${latestSession.additionalDirectories.length}`,
107
- `schema: ${latestSession.outputSchema ? "set" : "none"}`,
108
- `model: ${latestSession.model}`,
109
- `effort: ${formatReasoningEffort(latestSession.reasoningEffort)}`,
110
- ].join("\n"));
111
- });
112
- bot.command("queue", async (ctx) => {
113
- const session = getScopedSession(ctx, store, projects, config);
88
+ await replyDocument(ctx, {
89
+ title: "Status",
90
+ fields: [
91
+ codeField("project", project.name),
92
+ codeField("root", project.cwd),
93
+ codeField("thread", latestSession.codexThreadId ?? "not created"),
94
+ textField("state", formatSessionRuntimeStatus(latestSession.runtimeStatus)),
95
+ textField("state detail", latestSession.runtimeStatusDetail ?? "none"),
96
+ textField("state updated", formatIsoTimestamp(latestSession.runtimeStatusUpdatedAt)),
97
+ codeField("active turn", latestSession.activeTurnId ?? "none"),
98
+ textField("active run", activeRun ? formatIsoTimestamp(activeRun.startedAt) : "none"),
99
+ codeField("active run thread", activeRun?.threadId ?? "none"),
100
+ textField("active run last event", activeRun?.lastEventType ?? "none"),
101
+ textField("active run last update", activeRun ? formatIsoTimestamp(activeRun.lastEventAt) : "none"),
102
+ textField("queue", queueDepth),
103
+ textField("queue next", formatQueuedPreview(queuedPreview)),
104
+ codeField("cwd", latestSession.cwd),
105
+ textField("preset", presetFromProfile(latestSession)),
106
+ textField("sandbox", latestSession.sandboxMode),
107
+ textField("approval", latestSession.approvalPolicy),
108
+ textField("network", latestSession.networkAccessEnabled ? "on" : "off"),
109
+ textField("web", latestSession.webSearchMode ?? "codex-default"),
110
+ textField("git check", latestSession.skipGitRepoCheck ? "skip" : "enforce"),
111
+ textField("add dirs", latestSession.additionalDirectories.length),
112
+ textField("schema", latestSession.outputSchema ? "set" : "none"),
113
+ textField("model", latestSession.model),
114
+ textField("effort", formatReasoningEffort(latestSession.reasoningEffort)),
115
+ ],
116
+ });
117
+ }));
118
+ bot.command("queue", wrapUserFacingHandler("queue", logger, async (ctx) => {
119
+ const session = await requireScopedSession(ctx, store, projects, config);
114
120
  if (!session)
115
121
  return;
116
122
  const { command, args } = parseSubcommand(ctx.match.trim());
117
123
  if (!command) {
118
124
  const queued = store.listQueuedInputs(session.sessionKey, 5);
119
125
  const queueDepth = store.getQueuedInputCount(session.sessionKey);
120
- await ctx.reply([
121
- "Queue",
122
- `state: ${formatSessionRuntimeStatus(session.runtimeStatus)}`,
123
- `active turn: ${session.activeTurnId ?? "none"}`,
124
- `queue: ${queueDepth}`,
125
- queued.length > 0 ? `items:\n${formatQueuedItems(queued)}` : "items: none",
126
- "Usage: /queue | /queue drop <id> | /queue clear",
127
- ].join("\n"));
126
+ await replyDocument(ctx, {
127
+ title: "Queue",
128
+ fields: [
129
+ textField("state", formatSessionRuntimeStatus(session.runtimeStatus)),
130
+ codeField("active turn", session.activeTurnId ?? "none"),
131
+ textField("queue", queueDepth),
132
+ ],
133
+ sections: [
134
+ {
135
+ title: "Items",
136
+ lines: queued.length > 0 ? [formatQueuedItems(queued)] : ["none"],
137
+ },
138
+ ],
139
+ footer: "Usage: /queue | /queue drop <id> | /queue clear",
140
+ });
128
141
  return;
129
142
  }
130
143
  if (command === "clear") {
131
144
  const removed = store.clearQueuedInputs(session.sessionKey);
132
- await ctx.reply(`Cleared the queue and removed ${removed} pending message(s).`);
145
+ await replyNotice(ctx, `Cleared the queue and removed ${removed} pending message(s).`);
133
146
  return;
134
147
  }
135
148
  if (command === "drop") {
136
149
  const id = Number(args);
137
150
  if (!Number.isInteger(id) || id <= 0) {
138
- await ctx.reply("Usage: /queue drop <id>");
151
+ await replyUsage(ctx, "/queue drop <id>");
139
152
  return;
140
153
  }
141
154
  const removed = store.removeQueuedInputForSession(session.sessionKey, id);
142
- await ctx.reply(removed ? `Removed queued item #${id}.` : `Queued item #${id} was not found.`);
155
+ await replyNotice(ctx, removed ? `Removed queued item #${id}.` : `Queued item #${id} was not found.`);
143
156
  return;
144
157
  }
145
- await ctx.reply("Usage: /queue | /queue drop <id> | /queue clear");
146
- });
147
- bot.command("stop", async (ctx) => {
148
- const session = getScopedSession(ctx, store, projects, config);
158
+ await replyUsage(ctx, "/queue | /queue drop <id> | /queue clear");
159
+ }));
160
+ bot.command("stop", wrapUserFacingHandler("stop", logger, async (ctx) => {
161
+ const session = await requireScopedSession(ctx, store, projects, config);
149
162
  if (!session)
150
163
  return;
151
164
  const latest = store.get(session.sessionKey) ?? session;
152
165
  if (!codex.isRunning(session.sessionKey)) {
153
- await ctx.reply("There is no active Codex SDK turn right now.");
166
+ await replyNotice(ctx, "There is no active Codex SDK turn right now.");
154
167
  return;
155
168
  }
156
169
  try {
157
170
  codex.interrupt(session.sessionKey);
158
- await ctx.reply("Interrupt requested for the current run. Waiting for Codex SDK to stop.");
171
+ await replyNotice(ctx, "Interrupt requested for the current run. Waiting for Codex SDK to stop.");
159
172
  }
160
173
  catch (error) {
161
174
  logger?.warn("interrupt turn failed", {
@@ -163,9 +176,9 @@ export function registerOperationalHandlers(deps) {
163
176
  ...sessionLogFields(latest),
164
177
  error,
165
178
  });
166
- await ctx.reply(`Interrupt failed: ${error instanceof Error ? error.message : String(error)}`);
179
+ await replyError(ctx, `Interrupt failed: ${error instanceof Error ? error.message : String(error)}`);
167
180
  }
168
- });
181
+ }));
169
182
  }
170
183
  function formatQueuedPreview(items) {
171
184
  if (items.length === 0)