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.
@@ -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;
@@ -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,
@@ -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
  ],
@@ -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
  */
@@ -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.screen.writeLine(y++, `Enter API Key for ${selectedProvider.name}`, fg.cyan + style.bold);
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) => new Promise((resolve) => {
160
- const target = toolCall.parameters.path ||
161
- toolCall.parameters.command || 'unknown';
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
- 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
- })
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 = "2.24.0";
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 = '2.24.0';
4
+ export const VERSION = '3.0.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "2.24.0",
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",