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.
- package/dist/config/index.d.ts +14 -0
- package/dist/config/index.js +3 -0
- package/dist/config/providers.js +4 -2
- package/dist/renderer/App.d.ts +31 -1
- package/dist/renderer/App.js +60 -5
- package/dist/renderer/agentExecution.js +120 -25
- package/dist/renderer/commands/registry.js +1 -0
- package/dist/renderer/commands.js +34 -0
- package/dist/renderer/components/Settings.js +31 -0
- package/dist/renderer/index.d.ts +12 -6
- package/dist/renderer/layout.d.ts +7 -0
- package/dist/renderer/layout.js +1 -1
- package/dist/renderer/main.js +46 -2
- package/dist/utils/approvalRace.d.ts +33 -0
- package/dist/utils/approvalRace.js +58 -0
- package/dist/utils/telegramApproval.d.ts +125 -0
- package/dist/utils/telegramApproval.js +308 -0
- package/dist/utils/telegramCredentials.d.ts +32 -0
- package/dist/utils/telegramCredentials.js +68 -0
- package/dist/utils/telegramInbox.d.ts +93 -0
- package/dist/utils/telegramInbox.js +188 -0
- package/dist/utils/telegramNotify.d.ts +76 -0
- package/dist/utils/telegramNotify.js +132 -0
- package/dist/utils/telegramUpdates.d.ts +71 -0
- package/dist/utils/telegramUpdates.js +175 -0
- package/dist/utils/tokenTracker.js +51 -8
- package/dist/utils/tools.js +6 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/config/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/config/index.js
CHANGED
|
@@ -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,
|
package/dist/config/providers.js
CHANGED
|
@@ -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' },
|
package/dist/renderer/App.d.ts
CHANGED
|
@@ -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)
|
package/dist/renderer/App.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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) =>
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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',
|
package/dist/renderer/index.d.ts
CHANGED
|
@@ -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
|
|
10
|
-
export {
|
|
11
|
-
export {
|
|
12
|
-
export {
|
|
13
|
-
export {
|
|
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
|
|
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';
|
package/dist/renderer/layout.js
CHANGED
|
@@ -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 ';
|
package/dist/renderer/main.js
CHANGED
|
@@ -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) => {
|
|
70
|
-
|
|
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`
|