telecodex 0.1.4 → 0.1.6

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.
@@ -2,9 +2,10 @@ import { contextLogFields, requireScopedSession, } from "../commandSupport.js";
2
2
  import { handleUserInput, handleUserText } from "../inputService.js";
3
3
  import { telegramImageMessageToCodexInput } from "../../telegram/attachments.js";
4
4
  import { replyError, replyNotice } from "../../telegram/formatted.js";
5
+ import { wrapUserFacingHandler } from "../userFacingErrors.js";
5
6
  export function registerMessageHandlers(deps) {
6
7
  const { bot, config, store, projects, codex, buffers, logger } = deps;
7
- bot.on("message:text", async (ctx) => {
8
+ bot.on("message:text", wrapUserFacingHandler("message:text", logger, async (ctx) => {
8
9
  const text = ctx.message.text;
9
10
  logger?.info("received telegram text message", {
10
11
  ...contextLogFields(ctx),
@@ -30,8 +31,8 @@ export function registerMessageHandlers(deps) {
30
31
  bot,
31
32
  ...(logger ? { logger } : {}),
32
33
  });
33
- });
34
- bot.on(["message:photo", "message:document"], async (ctx) => {
34
+ }));
35
+ bot.on(["message:photo", "message:document"], wrapUserFacingHandler("message:attachment", logger, async (ctx) => {
35
36
  const session = await requireScopedSession(ctx, store, projects, config);
36
37
  if (!session) {
37
38
  logger?.warn("ignored telegram attachment because no scoped session was available", {
@@ -68,5 +69,5 @@ export function registerMessageHandlers(deps) {
68
69
  });
69
70
  await replyError(ctx, error instanceof Error ? error.message : String(error));
70
71
  }
71
- });
72
+ }));
72
73
  }
@@ -5,12 +5,13 @@ import { refreshSessionIfActiveTurnIsStale } from "../inputService.js";
5
5
  import { contextLogFields, formatHelpText, formatPrivateStatus, formatProjectStatus, formatReasoningEffort, getProjectForContext, hasTopicContext, isPrivateChat, parseSubcommand, requireScopedSession, } from "../commandSupport.js";
6
6
  import { formatIsoTimestamp, sessionLogFields } from "../sessionFlow.js";
7
7
  import { codeField, replyDocument, replyError, replyNotice, replyUsage, textField } from "../../telegram/formatted.js";
8
+ import { wrapUserFacingHandler } from "../userFacingErrors.js";
8
9
  export function registerOperationalHandlers(deps) {
9
10
  const { bot, config, store, projects, codex, logger } = deps;
10
- bot.command(["start", "help"], async (ctx) => {
11
+ bot.command(["start", "help"], wrapUserFacingHandler("help", logger, async (ctx) => {
11
12
  await replyNotice(ctx, formatHelpText(ctx, projects));
12
- });
13
- bot.command("admin", async (ctx) => {
13
+ }));
14
+ bot.command("admin", wrapUserFacingHandler("admin", logger, async (ctx) => {
14
15
  if (!isPrivateChat(ctx)) {
15
16
  await replyNotice(ctx, "Use /admin in the bot private chat.");
16
17
  return;
@@ -62,8 +63,8 @@ export function registerOperationalHandlers(deps) {
62
63
  return;
63
64
  }
64
65
  await replyUsage(ctx, "/admin | /admin rebind | /admin cancel");
65
- });
66
- bot.command("status", async (ctx) => {
66
+ }));
67
+ bot.command("status", wrapUserFacingHandler("status", logger, async (ctx) => {
67
68
  if (isPrivateChat(ctx)) {
68
69
  await replyNotice(ctx, formatPrivateStatus(store, projects));
69
70
  return;
@@ -93,7 +94,6 @@ export function registerOperationalHandlers(deps) {
93
94
  textField("state", formatSessionRuntimeStatus(latestSession.runtimeStatus)),
94
95
  textField("state detail", latestSession.runtimeStatusDetail ?? "none"),
95
96
  textField("state updated", formatIsoTimestamp(latestSession.runtimeStatusUpdatedAt)),
96
- codeField("active turn", latestSession.activeTurnId ?? "none"),
97
97
  textField("active run", activeRun ? formatIsoTimestamp(activeRun.startedAt) : "none"),
98
98
  codeField("active run thread", activeRun?.threadId ?? "none"),
99
99
  textField("active run last event", activeRun?.lastEventType ?? "none"),
@@ -113,8 +113,8 @@ export function registerOperationalHandlers(deps) {
113
113
  textField("effort", formatReasoningEffort(latestSession.reasoningEffort)),
114
114
  ],
115
115
  });
116
- });
117
- bot.command("queue", async (ctx) => {
116
+ }));
117
+ bot.command("queue", wrapUserFacingHandler("queue", logger, async (ctx) => {
118
118
  const session = await requireScopedSession(ctx, store, projects, config);
119
119
  if (!session)
120
120
  return;
@@ -126,7 +126,6 @@ export function registerOperationalHandlers(deps) {
126
126
  title: "Queue",
127
127
  fields: [
128
128
  textField("state", formatSessionRuntimeStatus(session.runtimeStatus)),
129
- codeField("active turn", session.activeTurnId ?? "none"),
130
129
  textField("queue", queueDepth),
131
130
  ],
132
131
  sections: [
@@ -155,8 +154,8 @@ export function registerOperationalHandlers(deps) {
155
154
  return;
156
155
  }
157
156
  await replyUsage(ctx, "/queue | /queue drop <id> | /queue clear");
158
- });
159
- bot.command("stop", async (ctx) => {
157
+ }));
158
+ bot.command("stop", wrapUserFacingHandler("stop", logger, async (ctx) => {
160
159
  const session = await requireScopedSession(ctx, store, projects, config);
161
160
  if (!session)
162
161
  return;
@@ -177,7 +176,7 @@ export function registerOperationalHandlers(deps) {
177
176
  });
178
177
  await replyError(ctx, `Interrupt failed: ${error instanceof Error ? error.message : String(error)}`);
179
178
  }
180
- });
179
+ }));
181
180
  }
182
181
  function formatQueuedPreview(items) {
183
182
  if (items.length === 0)
@@ -2,10 +2,11 @@ import path from "node:path";
2
2
  import { contextLogFields, ensureTopicSession, formatPrivateProjectList, formatProjectStatus, formatTopicName, getProjectForContext, hasTopicContext, isPrivateChat, isSupergroupChat, parseSubcommand, postTopicReadyMessage, requireScopedSession, resolveExistingDirectory, } from "../commandSupport.js";
3
3
  import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
4
4
  import { codeField, replyDocument, replyError, replyNotice, replyUsage, textField } from "../../telegram/formatted.js";
5
+ import { wrapUserFacingHandler } from "../userFacingErrors.js";
5
6
  const PROJECT_REQUIRED_MESSAGE = "This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.";
6
7
  export function registerProjectHandlers(deps) {
7
8
  const { bot, config, store, projects, logger } = deps;
8
- bot.command("project", async (ctx) => {
9
+ bot.command("project", wrapUserFacingHandler("project", logger, async (ctx) => {
9
10
  const { command, args } = parseSubcommand(ctx.match.trim());
10
11
  if (isPrivateChat(ctx)) {
11
12
  if (!command || command === "list" || command === "status") {
@@ -67,8 +68,8 @@ export function registerProjectHandlers(deps) {
67
68
  return;
68
69
  }
69
70
  await replyUsage(ctx, ["/project", "/project bind <absolute-path>", "/project unbind"]);
70
- });
71
- bot.command("thread", async (ctx) => {
71
+ }));
72
+ bot.command("thread", wrapUserFacingHandler("thread", logger, async (ctx) => {
72
73
  if (isPrivateChat(ctx)) {
73
74
  await replyNotice(ctx, "The thread command is only available inside project supergroups.");
74
75
  return;
@@ -112,7 +113,7 @@ export function registerProjectHandlers(deps) {
112
113
  return;
113
114
  }
114
115
  await replyUsage(ctx, ["/thread list", "/thread resume <threadId>", "/thread new <topic-name>"]);
115
- });
116
+ }));
116
117
  bot.on(["message:forum_topic_created", "message:forum_topic_edited"], async (ctx) => {
117
118
  const threadId = ctx.message.message_thread_id;
118
119
  if (threadId == null)
@@ -2,6 +2,7 @@ import { APPROVAL_POLICIES, MODE_PRESETS, REASONING_EFFORTS, SANDBOX_MODES, WEB_
2
2
  import { parseCodexConfigOverrides } from "../../codex/configOverrides.js";
3
3
  import { assertProjectScopedPath, formatProfileReply, formatReasoningEffort, getProjectForContext, requireScopedSession, resolveExistingDirectory, } from "../commandSupport.js";
4
4
  import { codeField, replyDocument, replyError, replyNotice, replyUsage, textField } from "../../telegram/formatted.js";
5
+ import { wrapUserFacingHandler } from "../userFacingErrors.js";
5
6
  export function registerSessionConfigHandlers(deps) {
6
7
  registerDirectoryHandlers(deps);
7
8
  registerProfileHandlers(deps);
@@ -9,8 +10,8 @@ export function registerSessionConfigHandlers(deps) {
9
10
  registerAdvancedHandlers(deps);
10
11
  }
11
12
  function registerDirectoryHandlers(deps) {
12
- const { bot, config, store, projects } = deps;
13
- bot.command("cwd", async (ctx) => {
13
+ const { bot, config, store, projects, logger } = deps;
14
+ bot.command("cwd", wrapUserFacingHandler("cwd", logger, async (ctx) => {
14
15
  const project = getProjectForContext(ctx, projects);
15
16
  const session = await requireScopedSession(ctx, store, projects, config);
16
17
  if (!project || !session)
@@ -34,11 +35,11 @@ function registerDirectoryHandlers(deps) {
34
35
  catch (error) {
35
36
  await replyError(ctx, error instanceof Error ? error.message : String(error));
36
37
  }
37
- });
38
+ }));
38
39
  }
39
40
  function registerProfileHandlers(deps) {
40
- const { bot, config, store, projects } = deps;
41
- bot.command("mode", async (ctx) => {
41
+ const { bot, config, store, projects, logger } = deps;
42
+ bot.command("mode", wrapUserFacingHandler("mode", logger, async (ctx) => {
42
43
  const session = await requireScopedSession(ctx, store, projects, config);
43
44
  if (!session)
44
45
  return;
@@ -59,8 +60,8 @@ function registerProfileHandlers(deps) {
59
60
  store.setSandboxMode(session.sessionKey, profile.sandboxMode);
60
61
  store.setApprovalPolicy(session.sessionKey, profile.approvalPolicy);
61
62
  await replyNotice(ctx, formatProfileReply("Preset updated.", profile.sandboxMode, profile.approvalPolicy));
62
- });
63
- bot.command("sandbox", async (ctx) => {
63
+ }));
64
+ bot.command("sandbox", wrapUserFacingHandler("sandbox", logger, async (ctx) => {
64
65
  const session = await requireScopedSession(ctx, store, projects, config);
65
66
  if (!session)
66
67
  return;
@@ -75,8 +76,8 @@ function registerProfileHandlers(deps) {
75
76
  }
76
77
  store.setSandboxMode(session.sessionKey, sandboxMode);
77
78
  await replyNotice(ctx, formatProfileReply("Sandbox updated.", sandboxMode, session.approvalPolicy));
78
- });
79
- bot.command("approval", async (ctx) => {
79
+ }));
80
+ bot.command("approval", wrapUserFacingHandler("approval", logger, async (ctx) => {
80
81
  const session = await requireScopedSession(ctx, store, projects, config);
81
82
  if (!session)
82
83
  return;
@@ -91,8 +92,8 @@ function registerProfileHandlers(deps) {
91
92
  }
92
93
  store.setApprovalPolicy(session.sessionKey, approvalPolicy);
93
94
  await replyNotice(ctx, formatProfileReply("Approval policy updated.", session.sandboxMode, approvalPolicy));
94
- });
95
- bot.command("yolo", async (ctx) => {
95
+ }));
96
+ bot.command("yolo", wrapUserFacingHandler("yolo", logger, async (ctx) => {
96
97
  const session = await requireScopedSession(ctx, store, projects, config);
97
98
  if (!session)
98
99
  return;
@@ -110,11 +111,11 @@ function registerProfileHandlers(deps) {
110
111
  store.setSandboxMode(session.sessionKey, profile.sandboxMode);
111
112
  store.setApprovalPolicy(session.sessionKey, profile.approvalPolicy);
112
113
  await replyNotice(ctx, formatProfileReply(value === "on" ? "YOLO enabled." : "YOLO disabled. Restored the write preset.", profile.sandboxMode, profile.approvalPolicy));
113
- });
114
+ }));
114
115
  }
115
116
  function registerExecutionHandlers(deps) {
116
- const { bot, config, store, projects } = deps;
117
- bot.command("model", async (ctx) => {
117
+ const { bot, config, store, projects, logger } = deps;
118
+ bot.command("model", wrapUserFacingHandler("model", logger, async (ctx) => {
118
119
  const session = await requireScopedSession(ctx, store, projects, config);
119
120
  if (!session)
120
121
  return;
@@ -125,8 +126,8 @@ function registerExecutionHandlers(deps) {
125
126
  }
126
127
  store.setModel(session.sessionKey, model);
127
128
  await replyNotice(ctx, `Set model: ${model}`);
128
- });
129
- bot.command("effort", async (ctx) => {
129
+ }));
130
+ bot.command("effort", wrapUserFacingHandler("effort", logger, async (ctx) => {
130
131
  const session = await requireScopedSession(ctx, store, projects, config);
131
132
  if (!session)
132
133
  return;
@@ -141,8 +142,8 @@ function registerExecutionHandlers(deps) {
141
142
  }
142
143
  store.setReasoningEffort(session.sessionKey, value === "default" ? null : value);
143
144
  await replyNotice(ctx, `Set reasoning effort: ${value === "default" ? "codex-default" : value}`);
144
- });
145
- bot.command("web", async (ctx) => {
145
+ }));
146
+ bot.command("web", wrapUserFacingHandler("web", logger, async (ctx) => {
146
147
  const session = await requireScopedSession(ctx, store, projects, config);
147
148
  if (!session)
148
149
  return;
@@ -157,8 +158,8 @@ function registerExecutionHandlers(deps) {
157
158
  }
158
159
  store.setWebSearchMode(session.sessionKey, value === "default" ? null : value);
159
160
  await replyNotice(ctx, `Set web search: ${value === "default" ? "codex-default" : value}`);
160
- });
161
- bot.command("network", async (ctx) => {
161
+ }));
162
+ bot.command("network", wrapUserFacingHandler("network", logger, async (ctx) => {
162
163
  const session = await requireScopedSession(ctx, store, projects, config);
163
164
  if (!session)
164
165
  return;
@@ -173,8 +174,8 @@ function registerExecutionHandlers(deps) {
173
174
  }
174
175
  store.setNetworkAccessEnabled(session.sessionKey, value === "on");
175
176
  await replyNotice(ctx, `Set network access: ${value}`);
176
- });
177
- bot.command("gitcheck", async (ctx) => {
177
+ }));
178
+ bot.command("gitcheck", wrapUserFacingHandler("gitcheck", logger, async (ctx) => {
178
179
  const session = await requireScopedSession(ctx, store, projects, config);
179
180
  if (!session)
180
181
  return;
@@ -189,11 +190,11 @@ function registerExecutionHandlers(deps) {
189
190
  }
190
191
  store.setSkipGitRepoCheck(session.sessionKey, value === "skip");
191
192
  await replyNotice(ctx, `Set git repo check: ${value}`);
192
- });
193
+ }));
193
194
  }
194
195
  function registerAdvancedHandlers(deps) {
195
- const { bot, config, store, projects, codex } = deps;
196
- bot.command("adddir", async (ctx) => {
196
+ const { bot, config, store, projects, codex, logger } = deps;
197
+ bot.command("adddir", wrapUserFacingHandler("adddir", logger, async (ctx) => {
197
198
  const project = getProjectForContext(ctx, projects);
198
199
  const session = await requireScopedSession(ctx, store, projects, config);
199
200
  if (!project || !session)
@@ -275,8 +276,8 @@ function registerAdvancedHandlers(deps) {
275
276
  return;
276
277
  }
277
278
  await replyUsage(ctx, "/adddir list | /adddir add <path-inside-project> | /adddir add-external <absolute-path> | /adddir drop <index> | /adddir clear");
278
- });
279
- bot.command("schema", async (ctx) => {
279
+ }));
280
+ bot.command("schema", wrapUserFacingHandler("schema", logger, async (ctx) => {
280
281
  const session = await requireScopedSession(ctx, store, projects, config);
281
282
  if (!session)
282
283
  return;
@@ -316,8 +317,8 @@ function registerAdvancedHandlers(deps) {
316
317
  catch (error) {
317
318
  await replyError(ctx, `Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`);
318
319
  }
319
- });
320
- bot.command("codexconfig", async (ctx) => {
320
+ }));
321
+ bot.command("codexconfig", wrapUserFacingHandler("codexconfig", logger, async (ctx) => {
321
322
  const raw = ctx.match.trim();
322
323
  if (!raw || raw === "show") {
323
324
  const current = store.getAppState("codex_config_overrides");
@@ -353,7 +354,7 @@ function registerAdvancedHandlers(deps) {
353
354
  catch (error) {
354
355
  await replyError(ctx, error instanceof Error ? error.message : String(error));
355
356
  }
356
- });
357
+ }));
357
358
  }
358
359
  function isPlainObject(value) {
359
360
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -32,7 +32,7 @@ export async function handleUserInput(input) {
32
32
  chatId: numericChatId(session),
33
33
  messageThreadId: numericMessageThreadId(session),
34
34
  }, [
35
- `The current Codex task is still ${describeBusyStatus(session.runtimeStatus)}. Your message was added to the queue.`,
35
+ `Codex is still ${describeBusyStatus(session.runtimeStatus)}. Your message was added to the queue.`,
36
36
  `queue position: ${queueDepth}`,
37
37
  `queued at: ${formatIsoTimestamp(queued.createdAt)}`,
38
38
  "It will be processed automatically after the current run finishes.",
@@ -48,7 +48,7 @@ export async function handleUserInput(input) {
48
48
  sessionKey: session.sessionKey,
49
49
  event: {
50
50
  type: "turn.preparing",
51
- detail: "starting codex sdk run",
51
+ detail: "waiting for first Codex SDK event",
52
52
  },
53
53
  logger,
54
54
  });
@@ -78,17 +78,6 @@ export async function handleUserInput(input) {
78
78
  };
79
79
  }
80
80
  store.setOutputMessage(session.sessionKey, outputMessageId);
81
- const turnId = createLocalTurnId();
82
- await applySessionRuntimeEvent({
83
- bot,
84
- store,
85
- sessionKey: session.sessionKey,
86
- event: {
87
- type: "turn.started",
88
- turnId,
89
- },
90
- logger,
91
- });
92
81
  void runSessionPrompt({
93
82
  sessionKey: session.sessionKey,
94
83
  prompt,
@@ -96,7 +85,6 @@ export async function handleUserInput(input) {
96
85
  codex,
97
86
  buffers,
98
87
  bot,
99
- turnId,
100
88
  bufferKey,
101
89
  ...(logger ? { logger } : {}),
102
90
  });
@@ -118,7 +106,6 @@ export async function refreshSessionIfActiveTurnIsStale(session, store, codex, b
118
106
  sessionKey: latest.sessionKey,
119
107
  event: {
120
108
  type: "turn.failed",
121
- turnId: latest.activeTurnId,
122
109
  message: "The previous run was lost. Send the message again.",
123
110
  },
124
111
  logger,
@@ -179,10 +166,13 @@ export async function processNextQueuedInputForSession(sessionKey, store, codex,
179
166
  }
180
167
  }
181
168
  async function runSessionPrompt(input) {
182
- const { sessionKey, prompt, store, codex, buffers, bot, turnId, bufferKey, logger } = input;
169
+ const { sessionKey, prompt, store, codex, buffers, bot, bufferKey, logger } = input;
183
170
  const session = store.get(sessionKey);
184
171
  if (!session)
185
172
  return;
173
+ logger?.info("starting codex sdk run", {
174
+ ...sessionLogFields(session),
175
+ });
186
176
  try {
187
177
  const result = await codex.run({
188
178
  profile: {
@@ -203,8 +193,19 @@ async function runSessionPrompt(input) {
203
193
  callbacks: {
204
194
  onThreadStarted: async (threadId) => {
205
195
  store.bindThread(sessionKey, threadId);
196
+ logger?.info("codex sdk thread started", {
197
+ sessionKey,
198
+ threadId,
199
+ });
206
200
  },
207
201
  onEvent: async (event) => {
202
+ await applyRuntimeStateForSdkEvent({
203
+ event,
204
+ sessionKey,
205
+ store,
206
+ bot,
207
+ logger,
208
+ });
208
209
  await projectEventToTelegramBuffer(buffers, bufferKey, event);
209
210
  },
210
211
  },
@@ -219,11 +220,14 @@ async function runSessionPrompt(input) {
219
220
  sessionKey,
220
221
  event: {
221
222
  type: "turn.completed",
222
- turnId,
223
223
  },
224
224
  logger,
225
225
  });
226
226
  }
227
+ logger?.info("codex sdk run completed", {
228
+ sessionKey,
229
+ threadId: result.threadId,
230
+ });
227
231
  await buffers.complete(bufferKey, result.finalResponse || undefined);
228
232
  }
229
233
  catch (error) {
@@ -237,16 +241,18 @@ async function runSessionPrompt(input) {
237
241
  event: isAbortError(error)
238
242
  ? {
239
243
  type: "turn.interrupted",
240
- turnId,
241
244
  }
242
245
  : {
243
246
  type: "turn.failed",
244
- turnId,
245
247
  message: error instanceof Error ? error.message : String(error),
246
248
  },
247
249
  logger,
248
250
  });
249
251
  }
252
+ logger?.warn("codex sdk run failed", {
253
+ sessionKey,
254
+ error,
255
+ });
250
256
  if (isAbortError(error)) {
251
257
  await buffers.fail(bufferKey, "Current run interrupted.");
252
258
  }
@@ -258,13 +264,33 @@ async function runSessionPrompt(input) {
258
264
  await processNextQueuedInputForSession(sessionKey, store, codex, buffers, bot, logger);
259
265
  }
260
266
  }
267
+ async function applyRuntimeStateForSdkEvent(input) {
268
+ if (input.event.type !== "turn.started") {
269
+ return;
270
+ }
271
+ const session = input.store.get(input.sessionKey);
272
+ if (!session)
273
+ return;
274
+ if (session.runtimeStatus === "running") {
275
+ return;
276
+ }
277
+ await applySessionRuntimeEvent({
278
+ bot: input.bot,
279
+ store: input.store,
280
+ sessionKey: input.sessionKey,
281
+ event: {
282
+ type: "turn.started",
283
+ },
284
+ logger: input.logger,
285
+ });
286
+ }
261
287
  async function projectEventToTelegramBuffer(buffers, key, event) {
262
288
  switch (event.type) {
263
289
  case "thread.started":
264
290
  buffers.note(key, `thread started: ${event.thread_id}`);
265
291
  return;
266
292
  case "turn.started":
267
- buffers.note(key, "started processing");
293
+ buffers.markTurnStarted(key);
268
294
  return;
269
295
  case "turn.completed":
270
296
  buffers.note(key, `token usage: in ${event.usage.input_tokens}, out ${event.usage.output_tokens}, cached ${event.usage.cached_input_tokens}`);
@@ -357,9 +383,6 @@ function projectTodoList(buffers, key, item) {
357
383
  buffers.setPlan(key, lines.join("\n"));
358
384
  }
359
385
  }
360
- function createLocalTurnId() {
361
- return `sdk-turn-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
362
- }
363
386
  function toSdkInput(input) {
364
387
  return input;
365
388
  }
@@ -31,11 +31,10 @@ export function sessionLogFields(session) {
31
31
  runtimeStatus: session.runtimeStatus,
32
32
  runtimeStatusDetail: session.runtimeStatusDetail,
33
33
  codexThreadId: session.codexThreadId,
34
- activeTurnId: session.activeTurnId,
35
34
  };
36
35
  }
37
36
  export function isSessionBusy(session) {
38
- return session.runtimeStatus === "preparing" || session.runtimeStatus === "running" || session.activeTurnId != null;
37
+ return session.runtimeStatus === "preparing" || session.runtimeStatus === "running";
39
38
  }
40
39
  export function describeBusyStatus(status) {
41
40
  switch (status) {
@@ -0,0 +1,59 @@
1
+ import { GrammyError, HttpError } from "grammy";
2
+ import { replyError } from "../telegram/formatted.js";
3
+ import { contextLogFields } from "./commandSupport.js";
4
+ export function wrapUserFacingHandler(handlerName, logger, handler) {
5
+ return async (ctx) => {
6
+ try {
7
+ await handler(ctx);
8
+ }
9
+ catch (error) {
10
+ logger?.error("telegram handler failed", {
11
+ handler: handlerName,
12
+ ...contextLogFields(ctx),
13
+ error,
14
+ });
15
+ await safeReplyError(ctx, describeUserFacingError(error), logger, handlerName, error);
16
+ }
17
+ };
18
+ }
19
+ async function safeReplyError(ctx, message, logger, handlerName, originalError) {
20
+ try {
21
+ await replyError(ctx, message);
22
+ }
23
+ catch (replyFailure) {
24
+ logger?.error("failed to send telegram handler error reply", {
25
+ handler: handlerName,
26
+ ...contextLogFields(ctx),
27
+ error: replyFailure,
28
+ originalError,
29
+ });
30
+ }
31
+ }
32
+ function describeUserFacingError(error) {
33
+ if (error instanceof GrammyError) {
34
+ return describeTelegramError(error);
35
+ }
36
+ if (error instanceof HttpError) {
37
+ return "Telegram request failed before it reached the API. Check the local network and try again.";
38
+ }
39
+ if (error instanceof Error) {
40
+ return error.message;
41
+ }
42
+ return String(error);
43
+ }
44
+ function describeTelegramError(error) {
45
+ const description = error.description || error.message;
46
+ const normalized = description.toLowerCase();
47
+ if (error.method === "createForumTopic") {
48
+ if (normalized.includes("not enough rights")) {
49
+ return "Telegram rejected topic creation because the bot lacks permission to create topics. Promote the bot to admin and grant topic management, then try again.";
50
+ }
51
+ if (normalized.includes("forum is disabled") ||
52
+ normalized.includes("chat is not a forum") ||
53
+ normalized.includes("topics are not enabled")) {
54
+ return "This supergroup does not currently allow forum topics. Enable topics for the group and try again.";
55
+ }
56
+ return `Failed to create the Telegram topic: ${description}`;
57
+ }
58
+ return description;
59
+ }
@@ -86,7 +86,12 @@ export class CodexSdkRuntime {
86
86
  let finalResponse = "";
87
87
  let usage = null;
88
88
  let threadId = input.initialThreadId;
89
- for await (const event of streamed.events) {
89
+ const iterator = streamed.events[Symbol.asyncIterator]();
90
+ while (true) {
91
+ const next = await iterator.next();
92
+ if (next.done)
93
+ break;
94
+ const event = next.value;
90
95
  const activeRun = this.activeRuns.get(input.sessionKey);
91
96
  if (activeRun) {
92
97
  activeRun.lastEventAt = new Date().toISOString();
@@ -13,14 +13,12 @@ export function reduceSessionRuntimeState(session, event, updatedAt = new Date()
13
13
  status: "preparing",
14
14
  detail: event.detail ?? null,
15
15
  updatedAt,
16
- activeTurnId: null,
17
16
  };
18
17
  case "turn.started":
19
18
  return {
20
19
  status: "running",
21
20
  detail: null,
22
21
  updatedAt,
23
- activeTurnId: event.turnId,
24
22
  };
25
23
  case "turn.completed":
26
24
  case "turn.interrupted":
@@ -28,14 +26,12 @@ export function reduceSessionRuntimeState(session, event, updatedAt = new Date()
28
26
  status: "idle",
29
27
  detail: null,
30
28
  updatedAt,
31
- activeTurnId: null,
32
29
  };
33
30
  case "turn.failed":
34
31
  return {
35
32
  status: "failed",
36
33
  detail: event.message?.trim() || null,
37
34
  updatedAt,
38
- activeTurnId: null,
39
35
  };
40
36
  }
41
37
  }
@@ -320,7 +320,6 @@ function mapStoredSession(stored, runtimeState, outputMessageId) {
320
320
  status: "idle",
321
321
  detail: null,
322
322
  updatedAt: stored.updatedAt,
323
- activeTurnId: null,
324
323
  };
325
324
  return {
326
325
  ...stored,
@@ -328,7 +327,6 @@ function mapStoredSession(stored, runtimeState, outputMessageId) {
328
327
  runtimeStatus: runtime.status,
329
328
  runtimeStatusDetail: runtime.detail,
330
329
  runtimeStatusUpdatedAt: runtime.updatedAt,
331
- activeTurnId: runtime.activeTurnId,
332
330
  outputMessageId: outputMessageId ?? null,
333
331
  };
334
332
  }
@@ -1,13 +1,25 @@
1
- import { GrammyError } from "grammy";
1
+ import { GrammyError, HttpError } from "grammy";
2
2
  import { renderPlainChunksForTelegram } from "./renderer.js";
3
3
  import { splitTelegramHtml } from "./splitMessage.js";
4
4
  const telegramCooldownByClient = new WeakMap();
5
+ const MAX_TELEGRAM_RETRY_ATTEMPTS = 5;
6
+ const TELEGRAM_NETWORK_RETRY_BASE_MS = 100;
7
+ const TELEGRAM_NETWORK_RETRY_MAX_MS = 1_000;
8
+ const RETRYABLE_NETWORK_ERROR_CODES = new Set([
9
+ "ECONNRESET",
10
+ "ECONNREFUSED",
11
+ "EPIPE",
12
+ "ETIMEDOUT",
13
+ "ESOCKETTIMEDOUT",
14
+ "ENOTFOUND",
15
+ "EAI_AGAIN",
16
+ ]);
5
17
  export async function sendHtmlMessage(bot, input, logger) {
6
18
  return retryTelegramCall(bot.api, () => bot.api.sendMessage(input.chatId, input.text, {
7
19
  ...(input.messageThreadId == null ? {} : { message_thread_id: input.messageThreadId }),
8
20
  parse_mode: "HTML",
9
21
  link_preview_options: { is_disabled: true },
10
- }), logger, "telegram send rate limited", {
22
+ }), logger, "telegram send retry scheduled", {
11
23
  chatId: input.chatId,
12
24
  messageThreadId: input.messageThreadId,
13
25
  });
@@ -29,9 +41,11 @@ export async function sendPlainChunks(bot, input, logger) {
29
41
  export async function sendTypingAction(bot, input, logger) {
30
42
  await retryTelegramCall(bot.api, () => bot.api.sendChatAction(input.chatId, "typing", {
31
43
  ...(input.messageThreadId == null ? {} : { message_thread_id: input.messageThreadId }),
32
- }), logger, "telegram chat action rate limited", {
44
+ }), logger, "telegram chat action retry scheduled", {
33
45
  chatId: input.chatId,
34
46
  messageThreadId: input.messageThreadId,
47
+ }, {
48
+ allowNetworkRetry: true,
35
49
  });
36
50
  }
37
51
  export async function replaceOrSendHtmlChunks(bot, input, logger) {
@@ -84,9 +98,11 @@ export async function editHtmlMessage(bot, input, logger) {
84
98
  await retryTelegramCall(bot.api, () => bot.api.editMessageText(input.chatId, input.messageId, input.text, {
85
99
  parse_mode: "HTML",
86
100
  link_preview_options: { is_disabled: true },
87
- }), logger, "telegram edit rate limited", {
101
+ }), logger, "telegram edit retry scheduled", {
88
102
  chatId: input.chatId,
89
103
  messageId: input.messageId,
104
+ }, {
105
+ allowNetworkRetry: true,
90
106
  });
91
107
  }
92
108
  export function isMessageNotModifiedError(error) {
@@ -113,29 +129,90 @@ function retryAfterMs(error) {
113
129
  }
114
130
  return null;
115
131
  }
132
+ function retryPlan(error, attempt) {
133
+ const rateLimitDelayMs = retryAfterMs(error);
134
+ if (rateLimitDelayMs != null) {
135
+ return {
136
+ kind: "rate-limit",
137
+ delayMs: rateLimitDelayMs + 250,
138
+ };
139
+ }
140
+ if (isRetryableNetworkError(error)) {
141
+ return {
142
+ kind: "network",
143
+ delayMs: Math.min(TELEGRAM_NETWORK_RETRY_BASE_MS * 2 ** attempt, TELEGRAM_NETWORK_RETRY_MAX_MS),
144
+ };
145
+ }
146
+ return null;
147
+ }
116
148
  function descriptionOf(error) {
117
149
  return typeof error.description === "string" ? error.description : null;
118
150
  }
119
- export async function retryTelegramCall(cooldownKey, operation, logger, message, context) {
151
+ function isRetryableNetworkError(error) {
152
+ if (!(error instanceof HttpError))
153
+ return false;
154
+ return isRetryableNetworkCause(error.error);
155
+ }
156
+ function isRetryableNetworkCause(error) {
157
+ if (error instanceof Error && error.name === "AbortError") {
158
+ return false;
159
+ }
160
+ const code = readErrorCode(error);
161
+ if (code && RETRYABLE_NETWORK_ERROR_CODES.has(code)) {
162
+ return true;
163
+ }
164
+ const message = readErrorMessage(error);
165
+ if (!message)
166
+ return false;
167
+ return (message.includes("socket hang up") ||
168
+ message.includes("connection reset") ||
169
+ message.includes("network request failed") ||
170
+ message.includes("fetch failed") ||
171
+ message.includes("timed out") ||
172
+ message.includes("timeout"));
173
+ }
174
+ function readErrorCode(error) {
175
+ if (typeof error !== "object" || error == null || !("code" in error))
176
+ return null;
177
+ const code = error.code;
178
+ return typeof code === "string" && code ? code : null;
179
+ }
180
+ function readErrorMessage(error) {
181
+ if (error instanceof Error) {
182
+ return error.message.toLowerCase();
183
+ }
184
+ if (typeof error === "string") {
185
+ return error.toLowerCase();
186
+ }
187
+ return null;
188
+ }
189
+ export async function retryTelegramCall(cooldownKey, operation, logger, message, context, options) {
120
190
  for (let attempt = 0;; attempt += 1) {
121
191
  await waitForTelegramCooldown(cooldownKey);
122
192
  try {
123
193
  return await operation();
124
194
  }
125
195
  catch (error) {
126
- const waitMs = retryAfterMs(error);
127
- if (waitMs == null || attempt >= 5) {
196
+ const rateLimitDelayMs = retryAfterMs(error);
197
+ const retry = options?.allowNetworkRetry === true
198
+ ? retryPlan(error, attempt)
199
+ : rateLimitDelayMs == null
200
+ ? null
201
+ : {
202
+ kind: "rate-limit",
203
+ delayMs: rateLimitDelayMs + 250,
204
+ };
205
+ if (retry == null || attempt >= MAX_TELEGRAM_RETRY_ATTEMPTS) {
128
206
  throw error;
129
207
  }
130
- const cooldownMs = waitMs + 250;
131
208
  logger?.warn(message, {
132
209
  ...context,
133
210
  attempt: attempt + 1,
134
- retryAfterMs: waitMs,
135
- sharedCooldownMs: cooldownMs,
211
+ retryKind: retry.kind,
212
+ retryDelayMs: retry.delayMs,
136
213
  error,
137
214
  });
138
- await applyTelegramCooldown(cooldownKey, cooldownMs);
215
+ await applyTelegramCooldown(cooldownKey, retry.delayMs);
139
216
  }
140
217
  }
141
218
  }
@@ -1,15 +1,20 @@
1
1
  import { editHtmlMessage, isMessageNotModifiedError, replaceOrSendHtmlChunks, sendHtmlMessage, sendTypingAction, shouldFallbackToNewMessage, } from "./delivery.js";
2
2
  import { renderMarkdownForTelegram, renderPlainChunksForTelegram, renderPlainForTelegram } from "./renderer.js";
3
- const ACTIVITY_PULSE_INTERVAL_MS = 4_000;
3
+ const DEFAULT_ACTIVITY_PULSE_INTERVAL_MS = 4_000;
4
+ const DEFAULT_ACTIVITY_IDLE_MS = 60_000;
4
5
  export class MessageBuffer {
5
6
  bot;
6
7
  updateIntervalMs;
7
8
  logger;
8
9
  states = new Map();
9
- constructor(bot, updateIntervalMs, logger) {
10
+ activityPulseIntervalMs;
11
+ activityIdleMs;
12
+ constructor(bot, updateIntervalMs, logger, input) {
10
13
  this.bot = bot;
11
14
  this.updateIntervalMs = updateIntervalMs;
12
15
  this.logger = logger;
16
+ this.activityPulseIntervalMs = input?.activityPulseIntervalMs ?? DEFAULT_ACTIVITY_PULSE_INTERVAL_MS;
17
+ this.activityIdleMs = input?.activityIdleMs ?? DEFAULT_ACTIVITY_IDLE_MS;
13
18
  }
14
19
  async create(key, input) {
15
20
  const previous = this.states.get(key);
@@ -22,12 +27,13 @@ export class MessageBuffer {
22
27
  const message = await sendHtmlMessage(this.bot, {
23
28
  chatId: input.chatId,
24
29
  messageThreadId: input.messageThreadId,
25
- text: "Codex is working...",
30
+ text: "Starting Codex...",
26
31
  }, this.logger);
27
32
  const state = {
28
33
  chatId: input.chatId,
29
34
  messageThreadId: input.messageThreadId,
30
35
  messageId: message.message_id,
36
+ phase: "starting",
31
37
  text: "",
32
38
  progressLines: [],
33
39
  planText: "",
@@ -36,6 +42,7 @@ export class MessageBuffer {
36
42
  timer: null,
37
43
  activityTimer: null,
38
44
  activityInFlight: false,
45
+ lastActivityAt: Date.now(),
39
46
  lastSentText: "",
40
47
  queue: Promise.resolve(),
41
48
  };
@@ -51,6 +58,7 @@ export class MessageBuffer {
51
58
  if (!state)
52
59
  return;
53
60
  state.text = text;
61
+ this.touchActivity(state);
54
62
  this.scheduleFlush(key, state);
55
63
  }
56
64
  note(key, line) {
@@ -68,6 +76,17 @@ export class MessageBuffer {
68
76
  if (state.progressLines.length > 8) {
69
77
  state.progressLines.splice(0, state.progressLines.length - 8);
70
78
  }
79
+ this.touchActivity(state);
80
+ this.scheduleFlush(key, state);
81
+ }
82
+ markTurnStarted(key) {
83
+ const state = this.states.get(key);
84
+ if (!state)
85
+ return;
86
+ if (state.phase === "running")
87
+ return;
88
+ state.phase = "running";
89
+ this.touchActivity(state);
71
90
  this.scheduleFlush(key, state);
72
91
  }
73
92
  setPlan(key, text) {
@@ -75,6 +94,7 @@ export class MessageBuffer {
75
94
  if (!state)
76
95
  return;
77
96
  state.planText = text.trim();
97
+ this.touchActivity(state);
78
98
  this.scheduleFlush(key, state);
79
99
  }
80
100
  setReasoningSummary(key, text) {
@@ -82,6 +102,7 @@ export class MessageBuffer {
82
102
  if (!state)
83
103
  return;
84
104
  state.reasoningSummaryText = text.trim();
105
+ this.touchActivity(state);
85
106
  this.scheduleFlush(key, state);
86
107
  }
87
108
  setToolOutput(key, text) {
@@ -89,6 +110,7 @@ export class MessageBuffer {
89
110
  if (!state)
90
111
  return;
91
112
  state.toolOutputText = truncateTail(text.replace(/\r/g, "").trim(), 2000);
113
+ this.touchActivity(state);
92
114
  this.scheduleFlush(key, state);
93
115
  }
94
116
  rename(from, to) {
@@ -144,7 +166,7 @@ export class MessageBuffer {
144
166
  const latest = this.states.get(key);
145
167
  if (!latest)
146
168
  return;
147
- const text = renderPlainForTelegram(truncateForEdit(composePendingText(latest)));
169
+ const text = renderPlainForTelegram(truncateForEdit(composePendingText(latest), latest.phase));
148
170
  if (text === latest.lastSentText)
149
171
  return;
150
172
  await this.safeEdit(latest, text);
@@ -193,10 +215,12 @@ export class MessageBuffer {
193
215
  }
194
216
  }
195
217
  startActivityPulse(state) {
218
+ if (state.activityTimer)
219
+ return;
196
220
  void this.sendActivityPulse(state);
197
221
  const timer = setInterval(() => {
198
222
  void this.sendActivityPulse(state);
199
- }, ACTIVITY_PULSE_INTERVAL_MS);
223
+ }, this.activityPulseIntervalMs);
200
224
  timer.unref?.();
201
225
  state.activityTimer = timer;
202
226
  }
@@ -207,6 +231,10 @@ export class MessageBuffer {
207
231
  state.activityTimer = null;
208
232
  }
209
233
  async sendActivityPulse(state) {
234
+ if (Date.now() - state.lastActivityAt >= this.activityIdleMs) {
235
+ this.stopActivityPulse(state);
236
+ return;
237
+ }
210
238
  if (state.activityInFlight)
211
239
  return;
212
240
  state.activityInFlight = true;
@@ -227,6 +255,12 @@ export class MessageBuffer {
227
255
  state.activityInFlight = false;
228
256
  }
229
257
  }
258
+ touchActivity(state) {
259
+ state.lastActivityAt = Date.now();
260
+ if (!state.activityTimer) {
261
+ this.startActivityPulse(state);
262
+ }
263
+ }
230
264
  async replaceWithChunks(state, chunks) {
231
265
  const messageId = await replaceOrSendHtmlChunks(this.bot, {
232
266
  chatId: state.chatId,
@@ -248,13 +282,13 @@ export class MessageBuffer {
248
282
  await run;
249
283
  }
250
284
  }
251
- function truncateForEdit(text) {
285
+ function truncateForEdit(text, phase) {
252
286
  if (text.length <= 3800)
253
- return text || "Codex is working...";
287
+ return text || pendingBanner(phase);
254
288
  return `${text.slice(0, 3800)}\n\n...`;
255
289
  }
256
290
  function composePendingText(state) {
257
- const sections = ["Codex is working..."];
291
+ const sections = [pendingBanner(state.phase)];
258
292
  if (state.planText) {
259
293
  sections.push(`[Plan]\n${state.planText}`);
260
294
  }
@@ -275,6 +309,9 @@ function composePendingText(state) {
275
309
  }
276
310
  return sections.join("\n\n");
277
311
  }
312
+ function pendingBanner(phase) {
313
+ return phase === "running" ? "Codex is working..." : "Starting Codex...";
314
+ }
278
315
  function truncateTail(text, maxLength) {
279
316
  if (text.length <= maxLength)
280
317
  return text;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "telecodex",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Telegram bridge for local Codex.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -52,7 +52,7 @@
52
52
  "@grammyjs/parse-mode": "^2.3.0",
53
53
  "@grammyjs/runner": "^2.0.3",
54
54
  "@napi-rs/keyring": "^1.2.0",
55
- "@openai/codex-sdk": "^0.120.0",
55
+ "@openai/codex-sdk": "^0.121.0",
56
56
  "clipboardy": "^4.0.0",
57
57
  "grammy": "^1.42.0",
58
58
  "markdown-it": "^14.1.0",