codeep 2.24.0 → 3.0.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 +8 -0
- package/dist/config/index.js +2 -0
- package/dist/config/providers.js +2 -0
- package/dist/renderer/App.d.ts +26 -0
- package/dist/renderer/App.js +48 -1
- package/dist/renderer/agentExecution.js +64 -20
- package/dist/renderer/commands/registry.js +1 -0
- package/dist/renderer/commands.js +34 -0
- package/dist/renderer/components/Settings.js +20 -0
- package/dist/utils/approvalRace.d.ts +33 -0
- package/dist/utils/approvalRace.js +58 -0
- package/dist/utils/telegramApproval.d.ts +140 -0
- package/dist/utils/telegramApproval.js +347 -0
- package/dist/utils/telegramCredentials.d.ts +24 -0
- package/dist/utils/telegramCredentials.js +53 -0
- package/dist/utils/tokenTracker.js +8 -0
- 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,14 @@ 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
|
+
/** The single chat allowed to answer. Not a secret — it identifies a
|
|
92
|
+
* conversation, and it is useless without the token. */
|
|
93
|
+
telegramChatId: string;
|
|
86
94
|
agentConfirmDeleteFile: boolean;
|
|
87
95
|
agentConfirmExecuteCommand: boolean;
|
|
88
96
|
agentConfirmWriteFile: boolean;
|
package/dist/config/index.js
CHANGED
|
@@ -151,6 +151,8 @@ function createConfig() {
|
|
|
151
151
|
ollamaNumCtx: 0,
|
|
152
152
|
customBaseUrl: '',
|
|
153
153
|
agentConfirmation: 'dangerous',
|
|
154
|
+
telegramApproval: false,
|
|
155
|
+
telegramChatId: '',
|
|
154
156
|
agentConfirmDeleteFile: true,
|
|
155
157
|
agentConfirmExecuteCommand: true,
|
|
156
158
|
agentConfirmWriteFile: false,
|
package/dist/config/providers.js
CHANGED
|
@@ -19,6 +19,7 @@ export const PROVIDERS = {
|
|
|
19
19
|
},
|
|
20
20
|
models: [
|
|
21
21
|
{ id: 'glm-5.3', name: 'GLM-5.3', description: 'Latest flagship for project-scale engineering (1M context)' },
|
|
22
|
+
{ id: 'glm-5.3-flash', name: 'GLM-5.3 Flash', description: 'Same 1M context at a twentieth of the price' },
|
|
22
23
|
{ id: 'glm-5.2', name: 'GLM-5.2', description: 'Previous flagship for project-scale engineering (1M context)' },
|
|
23
24
|
{ id: 'glm-5-turbo', name: 'GLM-5 Turbo', description: 'Fast GLM-5 variant, available to all users' },
|
|
24
25
|
],
|
|
@@ -52,6 +53,7 @@ export const PROVIDERS = {
|
|
|
52
53
|
// (`z.ai-cn*`) bills separately and is not covered by that page, so it is
|
|
53
54
|
// left alone until its own listing is checked.
|
|
54
55
|
{ id: 'glm-5.3', name: 'GLM-5.3', description: 'Latest flagship for project-scale engineering (1M context)' },
|
|
56
|
+
{ id: 'glm-5.3-flash', name: 'GLM-5.3 Flash', description: 'Same 1M context at a twentieth of the price' },
|
|
55
57
|
{ id: 'glm-5.2', name: 'GLM-5.2', description: 'Previous flagship for project-scale engineering (1M context)' },
|
|
56
58
|
{ id: 'glm-5-turbo', name: 'GLM-5 Turbo', description: 'Fast GLM-5 variant' },
|
|
57
59
|
],
|
package/dist/renderer/App.d.ts
CHANGED
|
@@ -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
|
*/
|
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
|
}
|
|
@@ -2586,7 +2632,8 @@ export class App {
|
|
|
2586
2632
|
else {
|
|
2587
2633
|
// API key entry
|
|
2588
2634
|
const selectedProvider = this.loginProviders[this.loginProviderIndex];
|
|
2589
|
-
this.
|
|
2635
|
+
const heading = this.secretPrompt || `Enter API Key for ${selectedProvider.name}`;
|
|
2636
|
+
this.screen.writeLine(y++, heading, fg.cyan + style.bold);
|
|
2590
2637
|
y++;
|
|
2591
2638
|
// API key input (masked)
|
|
2592
2639
|
const maskedKey = this.loginApiKey.length > 0
|
|
@@ -7,6 +7,10 @@
|
|
|
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 { raceApproval } from '../utils/approvalRace.js';
|
|
13
|
+
import { describeAuditTarget } from '../utils/auditLog.js';
|
|
10
14
|
import { config, autoSaveSession, getCurrentSessionId } from '../config/index.js';
|
|
11
15
|
import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
|
|
12
16
|
import { getGitStatus, isGitRepository } from '../utils/git.js';
|
|
@@ -155,28 +159,68 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
155
159
|
const rawIterations = config.get('agentMaxIterations') || 50;
|
|
156
160
|
app.setAgentMaxIterations(Math.max(5, rawIterations));
|
|
157
161
|
const confirmationMode = config.get('agentConfirmation') || 'dangerous';
|
|
162
|
+
// Read the Telegram credentials once for the whole run rather than per tool
|
|
163
|
+
// call: they come from the OS keychain, and paying that on every dangerous
|
|
164
|
+
// tool would put a keychain round-trip in front of each confirmation.
|
|
165
|
+
// Null means the feature is off or half-configured, and the terminal is
|
|
166
|
+
// then the only place the question appears — exactly as before.
|
|
167
|
+
const telegramCredentials = confirmationMode === 'dangerous'
|
|
168
|
+
? await loadTelegramCredentials()
|
|
169
|
+
: null;
|
|
158
170
|
const onRequestPermission = confirmationMode === 'dangerous'
|
|
159
|
-
? (toolCall) =>
|
|
160
|
-
|
|
161
|
-
|
|
171
|
+
? async (toolCall) => {
|
|
172
|
+
// `parameters.command` is the binary alone — `git`, not `git status`.
|
|
173
|
+
// Showing that asks someone to approve a command they have not been
|
|
174
|
+
// shown, which is the one thing this gate must not do. The audit
|
|
175
|
+
// record already joins the binary with its arguments; reuse it rather
|
|
176
|
+
// than writing a second, subtly different answer.
|
|
177
|
+
const target = describeAuditTarget(toolCall);
|
|
162
178
|
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
|
-
|
|
179
|
+
const inTerminal = {
|
|
180
|
+
answer: new Promise((resolve) => {
|
|
181
|
+
app.showConfirm({
|
|
182
|
+
title: '⚠️ Confirm Action',
|
|
183
|
+
message: [
|
|
184
|
+
'The agent wants to execute:',
|
|
185
|
+
'',
|
|
186
|
+
` ${toolCall.tool}`,
|
|
187
|
+
` ${shortTarget}`,
|
|
188
|
+
'',
|
|
189
|
+
telegramCredentials ? 'Allow this action? (or answer on Telegram)' : 'Allow this action?',
|
|
190
|
+
],
|
|
191
|
+
confirmLabel: 'Allow',
|
|
192
|
+
cancelLabel: 'Deny',
|
|
193
|
+
extraOption: { label: 'Always Allow', onSelect: () => resolve('allow_always') },
|
|
194
|
+
onConfirm: () => resolve('allow_once'),
|
|
195
|
+
onCancel: () => resolve('reject_always'),
|
|
196
|
+
});
|
|
197
|
+
}),
|
|
198
|
+
// Answered on the phone: take the dialog down without running either
|
|
199
|
+
// callback, since the decision is already made and taken.
|
|
200
|
+
withdraw: (winner) => app.dismissConfirm(`Answered on Telegram — ${winner}.`),
|
|
201
|
+
};
|
|
202
|
+
let onPhone = null;
|
|
203
|
+
if (telegramCredentials) {
|
|
204
|
+
// Report a failure to *ask* once, in the terminal. Without this a
|
|
205
|
+
// wrong chat id looks exactly like a phone nobody picked up.
|
|
206
|
+
const telegram = new TelegramApproval(telegramCredentials, reason => app.notifyWarn(`Telegram: ${reason}`));
|
|
207
|
+
onPhone = {
|
|
208
|
+
answer: telegram
|
|
209
|
+
.ask(target, toolCall.tool, true)
|
|
210
|
+
.then(answer => (answer ? outcomeForAnswer(answer) : null))
|
|
211
|
+
// A phone that cannot be reached is not a denial. Step aside and
|
|
212
|
+
// let the terminal decide, however long that takes.
|
|
213
|
+
.catch(() => null),
|
|
214
|
+
withdraw: (winner) => telegram.withdraw(winner),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const { answer } = await raceApproval(inTerminal, onPhone, outcome => describePermissionOutcome(outcome));
|
|
218
|
+
// Nobody answered — neither side could even ask. `classifyPermissionOutcome`
|
|
219
|
+
// fails closed on anything it does not recognise, and this is spelled
|
|
220
|
+
// out rather than left to that: a question that was never put must
|
|
221
|
+
// never read as a yes.
|
|
222
|
+
return answer ?? 'reject_once';
|
|
223
|
+
}
|
|
180
224
|
: undefined;
|
|
181
225
|
const result = await runAgent(enrichedTask, context, {
|
|
182
226
|
dryRun,
|
|
@@ -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,26 @@ 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: 'telegramChatId',
|
|
141
|
+
label: 'Telegram chat ID',
|
|
142
|
+
getValue: () => config.get('telegramChatId') || '',
|
|
143
|
+
type: 'text',
|
|
144
|
+
},
|
|
125
145
|
{
|
|
126
146
|
key: 'agentConfirmDeleteFile',
|
|
127
147
|
label: 'Confirm: delete_file',
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One question, two places it can be answered, and exactly one answer.
|
|
3
|
+
*
|
|
4
|
+
* The terminal and the phone are both live at once. Whichever comes back first
|
|
5
|
+
* decides, and the other has to be taken down — a dialog left on screen after
|
|
6
|
+
* the phone answered would ask again, and a Telegram message left with three
|
|
7
|
+
* live buttons invites a tap that can no longer do anything.
|
|
8
|
+
*
|
|
9
|
+
* Kept apart from the agent loop and from the Telegram client because the part
|
|
10
|
+
* that goes wrong is the ordering, and the ordering can be tested with two
|
|
11
|
+
* promises and no network.
|
|
12
|
+
*/
|
|
13
|
+
export interface RaceParticipant<T> {
|
|
14
|
+
/** Resolves when this side is answered. Must never reject: a rejection here
|
|
15
|
+
* would take down a run over a failure to *ask*, which is not the same thing
|
|
16
|
+
* as a denial and must not be treated as one. */
|
|
17
|
+
answer: Promise<T | null>;
|
|
18
|
+
/** Take this side down because the other one won. Must be safe to call when
|
|
19
|
+
* this side never started, and must not itself answer anything. */
|
|
20
|
+
withdraw: (winner: string) => void | Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Settle a question from whichever side answers first.
|
|
24
|
+
*
|
|
25
|
+
* A side that resolves `null` has declined to decide — it failed to send, or it
|
|
26
|
+
* was cancelled — and is not treated as a winner. When both come back null,
|
|
27
|
+
* nobody answered, and the caller decides what that means. It must not mean
|
|
28
|
+
* approval.
|
|
29
|
+
*/
|
|
30
|
+
export declare function raceApproval<T>(terminal: RaceParticipant<T>, remote: RaceParticipant<T> | null, describe: (answer: T) => string): Promise<{
|
|
31
|
+
answer: T | null;
|
|
32
|
+
from: 'terminal' | 'remote' | 'nobody';
|
|
33
|
+
}>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One question, two places it can be answered, and exactly one answer.
|
|
3
|
+
*
|
|
4
|
+
* The terminal and the phone are both live at once. Whichever comes back first
|
|
5
|
+
* decides, and the other has to be taken down — a dialog left on screen after
|
|
6
|
+
* the phone answered would ask again, and a Telegram message left with three
|
|
7
|
+
* live buttons invites a tap that can no longer do anything.
|
|
8
|
+
*
|
|
9
|
+
* Kept apart from the agent loop and from the Telegram client because the part
|
|
10
|
+
* that goes wrong is the ordering, and the ordering can be tested with two
|
|
11
|
+
* promises and no network.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Settle a question from whichever side answers first.
|
|
15
|
+
*
|
|
16
|
+
* A side that resolves `null` has declined to decide — it failed to send, or it
|
|
17
|
+
* was cancelled — and is not treated as a winner. When both come back null,
|
|
18
|
+
* nobody answered, and the caller decides what that means. It must not mean
|
|
19
|
+
* approval.
|
|
20
|
+
*/
|
|
21
|
+
export async function raceApproval(terminal, remote, describe) {
|
|
22
|
+
// With no remote side there is nothing to race and nothing to withdraw.
|
|
23
|
+
if (!remote) {
|
|
24
|
+
return { answer: await terminal.answer, from: 'terminal' };
|
|
25
|
+
}
|
|
26
|
+
// Not Promise.race. Race settles on the first promise to *finish*, including
|
|
27
|
+
// one that finished by declining — and the branch for the side that answers
|
|
28
|
+
// later still runs, consuming the answer into a result nobody reads. Each
|
|
29
|
+
// side instead gets one shot at a shared resolver, and only a real answer
|
|
30
|
+
// takes it.
|
|
31
|
+
return new Promise(resolve => {
|
|
32
|
+
let settled = false;
|
|
33
|
+
const claim = async (from, answer, loser) => {
|
|
34
|
+
// A null is a side stepping aside, not an answer. Let the other run on.
|
|
35
|
+
if (answer === null || settled)
|
|
36
|
+
return;
|
|
37
|
+
settled = true;
|
|
38
|
+
// Withdrawal is best-effort: the decision is already made, and a network
|
|
39
|
+
// failure closing the other side must not undo it or throw in its place.
|
|
40
|
+
try {
|
|
41
|
+
await loser.withdraw(describe(answer));
|
|
42
|
+
}
|
|
43
|
+
catch { /* already decided */ }
|
|
44
|
+
resolve({ answer, from });
|
|
45
|
+
};
|
|
46
|
+
void terminal.answer.then(a => claim('terminal', a, remote));
|
|
47
|
+
void remote.answer.then(a => claim('remote', a, terminal));
|
|
48
|
+
// Both declined. Nobody answered — which the caller must not read as
|
|
49
|
+
// approval, and which is why this returns a name for it rather than a null
|
|
50
|
+
// that looks like every other null.
|
|
51
|
+
void Promise.all([terminal.answer, remote.answer]).then(() => {
|
|
52
|
+
if (settled)
|
|
53
|
+
return;
|
|
54
|
+
settled = true;
|
|
55
|
+
resolve({ answer: null, from: 'nobody' });
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answer a pending tool confirmation from a phone.
|
|
3
|
+
*
|
|
4
|
+
* The agent already parks on `onRequestPermission` and resumes when that promise
|
|
5
|
+
* settles. This adds a second way to settle it — nothing in the gate changes,
|
|
6
|
+
* and whichever answers first wins. A run that would have sat at an empty desk
|
|
7
|
+
* until someone came back can now continue.
|
|
8
|
+
*
|
|
9
|
+
* Telegram carries it because Telegram runs the infrastructure: the bot API is
|
|
10
|
+
* polled outbound from this machine, so there is no server to host, no inbound
|
|
11
|
+
* port, and no per-user cost. Long polling, never a webhook — a webhook needs a
|
|
12
|
+
* public address, which is precisely what we do not want to need. That is also
|
|
13
|
+
* why this works identically on Linux and Windows, where the Mac app's CloudKit
|
|
14
|
+
* route does not exist.
|
|
15
|
+
*
|
|
16
|
+
* SECURITY: a Telegram bot is reachable by anyone who learns its username. Every
|
|
17
|
+
* update is checked against the configured chat id before it can decide
|
|
18
|
+
* anything. Without that check a stranger who found the bot could approve a
|
|
19
|
+
* destructive command on someone else's machine. See `isFromOwner`.
|
|
20
|
+
*
|
|
21
|
+
* PRIVACY: the message carries the command line, so Telegram sees it. Stated in
|
|
22
|
+
* the docs rather than hidden — it is the reason this is opt-in.
|
|
23
|
+
*
|
|
24
|
+
* Ported from the Mac app's `TelegramApproval.swift`; the wire format, the token
|
|
25
|
+
* matching and the ownership check are deliberately identical, so a bug found on
|
|
26
|
+
* one side is findable on the other.
|
|
27
|
+
*/
|
|
28
|
+
/** What the phone sent back. Mirrors the three buttons the desktop offers. */
|
|
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
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The message text.
|
|
38
|
+
*
|
|
39
|
+
* Fenced, because a command containing underscores or asterisks would otherwise
|
|
40
|
+
* be mangled by Markdown parsing into something that is not what will run — and
|
|
41
|
+
* approving a command you were shown incorrectly is the one failure this whole
|
|
42
|
+
* feature must not have.
|
|
43
|
+
*/
|
|
44
|
+
export declare function composeMessage(command: string, toolName: string, isDestructive: boolean): string;
|
|
45
|
+
/**
|
|
46
|
+
* Only the configured chat may decide.
|
|
47
|
+
*
|
|
48
|
+
* A bot's username is discoverable, so without this an unrelated Telegram user
|
|
49
|
+
* could approve a command on someone else's machine. Telegram sends the id as a
|
|
50
|
+
* number; it is compared as a string because that is how the user typed it.
|
|
51
|
+
*/
|
|
52
|
+
export declare function isFromOwner(callback: unknown, chatID: string): boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Split `run:<token>` into its parts.
|
|
55
|
+
*
|
|
56
|
+
* A callback whose token does not match the question in flight is ignored, and
|
|
57
|
+
* that is what stops a stale button — tapped after the run moved on — from
|
|
58
|
+
* answering a later question it was never shown.
|
|
59
|
+
*/
|
|
60
|
+
export declare function parseCallbackData(data: string): {
|
|
61
|
+
answer: TelegramAnswer;
|
|
62
|
+
token: string;
|
|
63
|
+
} | null;
|
|
64
|
+
/** The keyboard sent with the question. Shape pinned by a test — a renamed
|
|
65
|
+
* callback_data field would leave three buttons that silently do nothing. */
|
|
66
|
+
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
|
+
/**
|
|
79
|
+
* Telegram's three buttons, in the terms the agent's gate speaks.
|
|
80
|
+
*
|
|
81
|
+
* The gate has four outcomes and `classifyPermissionOutcome` fails closed on
|
|
82
|
+
* anything it does not recognise, so this must be exhaustive rather than
|
|
83
|
+
* defaulted — a typo here would read as a denial, which is safe but silently
|
|
84
|
+
* wrong, and the user would tap Run and watch nothing happen.
|
|
85
|
+
*/
|
|
86
|
+
export declare function outcomeForAnswer(answer: TelegramAnswer): 'allow_once' | 'reject_once' | 'reject_always';
|
|
87
|
+
/**
|
|
88
|
+
* How a decision reads on the other device, once it is too late to change it.
|
|
89
|
+
*
|
|
90
|
+
* The withdrawn side says what happened rather than just going blank, so
|
|
91
|
+
* someone reaching for their phone a moment late learns what they missed
|
|
92
|
+
* instead of finding a message that silently lost its buttons.
|
|
93
|
+
*/
|
|
94
|
+
export declare function describePermissionOutcome(outcome: string): string;
|
|
95
|
+
/**
|
|
96
|
+
* Telegram's failure, phrased for someone who is setting this up.
|
|
97
|
+
*
|
|
98
|
+
* `chat not found` is the one people actually hit: the id belongs to a
|
|
99
|
+
* conversation with a *different* bot, or the bot has never been messaged from
|
|
100
|
+
* that chat at all. Repeating the API's words and adding what they mean beats
|
|
101
|
+
* a generic failure that sends them back to the docs.
|
|
102
|
+
*/
|
|
103
|
+
export declare function describeApiError(status: number, json: Record<string, unknown> | null): string;
|
|
104
|
+
export declare class TelegramApproval {
|
|
105
|
+
private readonly credentials;
|
|
106
|
+
private outstanding;
|
|
107
|
+
private updateOffset;
|
|
108
|
+
private polling;
|
|
109
|
+
/** Called once when the question could not be put at all. Not for a missing
|
|
110
|
+
* answer — only for a failure to ask. */
|
|
111
|
+
private readonly onProblem?;
|
|
112
|
+
constructor(credentials: TelegramCredentials, onProblem?: (reason: string) => void);
|
|
113
|
+
/**
|
|
114
|
+
* Send the question and wait.
|
|
115
|
+
*
|
|
116
|
+
* Resolves `null` when no answer arrived — the terminal was used instead, the
|
|
117
|
+
* caller aborted, or Telegram could not be reached. **A null is never
|
|
118
|
+
* approval**: the caller keeps its own gate and decides for itself.
|
|
119
|
+
*/
|
|
120
|
+
ask(command: string, toolName: string, isDestructive: boolean, signal?: AbortSignal): Promise<TelegramAnswer | null>;
|
|
121
|
+
/**
|
|
122
|
+
* The terminal answered first. Close the question on the phone so nobody taps
|
|
123
|
+
* a button that would do nothing, and say where it was decided.
|
|
124
|
+
*/
|
|
125
|
+
withdraw(decidedInTerminal: string): Promise<void>;
|
|
126
|
+
/**
|
|
127
|
+
* Why the last call failed, in Telegram's own words.
|
|
128
|
+
*
|
|
129
|
+
* Kept because swallowing it made a misconfiguration indistinguishable from
|
|
130
|
+
* silence: a wrong chat id answers `chat not found` on the very first send,
|
|
131
|
+
* and reporting nothing left the user watching a phone that was never going
|
|
132
|
+
* to ring.
|
|
133
|
+
*/
|
|
134
|
+
private lastError;
|
|
135
|
+
private post;
|
|
136
|
+
private sendQuestion;
|
|
137
|
+
private edit;
|
|
138
|
+
private poll;
|
|
139
|
+
private handle;
|
|
140
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answer a pending tool confirmation from a phone.
|
|
3
|
+
*
|
|
4
|
+
* The agent already parks on `onRequestPermission` and resumes when that promise
|
|
5
|
+
* settles. This adds a second way to settle it — nothing in the gate changes,
|
|
6
|
+
* and whichever answers first wins. A run that would have sat at an empty desk
|
|
7
|
+
* until someone came back can now continue.
|
|
8
|
+
*
|
|
9
|
+
* Telegram carries it because Telegram runs the infrastructure: the bot API is
|
|
10
|
+
* polled outbound from this machine, so there is no server to host, no inbound
|
|
11
|
+
* port, and no per-user cost. Long polling, never a webhook — a webhook needs a
|
|
12
|
+
* public address, which is precisely what we do not want to need. That is also
|
|
13
|
+
* why this works identically on Linux and Windows, where the Mac app's CloudKit
|
|
14
|
+
* route does not exist.
|
|
15
|
+
*
|
|
16
|
+
* SECURITY: a Telegram bot is reachable by anyone who learns its username. Every
|
|
17
|
+
* update is checked against the configured chat id before it can decide
|
|
18
|
+
* anything. Without that check a stranger who found the bot could approve a
|
|
19
|
+
* destructive command on someone else's machine. See `isFromOwner`.
|
|
20
|
+
*
|
|
21
|
+
* PRIVACY: the message carries the command line, so Telegram sees it. Stated in
|
|
22
|
+
* the docs rather than hidden — it is the reason this is opt-in.
|
|
23
|
+
*
|
|
24
|
+
* Ported from the Mac app's `TelegramApproval.swift`; the wire format, the token
|
|
25
|
+
* matching and the ownership check are deliberately identical, so a bug found on
|
|
26
|
+
* one side is findable on the other.
|
|
27
|
+
*/
|
|
28
|
+
const ANSWERS = ['run', 'skip', 'cancel'];
|
|
29
|
+
/** Longest command we put in a message. Telegram caps at 4096 for the whole
|
|
30
|
+
* text; this keeps room for the heading and the fences, and a command longer
|
|
31
|
+
* than this is not something anyone reads off a phone anyway. */
|
|
32
|
+
const MAX_COMMAND_CHARS = 300;
|
|
33
|
+
/**
|
|
34
|
+
* The message text.
|
|
35
|
+
*
|
|
36
|
+
* Fenced, because a command containing underscores or asterisks would otherwise
|
|
37
|
+
* be mangled by Markdown parsing into something that is not what will run — and
|
|
38
|
+
* approving a command you were shown incorrectly is the one failure this whole
|
|
39
|
+
* feature must not have.
|
|
40
|
+
*/
|
|
41
|
+
export function composeMessage(command, toolName, isDestructive) {
|
|
42
|
+
const head = isDestructive
|
|
43
|
+
? '⚠️ Codeep wants to run a destructive tool'
|
|
44
|
+
: 'Codeep needs approval';
|
|
45
|
+
const trimmed = command.length > MAX_COMMAND_CHARS
|
|
46
|
+
? command.slice(0, MAX_COMMAND_CHARS - 1) + '…'
|
|
47
|
+
: command;
|
|
48
|
+
// A fence inside the command would close ours early and leak the rest as
|
|
49
|
+
// prose. Neutralise it rather than trusting the input.
|
|
50
|
+
const safe = trimmed.replace(/```/g, "'''");
|
|
51
|
+
return `${head}\n\n\`${toolName}\`\n\n\`\`\`\n${safe}\n\`\`\``;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Only the configured chat may decide.
|
|
55
|
+
*
|
|
56
|
+
* A bot's username is discoverable, so without this an unrelated Telegram user
|
|
57
|
+
* could approve a command on someone else's machine. Telegram sends the id as a
|
|
58
|
+
* number; it is compared as a string because that is how the user typed it.
|
|
59
|
+
*/
|
|
60
|
+
export function isFromOwner(callback, chatID) {
|
|
61
|
+
if (!chatID)
|
|
62
|
+
return false;
|
|
63
|
+
const message = callback?.message;
|
|
64
|
+
const chat = message?.chat;
|
|
65
|
+
const id = chat?.id;
|
|
66
|
+
if (typeof id === 'number')
|
|
67
|
+
return String(id) === chatID;
|
|
68
|
+
if (typeof id === 'string')
|
|
69
|
+
return id === chatID;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Split `run:<token>` into its parts.
|
|
74
|
+
*
|
|
75
|
+
* A callback whose token does not match the question in flight is ignored, and
|
|
76
|
+
* that is what stops a stale button — tapped after the run moved on — from
|
|
77
|
+
* answering a later question it was never shown.
|
|
78
|
+
*/
|
|
79
|
+
export function parseCallbackData(data) {
|
|
80
|
+
const separator = data.indexOf(':');
|
|
81
|
+
if (separator <= 0)
|
|
82
|
+
return null;
|
|
83
|
+
const answer = data.slice(0, separator);
|
|
84
|
+
const token = data.slice(separator + 1);
|
|
85
|
+
if (!token)
|
|
86
|
+
return null;
|
|
87
|
+
if (!ANSWERS.includes(answer))
|
|
88
|
+
return null;
|
|
89
|
+
return { answer: answer, token };
|
|
90
|
+
}
|
|
91
|
+
/** The keyboard sent with the question. Shape pinned by a test — a renamed
|
|
92
|
+
* callback_data field would leave three buttons that silently do nothing. */
|
|
93
|
+
export function buildKeyboard(token) {
|
|
94
|
+
return {
|
|
95
|
+
inline_keyboard: [[
|
|
96
|
+
{ text: 'Run', callback_data: `run:${token}` },
|
|
97
|
+
{ text: 'Skip', callback_data: `skip:${token}` },
|
|
98
|
+
{ text: 'Cancel', callback_data: `cancel:${token}` },
|
|
99
|
+
]],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
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
|
+
/**
|
|
119
|
+
* Telegram's three buttons, in the terms the agent's gate speaks.
|
|
120
|
+
*
|
|
121
|
+
* The gate has four outcomes and `classifyPermissionOutcome` fails closed on
|
|
122
|
+
* anything it does not recognise, so this must be exhaustive rather than
|
|
123
|
+
* defaulted — a typo here would read as a denial, which is safe but silently
|
|
124
|
+
* wrong, and the user would tap Run and watch nothing happen.
|
|
125
|
+
*/
|
|
126
|
+
export function outcomeForAnswer(answer) {
|
|
127
|
+
switch (answer) {
|
|
128
|
+
case 'run': return 'allow_once';
|
|
129
|
+
// Skip this one and carry on — not a standing refusal.
|
|
130
|
+
case 'skip': return 'reject_once';
|
|
131
|
+
// Stop asking. The phone has no "always allow": granting a blanket
|
|
132
|
+
// permission is a decision that belongs at the keyboard, where you can see
|
|
133
|
+
// what you are granting it to.
|
|
134
|
+
case 'cancel': return 'reject_always';
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* How a decision reads on the other device, once it is too late to change it.
|
|
139
|
+
*
|
|
140
|
+
* The withdrawn side says what happened rather than just going blank, so
|
|
141
|
+
* someone reaching for their phone a moment late learns what they missed
|
|
142
|
+
* instead of finding a message that silently lost its buttons.
|
|
143
|
+
*/
|
|
144
|
+
export function describePermissionOutcome(outcome) {
|
|
145
|
+
switch (outcome) {
|
|
146
|
+
case 'allow_once': return 'allowed';
|
|
147
|
+
case 'allow_always': return 'allowed, and always from now on';
|
|
148
|
+
case 'reject_once': return 'skipped';
|
|
149
|
+
case 'reject_always': return 'denied';
|
|
150
|
+
default: return 'decided';
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Telegram's failure, phrased for someone who is setting this up.
|
|
155
|
+
*
|
|
156
|
+
* `chat not found` is the one people actually hit: the id belongs to a
|
|
157
|
+
* conversation with a *different* bot, or the bot has never been messaged from
|
|
158
|
+
* that chat at all. Repeating the API's words and adding what they mean beats
|
|
159
|
+
* a generic failure that sends them back to the docs.
|
|
160
|
+
*/
|
|
161
|
+
export function describeApiError(status, json) {
|
|
162
|
+
const description = typeof json?.description === 'string' ? json.description : '';
|
|
163
|
+
if (/chat not found/i.test(description)) {
|
|
164
|
+
return 'Telegram says "chat not found" — the chat ID does not belong to a conversation with this bot. Message the bot from that chat, then read the ID back from getUpdates.';
|
|
165
|
+
}
|
|
166
|
+
if (/bot was blocked/i.test(description)) {
|
|
167
|
+
return 'Telegram says the bot was blocked by this chat. Unblock it and try again.';
|
|
168
|
+
}
|
|
169
|
+
if (status === 401) {
|
|
170
|
+
return 'Telegram rejected the bot token. Re-enter it with /telegram.';
|
|
171
|
+
}
|
|
172
|
+
return description ? `Telegram refused the message: ${description}` : `Telegram returned HTTP ${status}.`;
|
|
173
|
+
}
|
|
174
|
+
// ─── The client ───────────────────────────────────────────────────────────────
|
|
175
|
+
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;
|
|
180
|
+
export class TelegramApproval {
|
|
181
|
+
credentials;
|
|
182
|
+
outstanding = null;
|
|
183
|
+
updateOffset = 0;
|
|
184
|
+
polling = false;
|
|
185
|
+
/** Called once when the question could not be put at all. Not for a missing
|
|
186
|
+
* answer — only for a failure to ask. */
|
|
187
|
+
onProblem;
|
|
188
|
+
constructor(credentials, onProblem) {
|
|
189
|
+
this.credentials = credentials;
|
|
190
|
+
this.onProblem = onProblem;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Send the question and wait.
|
|
194
|
+
*
|
|
195
|
+
* Resolves `null` when no answer arrived — the terminal was used instead, the
|
|
196
|
+
* caller aborted, or Telegram could not be reached. **A null is never
|
|
197
|
+
* approval**: the caller keeps its own gate and decides for itself.
|
|
198
|
+
*/
|
|
199
|
+
async ask(command, toolName, isDestructive, signal) {
|
|
200
|
+
const token = randomToken();
|
|
201
|
+
const messageID = await this.sendQuestion(composeMessage(command, toolName, isDestructive), token);
|
|
202
|
+
if (messageID === null) {
|
|
203
|
+
// Say it once, here, rather than leaving the caller to guess from a null
|
|
204
|
+
// that also means "answered elsewhere" and "cancelled".
|
|
205
|
+
this.onProblem?.(this.lastError ?? 'the question could not be sent');
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
return new Promise(resolve => {
|
|
209
|
+
let settled = false;
|
|
210
|
+
const finish = (answer) => {
|
|
211
|
+
if (settled)
|
|
212
|
+
return;
|
|
213
|
+
settled = true;
|
|
214
|
+
this.outstanding = null;
|
|
215
|
+
this.polling = false;
|
|
216
|
+
resolve(answer);
|
|
217
|
+
};
|
|
218
|
+
this.outstanding = { token, messageID, resolve: finish };
|
|
219
|
+
if (signal) {
|
|
220
|
+
if (signal.aborted) {
|
|
221
|
+
finish(null);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
signal.addEventListener('abort', () => finish(null), { once: true });
|
|
225
|
+
}
|
|
226
|
+
void this.poll();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The terminal answered first. Close the question on the phone so nobody taps
|
|
231
|
+
* a button that would do nothing, and say where it was decided.
|
|
232
|
+
*/
|
|
233
|
+
async withdraw(decidedInTerminal) {
|
|
234
|
+
const pending = this.outstanding;
|
|
235
|
+
if (!pending)
|
|
236
|
+
return;
|
|
237
|
+
this.outstanding = null;
|
|
238
|
+
this.polling = false;
|
|
239
|
+
pending.resolve(null);
|
|
240
|
+
await this.edit(pending.messageID, `Answered in the terminal — ${decidedInTerminal}.`);
|
|
241
|
+
}
|
|
242
|
+
// ── plumbing ──
|
|
243
|
+
/**
|
|
244
|
+
* Why the last call failed, in Telegram's own words.
|
|
245
|
+
*
|
|
246
|
+
* Kept because swallowing it made a misconfiguration indistinguishable from
|
|
247
|
+
* silence: a wrong chat id answers `chat not found` on the very first send,
|
|
248
|
+
* and reporting nothing left the user watching a phone that was never going
|
|
249
|
+
* to ring.
|
|
250
|
+
*/
|
|
251
|
+
lastError = null;
|
|
252
|
+
async post(method, body) {
|
|
253
|
+
try {
|
|
254
|
+
const response = await fetch(`${API}/bot${this.credentials.botToken}/${method}`, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: { 'Content-Type': 'application/json' },
|
|
257
|
+
body: JSON.stringify(body),
|
|
258
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
259
|
+
});
|
|
260
|
+
const json = await response.json().catch(() => null);
|
|
261
|
+
if (!response.ok || json?.ok === false) {
|
|
262
|
+
this.lastError = describeApiError(response.status, json);
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
this.lastError = null;
|
|
266
|
+
return json;
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
// Network, timeout, malformed JSON. Still "no answer from the phone",
|
|
270
|
+
// but now it can say which.
|
|
271
|
+
this.lastError = error?.message || 'could not reach Telegram';
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async sendQuestion(text, token) {
|
|
276
|
+
const json = await this.post('sendMessage', {
|
|
277
|
+
chat_id: this.credentials.chatID,
|
|
278
|
+
text,
|
|
279
|
+
parse_mode: 'Markdown',
|
|
280
|
+
reply_markup: buildKeyboard(token),
|
|
281
|
+
});
|
|
282
|
+
const result = json?.result;
|
|
283
|
+
return typeof result?.message_id === 'number' ? result.message_id : null;
|
|
284
|
+
}
|
|
285
|
+
async edit(messageID, text) {
|
|
286
|
+
await this.post('editMessageText', {
|
|
287
|
+
chat_id: this.credentials.chatID,
|
|
288
|
+
message_id: messageID,
|
|
289
|
+
text,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
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
|
+
async handle(callback) {
|
|
319
|
+
const pending = this.outstanding;
|
|
320
|
+
if (!pending)
|
|
321
|
+
return;
|
|
322
|
+
if (!isFromOwner(callback, this.credentials.chatID))
|
|
323
|
+
return;
|
|
324
|
+
const data = callback.data;
|
|
325
|
+
if (typeof data !== 'string')
|
|
326
|
+
return;
|
|
327
|
+
const parsed = parseCallbackData(data);
|
|
328
|
+
if (!parsed || parsed.token !== pending.token)
|
|
329
|
+
return;
|
|
330
|
+
this.outstanding = null;
|
|
331
|
+
this.polling = false;
|
|
332
|
+
const id = callback.id;
|
|
333
|
+
if (typeof id === 'string')
|
|
334
|
+
await this.post('answerCallbackQuery', { callback_query_id: id });
|
|
335
|
+
await this.edit(pending.messageID, `${capitalise(parsed.answer)} — sent to your terminal.`);
|
|
336
|
+
pending.resolve(parsed.answer);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function randomToken() {
|
|
340
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
341
|
+
}
|
|
342
|
+
function capitalise(value) {
|
|
343
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
344
|
+
}
|
|
345
|
+
function sleep(ms) {
|
|
346
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
347
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the Telegram bot token lives, and how the two halves are read back.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately separate from `telegramApproval.ts`, which stays free of I/O so
|
|
5
|
+
* the parts that decide whether a stranger may approve your commands can be
|
|
6
|
+
* tested without a keychain, a config file, or a network.
|
|
7
|
+
*
|
|
8
|
+
* The token goes in the OS keychain — it *is* the bot, and anyone holding it can
|
|
9
|
+
* send messages as you. The chat id is ordinary config: it names a conversation
|
|
10
|
+
* and is useless on its own.
|
|
11
|
+
*/
|
|
12
|
+
import type { TelegramCredentials } from './telegramApproval.js';
|
|
13
|
+
export declare function setTelegramToken(token: string): Promise<void>;
|
|
14
|
+
export declare function clearTelegramToken(): Promise<void>;
|
|
15
|
+
export declare function hasTelegramToken(): Promise<boolean>;
|
|
16
|
+
/**
|
|
17
|
+
* Both halves, or nothing.
|
|
18
|
+
*
|
|
19
|
+
* Returns null when the feature is off or either half is missing. Half-
|
|
20
|
+
* configured must behave exactly like off: a token with no chat id would send
|
|
21
|
+
* the question nowhere, and a chat id with no token cannot send at all — and
|
|
22
|
+
* both would otherwise stall a run waiting for an answer that was never asked.
|
|
23
|
+
*/
|
|
24
|
+
export declare function loadTelegramCredentials(): Promise<TelegramCredentials | null>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the Telegram bot token lives, and how the two halves are read back.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately separate from `telegramApproval.ts`, which stays free of I/O so
|
|
5
|
+
* the parts that decide whether a stranger may approve your commands can be
|
|
6
|
+
* tested without a keychain, a config file, or a network.
|
|
7
|
+
*
|
|
8
|
+
* The token goes in the OS keychain — it *is* the bot, and anyone holding it can
|
|
9
|
+
* send messages as you. The chat id is ordinary config: it names a conversation
|
|
10
|
+
* and is useless on its own.
|
|
11
|
+
*/
|
|
12
|
+
import { config } from '../config/index.js';
|
|
13
|
+
import { createSecureStorage } from './keychain.js';
|
|
14
|
+
/**
|
|
15
|
+
* The keychain is keyed by provider id, and this is not a provider.
|
|
16
|
+
*
|
|
17
|
+
* Reusing the store rather than adding a second keychain integration is the
|
|
18
|
+
* point; going through `setApiKey` is not, because that also records the id in
|
|
19
|
+
* `configuredProviderIds` and Telegram would start appearing in provider lists
|
|
20
|
+
* as something you could log out of.
|
|
21
|
+
*/
|
|
22
|
+
const CREDENTIAL_ID = 'telegram';
|
|
23
|
+
export async function setTelegramToken(token) {
|
|
24
|
+
await createSecureStorage(config).setApiKey(CREDENTIAL_ID, token.trim());
|
|
25
|
+
}
|
|
26
|
+
export async function clearTelegramToken() {
|
|
27
|
+
await createSecureStorage(config).deleteApiKey(CREDENTIAL_ID);
|
|
28
|
+
}
|
|
29
|
+
export async function hasTelegramToken() {
|
|
30
|
+
return !!(await createSecureStorage(config).getApiKey(CREDENTIAL_ID));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Both halves, or nothing.
|
|
34
|
+
*
|
|
35
|
+
* Returns null when the feature is off or either half is missing. Half-
|
|
36
|
+
* configured must behave exactly like off: a token with no chat id would send
|
|
37
|
+
* the question nowhere, and a chat id with no token cannot send at all — and
|
|
38
|
+
* both would otherwise stall a run waiting for an answer that was never asked.
|
|
39
|
+
*/
|
|
40
|
+
export async function loadTelegramCredentials() {
|
|
41
|
+
// Strictly true. A truthy check would accept the string 'true' left behind
|
|
42
|
+
// by an earlier build whose toggle wrote strings — and then the feature
|
|
43
|
+
// would be live while the settings row read OFF.
|
|
44
|
+
if (config.get('telegramApproval') !== true)
|
|
45
|
+
return null;
|
|
46
|
+
const chatID = String(config.get('telegramChatId') || '').trim();
|
|
47
|
+
if (!chatID)
|
|
48
|
+
return null;
|
|
49
|
+
const botToken = await createSecureStorage(config).getApiKey(CREDENTIAL_ID);
|
|
50
|
+
if (!botToken)
|
|
51
|
+
return null;
|
|
52
|
+
return { botToken, chatID };
|
|
53
|
+
}
|
|
@@ -10,6 +10,7 @@ import { formatResourceImpactReport } from './resourceImpact.js';
|
|
|
10
10
|
const MODEL_CONTEXT_WINDOWS = {
|
|
11
11
|
// Z.AI / ZhipuAI
|
|
12
12
|
'glm-5.3': 1_000_000,
|
|
13
|
+
'glm-5.3-flash': 1_000_000,
|
|
13
14
|
'glm-5.2': 1_000_000,
|
|
14
15
|
'glm-5.1': 200_000,
|
|
15
16
|
'glm-5-turbo': 202_752,
|
|
@@ -81,6 +82,10 @@ const MODEL_PRICING = {
|
|
|
81
82
|
// inferred from the match — the two being equal today is a coincidence of the
|
|
82
83
|
// price list, not a rule.
|
|
83
84
|
'glm-5.3': { inputPer1M: 1.40, outputPer1M: 4.40 },
|
|
85
|
+
// Flash lists at $0.15/$0.50 with a 50% launch promotion in force at the
|
|
86
|
+
// time of writing. The list price is what belongs here: a promotional rate
|
|
87
|
+
// would quietly understate every session's cost the day it ends.
|
|
88
|
+
'glm-5.3-flash': { inputPer1M: 0.15, outputPer1M: 0.50 },
|
|
84
89
|
'glm-5.2': { inputPer1M: 1.40, outputPer1M: 4.40 },
|
|
85
90
|
'glm-5.1': { inputPer1M: 1.40, outputPer1M: 4.40 },
|
|
86
91
|
'glm-5-turbo': { inputPer1M: 1.20, outputPer1M: 4.00 },
|
|
@@ -121,6 +126,9 @@ const MODEL_PRICING = {
|
|
|
121
126
|
// subscription alias (flat-fee in reality, priced notionally like K2.7 Code).
|
|
122
127
|
'kimi-k3': { inputPer1M: 3.00, outputPer1M: 15.00 },
|
|
123
128
|
'kimi-k2.7-code': { inputPer1M: 0.95, outputPer1M: 4.00 },
|
|
129
|
+
// Highspeed is the same model served faster, at exactly double the rate
|
|
130
|
+
// across every token category (platform.kimi.ai/docs/pricing/chat-k27-code).
|
|
131
|
+
'kimi-k2.7-code-highspeed': { inputPer1M: 1.90, outputPer1M: 8.00 },
|
|
124
132
|
// Kimi doesn't publish a distinct high-speed price in its main table.
|
|
125
133
|
// Leave that variant unpriced rather than presenting an invented estimate.
|
|
126
134
|
'kimi-k2.6': { inputPer1M: 0.95, outputPer1M: 4.00 },
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "
|
|
1
|
+
export declare const VERSION = "3.0.0";
|
package/dist/version.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
|
|
2
2
|
// Baked from package.json at build time so the bun-compiled binary reports
|
|
3
3
|
// the right version (it has no package.json on disk to read at runtime).
|
|
4
|
-
export const VERSION = '
|
|
4
|
+
export const VERSION = '3.0.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeep",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|