codeep 3.0.0 → 3.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.
@@ -88,6 +88,12 @@ export interface ConfigSchema {
88
88
  * and waiting on an answer that cannot come would hang CI. The bot token
89
89
  * lives in the keychain, never here. */
90
90
  telegramApproval: boolean;
91
+ /** Whether a message from the configured chat becomes a prompt.
92
+ *
93
+ * Separate from telegramApproval on purpose. Approval lets the phone answer
94
+ * a question the agent already chose to ask; this lets the phone ask one,
95
+ * which is a keyboard attached to this machine. Off unless asked for. */
96
+ telegramInbox: boolean;
91
97
  /** The single chat allowed to answer. Not a secret — it identifies a
92
98
  * conversation, and it is useless without the token. */
93
99
  telegramChatId: string;
@@ -152,6 +152,7 @@ function createConfig() {
152
152
  customBaseUrl: '',
153
153
  agentConfirmation: 'dangerous',
154
154
  telegramApproval: false,
155
+ telegramInbox: false,
155
156
  telegramChatId: '',
156
157
  agentConfirmDeleteFile: true,
157
158
  agentConfirmExecuteCommand: true,
@@ -477,7 +477,8 @@ export const PROVIDERS = {
477
477
  },
478
478
  },
479
479
  models: [
480
- { id: 'claude-fable-5', name: 'Claude Fable 5', description: 'Most capable — hardest reasoning & long-horizon agentic work' },
480
+ { id: 'claude-fable-5-1', name: 'Claude Fable 5.1', description: 'Most capable — hardest reasoning & long-horizon agentic work' },
481
+ { id: 'claude-fable-5', name: 'Claude Fable 5', description: 'Superseded by 5.1 — same price, kept for pinned configs' },
481
482
  { id: 'claude-opus-5', name: 'Claude Opus 5', description: 'Complex agentic coding & deep reasoning — the Opus workhorse' },
482
483
  { id: 'claude-sonnet-5', name: 'Claude Sonnet 5', description: 'Best balance of speed and intelligence' },
483
484
  { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku', description: 'Fastest and most affordable' },
@@ -528,7 +529,8 @@ export const PROVIDERS = {
528
529
  // get a working dropdown.
529
530
  models: [
530
531
  { id: 'openrouter/auto', name: 'Auto-route', description: 'OpenRouter picks the best model for the task' },
531
- { id: 'anthropic/claude-fable-5', name: 'Claude Fable 5', description: 'Anthropic — most capable' },
532
+ { id: 'anthropic/claude-fable-5-1', name: 'Claude Fable 5.1', description: 'Anthropic — most capable' },
533
+ { id: 'anthropic/claude-fable-5', name: 'Claude Fable 5', description: 'Anthropic — superseded by 5.1' },
532
534
  { id: 'anthropic/claude-opus-5', name: 'Claude Opus 5', description: 'Anthropic — flagship Opus tier' },
533
535
  { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', description: 'Anthropic — balanced' },
534
536
  { id: 'openai/gpt-5.6-sol', name: 'GPT-5.6 Sol', description: 'OpenAI — flagship' },
@@ -20,7 +20,7 @@ export interface ConfirmOptions {
20
20
  onConfirm: () => void;
21
21
  onCancel?: () => void;
22
22
  }
23
- export { HunkPickerItem, HunkPickerOptions } from './components/HunkPicker';
23
+ export type { HunkPickerItem, HunkPickerOptions } from './components/HunkPicker';
24
24
  import { type HunkPickerOptions } from './components/HunkPicker';
25
25
  /**
26
26
  * Options for the interactive hunk picker — see components/HunkPicker.ts.
@@ -492,6 +492,10 @@ export declare class App {
492
492
  /**
493
493
  * Render status bar
494
494
  */
495
+ /**
496
+ * @param canScroll false when the caller renders instead of the transcript
497
+ * rather than above it, so there is nothing on screen for PgDn to move.
498
+ */
495
499
  private renderStatusBar;
496
500
  /**
497
501
  * Get visible messages (including streaming)
@@ -1469,7 +1469,7 @@ export class App {
1469
1469
  // Input (don't render cursor when menu/settings is open)
1470
1470
  this.renderInput(inputLine, width, this.menuOpen || this.settingsOpen);
1471
1471
  // Status bar
1472
- this.renderStatusBar(statusLine, width);
1472
+ this.renderStatusBar(statusLine, width, true);
1473
1473
  // Inline menu renders BELOW status bar
1474
1474
  if (this.menuOpen && this.menuItems.length > 0) {
1475
1475
  this.renderInlineMenu(statusLine + 1, width);
@@ -1707,7 +1707,7 @@ export class App {
1707
1707
  this.renderInput(inputY, width, false);
1708
1708
  this.renderAgentKeyHints(hintsY, width);
1709
1709
  this.screen.horizontalLine(footerDividerY, '─', fg.gray);
1710
- this.renderStatusBar(statusY, width);
1710
+ this.renderStatusBar(statusY, width, false);
1711
1711
  this.screen.render();
1712
1712
  }
1713
1713
  renderAgentContextRail(dividerX, top, bottom, railWidth, timeline) {
@@ -2424,7 +2424,11 @@ export class App {
2424
2424
  /**
2425
2425
  * Render status bar
2426
2426
  */
2427
- renderStatusBar(y, width) {
2427
+ /**
2428
+ * @param canScroll false when the caller renders instead of the transcript
2429
+ * rather than above it, so there is nothing on screen for PgDn to move.
2430
+ */
2431
+ renderStatusBar(y, width, canScroll) {
2428
2432
  // Clear the line first
2429
2433
  this.screen.writeLine(y, '');
2430
2434
  if (this.notification) {
@@ -2441,8 +2445,12 @@ export class App {
2441
2445
  unseenWhileScrolled: this.unseenWhileScrolled,
2442
2446
  isStreaming: this.isStreaming,
2443
2447
  isLoading: this.isLoading,
2448
+ canScroll,
2444
2449
  });
2445
- if (this.scrollOffset > 0 && this.unseenWhileScrolled > 0) {
2450
+ // Only the badge replaces the footer. Without the guard a run wide enough
2451
+ // to show the timeline traded its runtime and token counts for a hint that
2452
+ // did nothing.
2453
+ if (canScroll && this.scrollOffset > 0 && this.unseenWhileScrolled > 0) {
2446
2454
  this.screen.write(width - rightText.length, y, rightText, PRIMARY_COLOR);
2447
2455
  return;
2448
2456
  }
@@ -9,6 +9,9 @@ import { chat } from '../api/index.js';
9
9
  import { runAgent } from '../utils/agent.js';
10
10
  import { TelegramApproval, outcomeForAnswer, describePermissionOutcome } from '../utils/telegramApproval.js';
11
11
  import { loadTelegramCredentials } from '../utils/telegramCredentials.js';
12
+ import { composeRunMessages, sendTelegramNotice, shouldNotify } from '../utils/telegramNotify.js';
13
+ import { takeRunFromPhone } from '../utils/telegramInbox.js';
14
+ import { isFlatFeeProvider } from '../config/providers.js';
12
15
  import { raceApproval } from '../utils/approvalRace.js';
13
16
  import { describeAuditTarget } from '../utils/auditLog.js';
14
17
  import { config, autoSaveSession, getCurrentSessionId } from '../config/index.js';
@@ -167,6 +170,12 @@ export async function executeAgentTask(task, dryRun, ctx) {
167
170
  const telegramCredentials = confirmationMode === 'dangerous'
168
171
  ? await loadTelegramCredentials()
169
172
  : null;
173
+ // The finish notice does not depend on the confirmation mode — a run with
174
+ // confirmations off is exactly the one you are most likely to walk away
175
+ // from. Reuse the credentials already read above when there are any, so
176
+ // this costs a second keychain round-trip only when there are not.
177
+ const noticeCredentials = telegramCredentials ?? await loadTelegramCredentials();
178
+ const runStartedAt = Date.now();
170
179
  const onRequestPermission = confirmationMode === 'dangerous'
171
180
  ? async (toolCall) => {
172
181
  // `parameters.command` is the binary alone — `git`, not `git status`.
@@ -411,12 +420,31 @@ export async function executeAgentTask(task, dryRun, ctx) {
411
420
  // Report stats to codeep.dev (fire-and-forget, only if github_id is set)
412
421
  const { getCurrentVersion } = await import('../utils/update.js');
413
422
  const sessionId = getCurrentSessionId();
414
- // Auto-name from task if no display name set yet
415
- if (!ctx.sessionDisplayName && ctx.setSessionDisplayName) {
416
- const taskWords = task.replace(/\s+/g, ' ').trim().split(' ').slice(0, 5).join(' ');
417
- ctx.setSessionDisplayName(taskWords.length > 48 ? taskWords.slice(0, 45) + '…' : taskWords);
423
+ // Auto-name from the task if no display name is set yet.
424
+ //
425
+ // The derived name is kept in a local rather than read back off ctx.
426
+ // makeCtx() copies sessionDisplayName by value, so setSessionDisplayName
427
+ // updates the module's variable while this object keeps the undefined it
428
+ // was built with — and reading it one line after calling the setter always
429
+ // returned nothing. Everything downstream then fell back to the session id,
430
+ // so a run reported itself to the dashboard, and announced itself on
431
+ // Telegram, as "session-2026-09-02-ddc1f13c" instead of its task.
432
+ const shortLabel = (text) => {
433
+ const words = text.replace(/\s+/g, ' ').trim().split(' ').slice(0, 5).join(' ');
434
+ return words.length > 48 ? words.slice(0, 45) + '…' : words;
435
+ };
436
+ // What THIS run was asked to do. The session name below is the first task's
437
+ // and stays put, which is right for a session and wrong for one run inside
438
+ // it: the second task in a session would otherwise announce itself on
439
+ // Telegram under the first one's name.
440
+ const runLabel = shortLabel(task) || sessionId;
441
+ let displayName = ctx.sessionDisplayName;
442
+ if (!displayName) {
443
+ displayName = shortLabel(task);
444
+ ctx.setSessionDisplayName?.(displayName);
418
445
  }
419
- const displayName = ctx.sessionDisplayName || sessionId;
446
+ if (!displayName)
447
+ displayName = sessionId;
420
448
  syncSession({
421
449
  sessionId,
422
450
  sessionName: displayName,
@@ -428,6 +456,35 @@ export async function executeAgentTask(task, dryRun, ctx) {
428
456
  // even if the user switched model mid-session. Only this run's delta
429
457
  // (since tokenReportStart) is reported; the cumulative store is preserved.
430
458
  const costBreakdown = getCostBreakdown(tokenReportStart);
459
+ // Told once the run is over, and only when it ran long enough that you
460
+ // could plausibly have stopped watching. Awaited so the process does not
461
+ // exit from under the request, but never allowed to fail the run.
462
+ if (noticeCredentials) {
463
+ const elapsedMs = Date.now() - runStartedAt;
464
+ // Consumed once per run either way, so a phone-started run cannot leave
465
+ // the flag set for whatever the terminal does next.
466
+ const fromPhone = takeRunFromPhone();
467
+ // The one-minute threshold exists so a phone is not buzzed about work you
468
+ // watched finish. It has no business gating a run the phone itself asked
469
+ // for: that answer was wanted whether it took ten seconds or ten minutes,
470
+ // and withholding it leaves "Started —" as the last word.
471
+ if (fromPhone || shouldNotify(elapsedMs, true)) {
472
+ const payPerUse = costBreakdown.filter(entry => !isFlatFeeProvider(entry.provider));
473
+ // Usually one message. An answer past Telegram's limit continues into
474
+ // further ones rather than being cut at the first — awaited in turn so
475
+ // they arrive in the order they were written.
476
+ const messages = composeRunMessages({
477
+ task: runLabel,
478
+ elapsedMs,
479
+ answer: fromPhone ? result.finalResponse : undefined,
480
+ tokens: costBreakdown.reduce((sum, e) => sum + e.promptTokens + e.completionTokens, 0),
481
+ costUsd: payPerUse.reduce((sum, e) => sum + e.estimatedCost, 0),
482
+ });
483
+ for (const message of messages) {
484
+ await sendTelegramNotice(noticeCredentials, message).catch(() => false);
485
+ }
486
+ }
487
+ }
431
488
  const sharedFields = {
432
489
  sessionId,
433
490
  sessionName: displayName,
@@ -136,6 +136,17 @@ export const SETTINGS = [
136
136
  { value: false, label: 'OFF' },
137
137
  ],
138
138
  },
139
+ {
140
+ key: 'telegramInbox',
141
+ label: 'Start tasks from Telegram',
142
+ getValue: () => config.get('telegramInbox') === true,
143
+ type: 'select',
144
+ // Booleans, for the same reason as the row above.
145
+ options: [
146
+ { value: true, label: 'ON' },
147
+ { value: false, label: 'OFF' },
148
+ ],
149
+ },
139
150
  {
140
151
  key: 'telegramChatId',
141
152
  label: 'Telegram chat ID',
@@ -6,10 +6,16 @@
6
6
  * with diff-based rendering for flicker-free updates.
7
7
  */
8
8
  export { cursor, screen, fg, bg, style, styled, stripAnsi, visibleLength, truncate, wordWrap } from './ansi';
9
- export { Screen, Cell } from './Screen';
10
- export { Input, LineEditor, KeyEvent, KeyHandler } from './Input';
11
- export { App, AppOptions, Message } from './App';
12
- export { createBox, centerBox, BoxStyle, BoxOptions } from './components/Box';
13
- export { renderModal, renderHelpModal, renderListModal, ModalOptions } from './components/Modal';
9
+ export { Screen } from './Screen';
10
+ export type { Cell } from './Screen';
11
+ export { Input, LineEditor } from './Input';
12
+ export type { KeyEvent, KeyHandler } from './Input';
13
+ export { App } from './App';
14
+ export type { AppOptions, Message } from './App';
15
+ export { createBox, centerBox } from './components/Box';
16
+ export type { BoxStyle, BoxOptions } from './components/Box';
17
+ export { renderModal, renderHelpModal, renderListModal } from './components/Modal';
18
+ export type { ModalOptions } from './components/Modal';
14
19
  export { helpCategories, keyboardShortcuts } from './components/Help';
15
- export { renderStatusScreen, StatusInfo } from './components/Status';
20
+ export { renderStatusScreen } from './components/Status';
21
+ export type { StatusInfo } from './components/Status';
@@ -145,6 +145,13 @@ export declare function statusBarRightHint(args: {
145
145
  unseenWhileScrolled: number;
146
146
  isStreaming: boolean;
147
147
  isLoading: boolean;
148
+ /**
149
+ * False while something else owns the whole screen — the agent timeline
150
+ * takes it over and returns before the transcript is drawn at all, so
151
+ * scrolling changes an offset nothing reads. Offering PgDn there asks for a
152
+ * keypress that does nothing and leaves the reader hunting for the key.
153
+ */
154
+ canScroll: boolean;
148
155
  }): string;
149
156
  /** The panel that currently owns keyboard focus, in priority order. */
150
157
  export type ActivePanel = 'pasteInfo' | 'permission' | 'sessionPicker' | 'confirm' | 'status' | 'help' | 'settings' | 'search' | 'export' | 'logout' | 'login' | 'menu' | 'autocomplete' | 'hunkPicker' | 'chat';
@@ -213,7 +213,7 @@ export function formatTokenCount(tokens) {
213
213
  * scrolled up — otherwise the hint depends on whether work is in flight.
214
214
  */
215
215
  export function statusBarRightHint(args) {
216
- if (args.scrollOffset > 0 && args.unseenWhileScrolled > 0) {
216
+ if (args.canScroll && args.scrollOffset > 0 && args.unseenWhileScrolled > 0) {
217
217
  return `↓ ${args.unseenWhileScrolled} new · PgDn `;
218
218
  }
219
219
  return args.isStreaming || args.isLoading ? 'Esc to stop ' : '/help · ↑↓ history ';
@@ -22,6 +22,10 @@ import { expandFileAndFolderMentions, expandGitMentions } from '../utils/mention
22
22
  import { expandWebMentions } from '../utils/webFetch.js';
23
23
  import { handleCommand as dispatchCommand } from './commands.js';
24
24
  import { logAppError } from '../utils/logger.js';
25
+ import { loadTelegramInboxCredentials } from '../utils/telegramCredentials.js';
26
+ import { attachTelegramInbox } from '../utils/telegramInbox.js';
27
+ import { sharedUpdates } from '../utils/telegramUpdates.js';
28
+ import { sendTelegramNotice } from '../utils/telegramNotify.js';
25
29
  import { executeAgentTask, runAgentTask, } from './agentExecution.js';
26
30
  // ─── Global state ─────────────────────────────────────────────────────────────
27
31
  let projectPath = process.cwd();
@@ -50,6 +54,8 @@ export function deriveSessionName(message) {
50
54
  return words.length > 48 ? words.slice(0, 45) + '…' : words;
51
55
  }
52
56
  let isAgentRunningFlag = false;
57
+ /** Set once the phone is allowed to send instructions; null when it is not. */
58
+ let telegramInbox = null;
53
59
  let agentAbortController = null;
54
60
  let pendingInteractiveContext = null;
55
61
  // ─── Context factory ──────────────────────────────────────────────────────────
@@ -66,8 +72,14 @@ function makeCtx() {
66
72
  isAgentRunning: () => isAgentRunningFlag,
67
73
  // A finished run may have switched branches — drop the cache so the next
68
74
  // header render re-reads it.
69
- setAgentRunning: (v) => { isAgentRunningFlag = v; if (!v)
70
- gitBranchCache = null; },
75
+ setAgentRunning: (v) => {
76
+ isAgentRunningFlag = v;
77
+ if (!v) {
78
+ gitBranchCache = null;
79
+ // A run just ended: hand over anything the phone sent while it was busy.
80
+ telegramInbox?.drain();
81
+ }
82
+ },
71
83
  setAbortController: (ctrl) => { agentAbortController = ctrl; },
72
84
  formatAddedFilesContext,
73
85
  handleCommand: (cmd, args) => dispatchCommand(cmd, args, makeCtx()),
@@ -749,6 +761,38 @@ Commands (in chat):
749
761
  welcomeLines.push(' /help · Ctrl+L clear · Esc cancel');
750
762
  app.addMessage({ role: 'welcome', content: welcomeLines.join('\n') });
751
763
  app.start();
764
+ // Let the phone send instructions, if it has been switched on. Started after
765
+ // app.start() so a prompt that arrives immediately has somewhere to land.
766
+ void (async () => {
767
+ const credentials = await loadTelegramInboxCredentials();
768
+ if (!credentials)
769
+ return;
770
+ telegramInbox = attachTelegramInbox(credentials, {
771
+ isBusy: () => isAgentRunningFlag,
772
+ submit: (text) => {
773
+ // Through the same door the input box uses, and shown in the transcript
774
+ // the same way — a run started from the phone must not be invisible to
775
+ // whoever is sitting at the terminal.
776
+ app.addMessage({ role: 'user', content: text });
777
+ app.notify('Telegram: running an instruction from your phone');
778
+ app.setLoading(true);
779
+ void handleSubmit(text).catch(err => {
780
+ app.notify(`Error: ${err.message}`);
781
+ app.setLoading(false);
782
+ });
783
+ },
784
+ reply: (text) => { void sendTelegramNotice(credentials, text); },
785
+ });
786
+ // Say it once when the poll starts failing, and once when it recovers. A
787
+ // webhook left on the bot, or a revoked token, otherwise looks exactly like
788
+ // a phone nobody has messaged.
789
+ sharedUpdates(credentials.botToken).observe(({ ok, detail }) => {
790
+ if (ok)
791
+ app.notify(`Telegram: ${detail}`);
792
+ else
793
+ app.notifyWarn(`Telegram: ${detail}`);
794
+ });
795
+ })();
752
796
  // Spawn MCP servers in the background. They register against the fixed
753
797
  // session id `codeep-tui` that runAgentTask passes into runAgent's
754
798
  // `mcpSessionId` — so the agent picks up `.codeep/mcp_servers.json`
@@ -27,12 +27,9 @@
27
27
  */
28
28
  /** What the phone sent back. Mirrors the three buttons the desktop offers. */
29
29
  export type TelegramAnswer = 'run' | 'skip' | 'cancel';
30
- export interface TelegramCredentials {
31
- /** From @BotFather. A credential — belongs in the keychain, never in config. */
32
- botToken: string;
33
- /** The single chat allowed to answer. Anything else is ignored. */
34
- chatID: string;
35
- }
30
+ import { type TelegramCredentials } from './telegramUpdates';
31
+ export type { TelegramCredentials } from './telegramUpdates';
32
+ export { nextOffset } from './telegramUpdates';
36
33
  /**
37
34
  * The message text.
38
35
  *
@@ -64,17 +61,6 @@ export declare function parseCallbackData(data: string): {
64
61
  /** The keyboard sent with the question. Shape pinned by a test — a renamed
65
62
  * callback_data field would leave three buttons that silently do nothing. */
66
63
  export declare function buildKeyboard(token: string): Record<string, unknown>;
67
- /**
68
- * Advance the long-poll offset past everything just handled.
69
- *
70
- * Telegram redelivers any update that has not been acknowledged by a higher
71
- * offset, so getting this wrong means the same tap arrives forever. Exported
72
- * because it is off-by-one-shaped and cheaper to test than to debug against a
73
- * live bot.
74
- */
75
- export declare function nextOffset(current: number, updates: {
76
- update_id?: unknown;
77
- }[]): number;
78
64
  /**
79
65
  * Telegram's three buttons, in the terms the agent's gate speaks.
80
66
  *
@@ -104,8 +90,8 @@ export declare function describeApiError(status: number, json: Record<string, un
104
90
  export declare class TelegramApproval {
105
91
  private readonly credentials;
106
92
  private outstanding;
107
- private updateOffset;
108
- private polling;
93
+ /** Set while a question is open; called to stop listening once it closes. */
94
+ private unlisten;
109
95
  /** Called once when the question could not be put at all. Not for a missing
110
96
  * answer — only for a failure to ask. */
111
97
  private readonly onProblem?;
@@ -135,6 +121,5 @@ export declare class TelegramApproval {
135
121
  private post;
136
122
  private sendQuestion;
137
123
  private edit;
138
- private poll;
139
124
  private handle;
140
125
  }
@@ -26,6 +26,8 @@
26
26
  * one side is findable on the other.
27
27
  */
28
28
  const ANSWERS = ['run', 'skip', 'cancel'];
29
+ import { sharedUpdates } from './telegramUpdates.js';
30
+ export { nextOffset } from './telegramUpdates.js';
29
31
  /** Longest command we put in a message. Telegram caps at 4096 for the whole
30
32
  * text; this keeps room for the heading and the fences, and a command longer
31
33
  * than this is not something anyone reads off a phone anyway. */
@@ -99,22 +101,6 @@ export function buildKeyboard(token) {
99
101
  ]],
100
102
  };
101
103
  }
102
- /**
103
- * Advance the long-poll offset past everything just handled.
104
- *
105
- * Telegram redelivers any update that has not been acknowledged by a higher
106
- * offset, so getting this wrong means the same tap arrives forever. Exported
107
- * because it is off-by-one-shaped and cheaper to test than to debug against a
108
- * live bot.
109
- */
110
- export function nextOffset(current, updates) {
111
- let out = current;
112
- for (const update of updates) {
113
- if (typeof update.update_id === 'number')
114
- out = Math.max(out, update.update_id + 1);
115
- }
116
- return out;
117
- }
118
104
  /**
119
105
  * Telegram's three buttons, in the terms the agent's gate speaks.
120
106
  *
@@ -173,15 +159,13 @@ export function describeApiError(status, json) {
173
159
  }
174
160
  // ─── The client ───────────────────────────────────────────────────────────────
175
161
  const API = 'https://api.telegram.org';
176
- /** Server-side long-poll window. The request blocks for up to this long. */
177
- const POLL_SECONDS = 25;
178
- /** Local ceiling, comfortably past the server's own. */
179
- const REQUEST_TIMEOUT_MS = (POLL_SECONDS + 10) * 1000;
162
+ /** Sending a message or editing one — no long poll lives here any more. */
163
+ const REQUEST_TIMEOUT_MS = 15_000;
180
164
  export class TelegramApproval {
181
165
  credentials;
182
166
  outstanding = null;
183
- updateOffset = 0;
184
- polling = false;
167
+ /** Set while a question is open; called to stop listening once it closes. */
168
+ unlisten = null;
185
169
  /** Called once when the question could not be put at all. Not for a missing
186
170
  * answer — only for a failure to ask. */
187
171
  onProblem;
@@ -212,7 +196,8 @@ export class TelegramApproval {
212
196
  return;
213
197
  settled = true;
214
198
  this.outstanding = null;
215
- this.polling = false;
199
+ this.unlisten?.();
200
+ this.unlisten = null;
216
201
  resolve(answer);
217
202
  };
218
203
  this.outstanding = { token, messageID, resolve: finish };
@@ -223,7 +208,10 @@ export class TelegramApproval {
223
208
  }
224
209
  signal.addEventListener('abort', () => finish(null), { once: true });
225
210
  }
226
- void this.poll();
211
+ // Listen on the bot's one poller rather than opening a second. Two
212
+ // cursors on the same bot silently eat each other's updates.
213
+ this.unlisten = sharedUpdates(this.credentials.botToken)
214
+ .subscribe('callback_query', callback => this.handle(callback));
227
215
  });
228
216
  }
229
217
  /**
@@ -235,7 +223,8 @@ export class TelegramApproval {
235
223
  if (!pending)
236
224
  return;
237
225
  this.outstanding = null;
238
- this.polling = false;
226
+ this.unlisten?.();
227
+ this.unlisten = null;
239
228
  pending.resolve(null);
240
229
  await this.edit(pending.messageID, `Answered in the terminal — ${decidedInTerminal}.`);
241
230
  }
@@ -289,32 +278,6 @@ export class TelegramApproval {
289
278
  text,
290
279
  });
291
280
  }
292
- async poll() {
293
- if (this.polling)
294
- return;
295
- this.polling = true;
296
- while (this.polling && this.outstanding) {
297
- const json = await this.post('getUpdates', {
298
- offset: this.updateOffset,
299
- timeout: POLL_SECONDS,
300
- allowed_updates: ['callback_query'],
301
- });
302
- if (!this.polling || !this.outstanding)
303
- return;
304
- const updates = Array.isArray(json?.result) ? json.result : [];
305
- this.updateOffset = nextOffset(this.updateOffset, updates);
306
- for (const update of updates) {
307
- if (update.callback_query)
308
- await this.handle(update.callback_query);
309
- if (!this.outstanding)
310
- return;
311
- }
312
- // Telegram's long poll already blocks server-side; this pause only covers
313
- // the error case, so a failing network cannot spin the loop.
314
- if (updates.length === 0)
315
- await sleep(1000);
316
- }
317
- }
318
281
  async handle(callback) {
319
282
  const pending = this.outstanding;
320
283
  if (!pending)
@@ -328,7 +291,8 @@ export class TelegramApproval {
328
291
  if (!parsed || parsed.token !== pending.token)
329
292
  return;
330
293
  this.outstanding = null;
331
- this.polling = false;
294
+ this.unlisten?.();
295
+ this.unlisten = null;
332
296
  const id = callback.id;
333
297
  if (typeof id === 'string')
334
298
  await this.post('answerCallbackQuery', { callback_query_id: id });
@@ -342,6 +306,3 @@ function randomToken() {
342
306
  function capitalise(value) {
343
307
  return value.charAt(0).toUpperCase() + value.slice(1);
344
308
  }
345
- function sleep(ms) {
346
- return new Promise(resolve => setTimeout(resolve, ms));
347
- }
@@ -22,3 +22,11 @@ export declare function hasTelegramToken(): Promise<boolean>;
22
22
  * both would otherwise stall a run waiting for an answer that was never asked.
23
23
  */
24
24
  export declare function loadTelegramCredentials(): Promise<TelegramCredentials | null>;
25
+ /**
26
+ * The same pair, for the inbox, behind its own switch.
27
+ *
28
+ * Not folded into the call above: someone can want the phone to be told a run
29
+ * finished without wanting the phone to be able to start one, and the reverse.
30
+ * One flag serving both would make turning either off turn both off.
31
+ */
32
+ export declare function loadTelegramInboxCredentials(): Promise<TelegramCredentials | null>;
@@ -43,6 +43,21 @@ export async function loadTelegramCredentials() {
43
43
  // would be live while the settings row read OFF.
44
44
  if (config.get('telegramApproval') !== true)
45
45
  return null;
46
+ return readCredentials();
47
+ }
48
+ /**
49
+ * The same pair, for the inbox, behind its own switch.
50
+ *
51
+ * Not folded into the call above: someone can want the phone to be told a run
52
+ * finished without wanting the phone to be able to start one, and the reverse.
53
+ * One flag serving both would make turning either off turn both off.
54
+ */
55
+ export async function loadTelegramInboxCredentials() {
56
+ if (config.get('telegramInbox') !== true)
57
+ return null;
58
+ return readCredentials();
59
+ }
60
+ async function readCredentials() {
46
61
  const chatID = String(config.get('telegramChatId') || '').trim();
47
62
  if (!chatID)
48
63
  return null;