pi-telegram-plus 0.0.2 → 0.0.3

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/lib/controller.ts CHANGED
@@ -12,6 +12,9 @@ import type {
12
12
  TelegramTurn,
13
13
  } from "./types.ts";
14
14
  import type { TelegramUiRuntime } from "./telegram-ui.ts";
15
+ import { log } from "./logger.ts";
16
+
17
+ const ctrlLog = log.child("controller");
15
18
 
16
19
  export type TelegramController = {
17
20
  handleMessage(message: TelegramMessage): Promise<void>;
@@ -221,7 +224,7 @@ export function createTelegramController(deps: {
221
224
  const mode = deps.getMessageMode();
222
225
  if (mode === "steer") {
223
226
  const task = runPrompt(text, chatId, replaceMessageId);
224
- void task.catch(() => undefined);
227
+ void task.catch(ctrlLog.swallow("error", "steer-mode prompt task failed", { chatId }));
225
228
  return;
226
229
  }
227
230
 
@@ -233,7 +236,7 @@ export function createTelegramController(deps: {
233
236
  if (generation !== getInterruptGeneration(chatId)) return;
234
237
  return runPrompt(text, chatId, replaceMessageId);
235
238
  })
236
- .catch(() => undefined);
239
+ .catch(ctrlLog.swallow("error", "queue-mode prompt task failed", { chatId }));
237
240
  setTail(chatId, task);
238
241
  };
239
242
 
@@ -247,11 +250,45 @@ export function createTelegramController(deps: {
247
250
  // prompts (e.g. /new confirmation), and awaiting them would stall the
248
251
  // polling loop and block callback processing for that update.
249
252
  const telegramUi = deps.ui.create(chatId);
253
+ const ctx = session.extensionRunner.createCommandContext();
254
+ // Capture idleness BEFORE the handler runs. Commands like /sisyphus and
255
+ // /goals enqueue the agent turn fire-and-forget via pi.sendUserMessage and
256
+ // return immediately; the actual turn (and any goal_question /
257
+ // propose_goal_draft / goal_questionnaire dialogs it raises) runs AFTER the
258
+ // handler resolves. If we let runWithTelegramUi restore the TUI UI at that
259
+ // point, those dialogs render to the local TUI and Telegram gets nothing.
260
+ // Only hold the swap when the agent was idle when the command arrived: if a
261
+ // local turn was already streaming, holding the swap would hijack the local
262
+ // user's TUI (modals would route to Telegram, editor would no-op).
263
+ const idleFn = typeof (ctx as any).isIdle === "function" ? (ctx as any).isIdle : undefined;
264
+ const waitFn = typeof (ctx as any).waitForIdle === "function" ? (ctx as any).waitForIdle : undefined;
265
+ const wasIdle = idleFn ? idleFn.call(ctx) : false;
250
266
  void runWithTelegramUi({
251
267
  session,
252
268
  ui: telegramUi,
253
- run: async () => handler(args, session.extensionRunner.createCommandContext()),
254
- }).catch(() => undefined);
269
+ run: async () => {
270
+ await handler(args, ctx);
271
+ if (!wasIdle || !idleFn || !waitFn) return;
272
+ // Hold the Telegram UI swap across the command's enqueued turn and any
273
+ // auto-continue chain it spawns. pi-goal schedules continuation turns via
274
+ // setTimeout(0 / 50ms) after a turn ends, so a single waitForIdle()
275
+ // resolves before the next turn starts. After each idle, yield one
276
+ // macrotask window (120ms > pi-goal's 50ms CONTINUATION_IDLE_RETRY_MS) to
277
+ // let a pending continuation begin; if the agent starts streaming again,
278
+ // keep waiting. Stop when the agent stays idle through the window — the
279
+ // chain has drained and the local TUI is fully usable again.
280
+ try {
281
+ for (;;) {
282
+ await waitFn.call(ctx);
283
+ await new Promise((r) => setTimeout(r, 120));
284
+ if (idleFn.call(ctx)) break;
285
+ }
286
+ } catch (err) {
287
+ // Session disposed / torn down during the wait — nothing to do but log.
288
+ ctrlLog.debug("waitForIdle during enqueued turn interrupted", { err });
289
+ }
290
+ },
291
+ }).catch(ctrlLog.swallow("warn", "beginTelegramTurn enqueued-turn wrapper failed"));
255
292
  };
256
293
 
257
294
  const tryHandleSlashCommand = async (text: string, chatId: number): Promise<boolean> => {
@@ -287,7 +324,7 @@ export function createTelegramController(deps: {
287
324
  async handleCallbackQuery(query) {
288
325
  const chatId = query.message?.chat?.id;
289
326
  if (typeof chatId !== "number") return;
290
- await deps.transport.answerCallbackQuery(query.id).catch(() => undefined);
327
+ await deps.transport.answerCallbackQuery(query.id).catch(ctrlLog.swallow("debug", "answerCallbackQuery failed", { queryId: query.id }));
291
328
  if (!(await deps.authorizeUser(query.from?.id))) return;
292
329
  await deps.setActiveChatId(chatId);
293
330
 
@@ -297,15 +334,17 @@ export function createTelegramController(deps: {
297
334
  const result = deps.ui.resolveInput(chatId, uiValue, query.message?.message_id, true);
298
335
  if (!result.handled) {
299
336
  await deps.transport.sendText(chatId, "This prompt is no longer active.");
300
- } else {
301
- // Remove inline keyboards only for terminal UI callbacks. Select
302
- // pagination callbacks continue the same flow and immediately edit the
303
- // same message with the next page of buttons; removing the keyboard here
304
- // can race after that edit and strip the new page buttons.
305
- const isPaginationCallback = /^f:[^:]+:p:\d+$/.test(uiValue);
306
- const promptMessageId = result.promptMessageId ?? query.message?.message_id;
307
- if (!isPaginationCallback && promptMessageId) void deps.transport.removeInlineKeyboard(chatId, promptMessageId);
308
337
  }
338
+ // Keyboard cleanup is OWNED by each UI flow (confirm/input/select and the
339
+ // custom-dialog bridge): each calls removeInlineKeyboard on its own prompt
340
+ // message right before resolving a terminal value (yes/no/cancel/submit/...).
341
+ // The controller must NOT speculatively strip keyboards here. Doing so races
342
+ // with continuation flows (multi-question tab navigation, single-question
343
+ // option toggles, select pagination) that edit the SAME message in place:
344
+ // removeInlineKeyboard and editButtons hit Telegram concurrently on one
345
+ // message, and when the strip lands last the message is left with no
346
+ // buttons — the "answered Q1 but Q2 never appeared" bug. Terminal flows
347
+ // clean up themselves, so no controller-side strip is needed.
309
348
  return;
310
349
  }
311
350
 
@@ -368,7 +407,7 @@ export function createTelegramController(deps: {
368
407
  formatIncomingAttachmentReceipt("saved", downloadedMediaLines),
369
408
  formatIncomingAttachmentReceipt("failed", failedMediaLines),
370
409
  ].filter(Boolean);
371
- await deps.transport.sendText(chatId, lines.join("\n\n")).catch(() => undefined);
410
+ await deps.transport.sendText(chatId, lines.join("\n\n")).catch(ctrlLog.swallow("warn", "sendText media receipt failed", { chatId }));
372
411
  }
373
412
  }
374
413
  const hasPromptInput = hasTextInput || hasMediaInput;