codeep 2.25.0 → 3.1.0

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.
@@ -83,6 +83,20 @@ export interface ConfigSchema {
83
83
  ollamaNumCtx: number;
84
84
  customBaseUrl: string;
85
85
  agentConfirmation: 'always' | 'dangerous' | 'never';
86
+ /** Also send a pending confirmation to Telegram, so it can be answered away
87
+ * from the desk. Interactive runs only — a headless run has nobody to ask,
88
+ * and waiting on an answer that cannot come would hang CI. The bot token
89
+ * lives in the keychain, never here. */
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;
97
+ /** The single chat allowed to answer. Not a secret — it identifies a
98
+ * conversation, and it is useless without the token. */
99
+ telegramChatId: string;
86
100
  agentConfirmDeleteFile: boolean;
87
101
  agentConfirmExecuteCommand: boolean;
88
102
  agentConfirmWriteFile: boolean;
@@ -151,6 +151,9 @@ function createConfig() {
151
151
  ollamaNumCtx: 0,
152
152
  customBaseUrl: '',
153
153
  agentConfirmation: 'dangerous',
154
+ telegramApproval: false,
155
+ telegramInbox: false,
156
+ telegramChatId: '',
154
157
  agentConfirmDeleteFile: true,
155
158
  agentConfirmExecuteCommand: true,
156
159
  agentConfirmWriteFile: false,
@@ -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.
@@ -124,6 +124,10 @@ export declare class App {
124
124
  private isMultilineMode;
125
125
  private loginOpen;
126
126
  private loginStep;
127
+ /** Overrides the masked step's heading when that screen is reused for a
128
+ * secret that is not a provider API key — a Telegram bot token, say.
129
+ * Empty for an ordinary login. */
130
+ private secretPrompt;
127
131
  private loginProviders;
128
132
  private loginProviderIndex;
129
133
  private loginApiKey;
@@ -243,6 +247,18 @@ export declare class App {
243
247
  * Show confirmation dialog
244
248
  */
245
249
  showConfirm(options: ConfirmOptions): void;
250
+ /**
251
+ * Take the confirmation down without answering it.
252
+ *
253
+ * For a question that was settled somewhere else — today that means a phone
254
+ * answered it over Telegram. Neither callback fires: the decision has already
255
+ * been made and taken, and running `onCancel` here would deny a tool the user
256
+ * just approved.
257
+ *
258
+ * A no-op when nothing is open, so the caller can dismiss unconditionally
259
+ * rather than racing to check first.
260
+ */
261
+ dismissConfirm(reason?: string): void;
246
262
  /**
247
263
  * Show the interactive hunk picker (`/apply --interactive`).
248
264
  * The caller passes pre-built items + an `onComplete` callback.
@@ -304,6 +320,16 @@ export declare class App {
304
320
  providerId: string;
305
321
  apiKey: string;
306
322
  } | null) => void): void;
323
+ /**
324
+ * Ask for one secret, masked, with no provider step.
325
+ *
326
+ * The login screen already takes a credential without echoing it, and a second
327
+ * implementation of that is a second place to get masking wrong. This reuses
328
+ * it and replaces only the heading: "Enter API Key for Z.AI" is the wrong
329
+ * sentence for a Telegram bot token, and a prompt naming the wrong thing is
330
+ * how someone pastes the wrong thing.
331
+ */
332
+ showSecret(prompt: string, callback: (secret: string | null) => void): void;
307
333
  /**
308
334
  * Reinitialize screen (after external screen takeover)
309
335
  */
@@ -466,6 +492,10 @@ export declare class App {
466
492
  /**
467
493
  * Render status bar
468
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
+ */
469
499
  private renderStatusBar;
470
500
  /**
471
501
  * Get visible messages (including streaming)
@@ -155,6 +155,10 @@ export class App {
155
155
  // Inline login state
156
156
  loginOpen = false;
157
157
  loginStep = 'provider';
158
+ /** Overrides the masked step's heading when that screen is reused for a
159
+ * secret that is not a provider API key — a Telegram bot token, say.
160
+ * Empty for an ordinary login. */
161
+ secretPrompt = '';
158
162
  loginProviders = [];
159
163
  loginProviderIndex = 0;
160
164
  loginApiKey = '';
@@ -529,6 +533,27 @@ export class App {
529
533
  this.confirmOpen = true;
530
534
  this.scheduleRender();
531
535
  }
536
+ /**
537
+ * Take the confirmation down without answering it.
538
+ *
539
+ * For a question that was settled somewhere else — today that means a phone
540
+ * answered it over Telegram. Neither callback fires: the decision has already
541
+ * been made and taken, and running `onCancel` here would deny a tool the user
542
+ * just approved.
543
+ *
544
+ * A no-op when nothing is open, so the caller can dismiss unconditionally
545
+ * rather than racing to check first.
546
+ */
547
+ dismissConfirm(reason) {
548
+ if (!this.confirmOpen)
549
+ return;
550
+ this.screen.invalidate();
551
+ this.confirmOptions = null;
552
+ this.confirmOpen = false;
553
+ if (reason)
554
+ this.notify(reason);
555
+ this.scheduleRender();
556
+ }
532
557
  /**
533
558
  * Show the interactive hunk picker (`/apply --interactive`).
534
559
  * The caller passes pre-built items + an `onComplete` callback.
@@ -659,6 +684,27 @@ export class App {
659
684
  this.loginApiKey = '';
660
685
  this.loginError = '';
661
686
  this.loginCallback = callback;
687
+ this.secretPrompt = '';
688
+ this.loginOpen = true;
689
+ this.scheduleRender();
690
+ }
691
+ /**
692
+ * Ask for one secret, masked, with no provider step.
693
+ *
694
+ * The login screen already takes a credential without echoing it, and a second
695
+ * implementation of that is a second place to get masking wrong. This reuses
696
+ * it and replaces only the heading: "Enter API Key for Z.AI" is the wrong
697
+ * sentence for a Telegram bot token, and a prompt naming the wrong thing is
698
+ * how someone pastes the wrong thing.
699
+ */
700
+ showSecret(prompt, callback) {
701
+ this.loginProviders = [{ id: 'secret', name: prompt }];
702
+ this.loginProviderIndex = 0;
703
+ this.loginStep = 'apikey';
704
+ this.loginApiKey = '';
705
+ this.loginError = '';
706
+ this.secretPrompt = prompt;
707
+ this.loginCallback = (result) => callback(result ? result.apiKey : null);
662
708
  this.loginOpen = true;
663
709
  this.scheduleRender();
664
710
  }
@@ -1423,7 +1469,7 @@ export class App {
1423
1469
  // Input (don't render cursor when menu/settings is open)
1424
1470
  this.renderInput(inputLine, width, this.menuOpen || this.settingsOpen);
1425
1471
  // Status bar
1426
- this.renderStatusBar(statusLine, width);
1472
+ this.renderStatusBar(statusLine, width, true);
1427
1473
  // Inline menu renders BELOW status bar
1428
1474
  if (this.menuOpen && this.menuItems.length > 0) {
1429
1475
  this.renderInlineMenu(statusLine + 1, width);
@@ -1661,7 +1707,7 @@ export class App {
1661
1707
  this.renderInput(inputY, width, false);
1662
1708
  this.renderAgentKeyHints(hintsY, width);
1663
1709
  this.screen.horizontalLine(footerDividerY, '─', fg.gray);
1664
- this.renderStatusBar(statusY, width);
1710
+ this.renderStatusBar(statusY, width, false);
1665
1711
  this.screen.render();
1666
1712
  }
1667
1713
  renderAgentContextRail(dividerX, top, bottom, railWidth, timeline) {
@@ -2378,7 +2424,11 @@ export class App {
2378
2424
  /**
2379
2425
  * Render status bar
2380
2426
  */
2381
- 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) {
2382
2432
  // Clear the line first
2383
2433
  this.screen.writeLine(y, '');
2384
2434
  if (this.notification) {
@@ -2395,8 +2445,12 @@ export class App {
2395
2445
  unseenWhileScrolled: this.unseenWhileScrolled,
2396
2446
  isStreaming: this.isStreaming,
2397
2447
  isLoading: this.isLoading,
2448
+ canScroll,
2398
2449
  });
2399
- 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) {
2400
2454
  this.screen.write(width - rightText.length, y, rightText, PRIMARY_COLOR);
2401
2455
  return;
2402
2456
  }
@@ -2586,7 +2640,8 @@ export class App {
2586
2640
  else {
2587
2641
  // API key entry
2588
2642
  const selectedProvider = this.loginProviders[this.loginProviderIndex];
2589
- this.screen.writeLine(y++, `Enter API Key for ${selectedProvider.name}`, fg.cyan + style.bold);
2643
+ const heading = this.secretPrompt || `Enter API Key for ${selectedProvider.name}`;
2644
+ this.screen.writeLine(y++, heading, fg.cyan + style.bold);
2590
2645
  y++;
2591
2646
  // API key input (masked)
2592
2647
  const maskedKey = this.loginApiKey.length > 0
@@ -7,6 +7,13 @@
7
7
  */
8
8
  import { chat } from '../api/index.js';
9
9
  import { runAgent } from '../utils/agent.js';
10
+ import { TelegramApproval, outcomeForAnswer, describePermissionOutcome } from '../utils/telegramApproval.js';
11
+ import { loadTelegramCredentials } from '../utils/telegramCredentials.js';
12
+ import { composeRunSummary, sendTelegramNotice, shouldNotify } from '../utils/telegramNotify.js';
13
+ import { takeRunFromPhone } from '../utils/telegramInbox.js';
14
+ import { isFlatFeeProvider } from '../config/providers.js';
15
+ import { raceApproval } from '../utils/approvalRace.js';
16
+ import { describeAuditTarget } from '../utils/auditLog.js';
10
17
  import { config, autoSaveSession, getCurrentSessionId } from '../config/index.js';
11
18
  import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
12
19
  import { getGitStatus, isGitRepository } from '../utils/git.js';
@@ -155,28 +162,74 @@ export async function executeAgentTask(task, dryRun, ctx) {
155
162
  const rawIterations = config.get('agentMaxIterations') || 50;
156
163
  app.setAgentMaxIterations(Math.max(5, rawIterations));
157
164
  const confirmationMode = config.get('agentConfirmation') || 'dangerous';
165
+ // Read the Telegram credentials once for the whole run rather than per tool
166
+ // call: they come from the OS keychain, and paying that on every dangerous
167
+ // tool would put a keychain round-trip in front of each confirmation.
168
+ // Null means the feature is off or half-configured, and the terminal is
169
+ // then the only place the question appears — exactly as before.
170
+ const telegramCredentials = confirmationMode === 'dangerous'
171
+ ? await loadTelegramCredentials()
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();
158
179
  const onRequestPermission = confirmationMode === 'dangerous'
159
- ? (toolCall) => new Promise((resolve) => {
160
- const target = toolCall.parameters.path ||
161
- toolCall.parameters.command || 'unknown';
180
+ ? async (toolCall) => {
181
+ // `parameters.command` is the binary alone — `git`, not `git status`.
182
+ // Showing that asks someone to approve a command they have not been
183
+ // shown, which is the one thing this gate must not do. The audit
184
+ // record already joins the binary with its arguments; reuse it rather
185
+ // than writing a second, subtly different answer.
186
+ const target = describeAuditTarget(toolCall);
162
187
  const shortTarget = target.length > 50 ? '...' + target.slice(-47) : target;
163
- app.showConfirm({
164
- title: '⚠️ Confirm Action',
165
- message: [
166
- 'The agent wants to execute:',
167
- '',
168
- ` ${toolCall.tool}`,
169
- ` ${shortTarget}`,
170
- '',
171
- 'Allow this action?',
172
- ],
173
- confirmLabel: 'Allow',
174
- cancelLabel: 'Deny',
175
- extraOption: { label: 'Always Allow', onSelect: () => resolve('allow_always') },
176
- onConfirm: () => resolve('allow_once'),
177
- onCancel: () => resolve('reject_always'),
178
- });
179
- })
188
+ const inTerminal = {
189
+ answer: new Promise((resolve) => {
190
+ app.showConfirm({
191
+ title: '⚠️ Confirm Action',
192
+ message: [
193
+ 'The agent wants to execute:',
194
+ '',
195
+ ` ${toolCall.tool}`,
196
+ ` ${shortTarget}`,
197
+ '',
198
+ telegramCredentials ? 'Allow this action? (or answer on Telegram)' : 'Allow this action?',
199
+ ],
200
+ confirmLabel: 'Allow',
201
+ cancelLabel: 'Deny',
202
+ extraOption: { label: 'Always Allow', onSelect: () => resolve('allow_always') },
203
+ onConfirm: () => resolve('allow_once'),
204
+ onCancel: () => resolve('reject_always'),
205
+ });
206
+ }),
207
+ // Answered on the phone: take the dialog down without running either
208
+ // callback, since the decision is already made and taken.
209
+ withdraw: (winner) => app.dismissConfirm(`Answered on Telegram — ${winner}.`),
210
+ };
211
+ let onPhone = null;
212
+ if (telegramCredentials) {
213
+ // Report a failure to *ask* once, in the terminal. Without this a
214
+ // wrong chat id looks exactly like a phone nobody picked up.
215
+ const telegram = new TelegramApproval(telegramCredentials, reason => app.notifyWarn(`Telegram: ${reason}`));
216
+ onPhone = {
217
+ answer: telegram
218
+ .ask(target, toolCall.tool, true)
219
+ .then(answer => (answer ? outcomeForAnswer(answer) : null))
220
+ // A phone that cannot be reached is not a denial. Step aside and
221
+ // let the terminal decide, however long that takes.
222
+ .catch(() => null),
223
+ withdraw: (winner) => telegram.withdraw(winner),
224
+ };
225
+ }
226
+ const { answer } = await raceApproval(inTerminal, onPhone, outcome => describePermissionOutcome(outcome));
227
+ // Nobody answered — neither side could even ask. `classifyPermissionOutcome`
228
+ // fails closed on anything it does not recognise, and this is spelled
229
+ // out rather than left to that: a question that was never put must
230
+ // never read as a yes.
231
+ return answer ?? 'reject_once';
232
+ }
180
233
  : undefined;
181
234
  const result = await runAgent(enrichedTask, context, {
182
235
  dryRun,
@@ -367,12 +420,31 @@ export async function executeAgentTask(task, dryRun, ctx) {
367
420
  // Report stats to codeep.dev (fire-and-forget, only if github_id is set)
368
421
  const { getCurrentVersion } = await import('../utils/update.js');
369
422
  const sessionId = getCurrentSessionId();
370
- // Auto-name from task if no display name set yet
371
- if (!ctx.sessionDisplayName && ctx.setSessionDisplayName) {
372
- const taskWords = task.replace(/\s+/g, ' ').trim().split(' ').slice(0, 5).join(' ');
373
- 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);
374
445
  }
375
- const displayName = ctx.sessionDisplayName || sessionId;
446
+ if (!displayName)
447
+ displayName = sessionId;
376
448
  syncSession({
377
449
  sessionId,
378
450
  sessionName: displayName,
@@ -384,6 +456,29 @@ export async function executeAgentTask(task, dryRun, ctx) {
384
456
  // even if the user switched model mid-session. Only this run's delta
385
457
  // (since tokenReportStart) is reported; the cumulative store is preserved.
386
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
+ await sendTelegramNotice(noticeCredentials, composeRunSummary({
474
+ task: runLabel,
475
+ elapsedMs,
476
+ answer: fromPhone ? result.finalResponse : undefined,
477
+ tokens: costBreakdown.reduce((sum, e) => sum + e.promptTokens + e.completionTokens, 0),
478
+ costUsd: payPerUse.reduce((sum, e) => sum + e.estimatedCost, 0),
479
+ })).catch(() => false);
480
+ }
481
+ }
387
482
  const sharedFields = {
388
483
  sessionId,
389
484
  sessionName: displayName,
@@ -190,6 +190,7 @@ export const COMMANDS = [
190
190
  { name: 'lang', description: 'Set response language', category: 'settings' },
191
191
  { name: 'grant', description: 'Grant write permission', category: 'settings' },
192
192
  { name: 'login', aliases: ['apikey'], description: 'Login with API key', category: 'settings' },
193
+ { name: 'telegram', description: 'Set up answering confirmations on your phone', category: 'settings' },
193
194
  { name: 'logout', description: 'Logout from provider', category: 'settings' },
194
195
  {
195
196
  name: 'profile',
@@ -6,6 +6,7 @@
6
6
  * to keep startup time low.
7
7
  */
8
8
  import { config, getCurrentProvider, getModelsForCurrentProvider, PROTOCOLS, LANGUAGES, setProvider, setApiKey, clearApiKey, getApiKey, saveSession, startNewSession, loadSession, listSessionsWithInfo, deleteSession, renameSession, setProjectPermission, saveProfile, loadProfile, applyProfile, listProfiles, deleteProfile, initializeAsProject, isManuallyInitializedProject, } from '../config/index.js';
9
+ import { setTelegramToken, clearTelegramToken, hasTelegramToken } from '../utils/telegramCredentials.js';
9
10
  import { getProjectContext } from '../utils/project.js';
10
11
  import { getCurrentVersion } from '../utils/update.js';
11
12
  import { getProviderList, getProvider, modelSupportsReasoningEffort, reasoningParamsFor, availableReasoningTiers, resolveReasoningTier, REASONING_TIERS } from '../config/providers.js';
@@ -1084,6 +1085,39 @@ Format: use headers per category, only include categories where you found issues
1084
1085
  });
1085
1086
  break;
1086
1087
  }
1088
+ case 'telegram': {
1089
+ const enabled = config.get('telegramApproval') === true;
1090
+ const chatId = String(config.get('telegramChatId') || '').trim();
1091
+ const hasToken = await hasTelegramToken();
1092
+ // Say what is missing before offering to fix it. Half-configured behaves
1093
+ // exactly like off, and without this the user turns the switch on, sees
1094
+ // nothing arrive, and has no way to tell which half is absent.
1095
+ ctx.app.notify([
1096
+ `Telegram approval: ${enabled ? 'on' : 'off'}`,
1097
+ `Bot token: ${hasToken ? 'saved' : 'missing'}`,
1098
+ `Chat ID: ${chatId || 'missing'}`,
1099
+ ].join(' · '));
1100
+ ctx.app.showSecret('Paste your Telegram bot token (from @BotFather)', async (token) => {
1101
+ if (token === null)
1102
+ return;
1103
+ if (!token.trim()) {
1104
+ await clearTelegramToken();
1105
+ ctx.app.notify('Telegram bot token removed.');
1106
+ return;
1107
+ }
1108
+ try {
1109
+ await setTelegramToken(token);
1110
+ }
1111
+ catch {
1112
+ ctx.app.notify('Could not save the token (secure storage unavailable).');
1113
+ return;
1114
+ }
1115
+ ctx.app.notify(chatId
1116
+ ? 'Token saved. Turn on "Answer confirmations on Telegram" in /settings.'
1117
+ : 'Token saved. Now set your chat ID in /settings — open https://api.telegram.org/bot<TOKEN>/getUpdates after messaging your bot.');
1118
+ });
1119
+ break;
1120
+ }
1087
1121
  case 'login': {
1088
1122
  const providers = getProviderList();
1089
1123
  ctx.app.showLogin(providers.map(p => ({ id: p.id, name: p.name, description: p.description, subscribeUrl: p.subscribeUrl, noApiKey: p.noApiKey })), async (result) => {
@@ -122,6 +122,37 @@ export const SETTINGS = [
122
122
  { value: 'always', label: 'Always' },
123
123
  ],
124
124
  },
125
+ {
126
+ key: 'telegramApproval',
127
+ label: 'Answer confirmations on Telegram',
128
+ getValue: () => config.get('telegramApproval') === true,
129
+ type: 'select',
130
+ // Booleans, not the strings 'true'/'false'. With strings the option never
131
+ // matches what getValue returns, so the row renders the raw value and the
132
+ // toggle writes a string that `=== true` can never satisfy — it reads Off
133
+ // forever while claiming to have been turned On.
134
+ options: [
135
+ { value: true, label: 'ON' },
136
+ { value: false, label: 'OFF' },
137
+ ],
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
+ },
150
+ {
151
+ key: 'telegramChatId',
152
+ label: 'Telegram chat ID',
153
+ getValue: () => config.get('telegramChatId') || '',
154
+ type: 'text',
155
+ },
125
156
  {
126
157
  key: 'agentConfirmDeleteFile',
127
158
  label: 'Confirm: delete_file',
@@ -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`