bloby-bot 0.60.1 → 0.62.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.
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Telegram channel provider — DIRECT Bot API, no relay in the message path.
3
+ *
4
+ * Unlike Alexa (relay-mediated, degenerate provider), this provider holds the
5
+ * user's OWN @BotFather bot token (pasted at connect time) and long-polls
6
+ * `getUpdates` straight against api.telegram.org — no relay anywhere. Every
7
+ * inbound/outbound message (including media) flows Bloby ↔ Telegram directly.
8
+ * Outbound HTTPS only: works behind NAT, no public URL, no relay egress.
9
+ *
10
+ * It is lighter than the WhatsApp/Baileys provider (no reverse-engineered
11
+ * protocol, no QR, no auth-state files) — just a token string and a poll loop.
12
+ */
13
+
14
+ import { loadConfig } from '../../shared/config.js';
15
+ import { log } from '../../shared/logger.js';
16
+ import type { ChannelProvider, ChannelStatus, ChannelType } from './types.js';
17
+
18
+ const TG_API = 'https://api.telegram.org';
19
+ const POLL_TIMEOUT_S = 25; // long-poll hold time
20
+ const MAX_MESSAGE_CHARS = 4096; // Telegram hard limit per sendMessage
21
+ const TYPING_REFRESH_MS = 5_000; // Telegram "typing" expires ~5s
22
+
23
+ /** Image extracted from an inbound Telegram message. */
24
+ export interface TelegramImageAttachment {
25
+ mediaType: string;
26
+ data: string; // base64
27
+ }
28
+
29
+ /** Normalized inbound message handed to the ChannelManager. */
30
+ export interface TelegramInbound {
31
+ /** Chat id (string form of the numeric Telegram chat.id). Reply target. */
32
+ chatId: string;
33
+ /** Sender's numeric Telegram user id (string). */
34
+ fromUserId: string;
35
+ /** Display name (first name / @username) if available. */
36
+ senderName?: string;
37
+ text: string;
38
+ isGroup: boolean;
39
+ messageId?: number;
40
+ images?: TelegramImageAttachment[];
41
+ }
42
+
43
+ export type OnTelegramMessage = (msg: TelegramInbound) => void;
44
+ export type TranscribeFn = (audioBase64: string) => Promise<string | null>;
45
+
46
+ export class TelegramChannel implements ChannelProvider {
47
+ readonly type: ChannelType = 'telegram';
48
+
49
+ private token: string | null = null;
50
+ private botUsername: string | null = null;
51
+ private connected = false;
52
+ private offset = 0;
53
+ private intentionalDisconnect = false;
54
+ private pollAbort: AbortController | null = null;
55
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
56
+ private typingIntervals = new Map<string, ReturnType<typeof setInterval>>();
57
+
58
+ private onMessage: OnTelegramMessage;
59
+ private onStatusChange: (status: ChannelStatus) => void;
60
+ private transcribe: TranscribeFn | null;
61
+
62
+ constructor(
63
+ onMessage: OnTelegramMessage,
64
+ onStatusChange: (status: ChannelStatus) => void,
65
+ transcribe?: TranscribeFn,
66
+ ) {
67
+ this.onMessage = onMessage;
68
+ this.onStatusChange = onStatusChange;
69
+ this.transcribe = transcribe || null;
70
+ }
71
+
72
+ private api(method: string): string {
73
+ return `${TG_API}/bot${this.token}/${method}`;
74
+ }
75
+
76
+ hasCredentials(): boolean {
77
+ return !!loadConfig().channels?.telegram?.botToken;
78
+ }
79
+
80
+ getQrCode(): string | null {
81
+ return null;
82
+ }
83
+
84
+ getStatus(): ChannelStatus {
85
+ return {
86
+ channel: 'telegram',
87
+ connected: this.connected,
88
+ info: {
89
+ botUsername: this.botUsername || loadConfig().channels?.telegram?.botUsername || null,
90
+ linked: this.hasCredentials(),
91
+ hasCredentials: this.hasCredentials(),
92
+ },
93
+ };
94
+ }
95
+
96
+ async connect(): Promise<void> {
97
+ this.intentionalDisconnect = false;
98
+ const cfg = loadConfig().channels?.telegram;
99
+ if (!cfg?.botToken) {
100
+ log.warn('[telegram] No bot token configured — not connecting');
101
+ return;
102
+ }
103
+ this.token = cfg.botToken;
104
+ this.botUsername = cfg.botUsername || null;
105
+
106
+ // getMe confirms the token and learns the @username.
107
+ try {
108
+ const me = await this.call('getMe', {});
109
+ if (me?.username) this.botUsername = me.username;
110
+ log.ok(`[telegram] Connected as @${this.botUsername || 'unknown'} (id=${me?.id || '?'})`);
111
+ } catch (err: any) {
112
+ log.warn(`[telegram] getMe failed: ${err.message} — will retry in poll loop`);
113
+ }
114
+
115
+ this.connected = true;
116
+ this.emitStatus();
117
+ this.pollLoop(); // fire and forget
118
+ }
119
+
120
+ async disconnect(): Promise<void> {
121
+ this.intentionalDisconnect = true;
122
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
123
+ if (this.pollAbort) { try { this.pollAbort.abort(); } catch {} this.pollAbort = null; }
124
+ for (const interval of this.typingIntervals.values()) clearInterval(interval);
125
+ this.typingIntervals.clear();
126
+ this.connected = false;
127
+ this.emitStatus();
128
+ }
129
+
130
+ // ── Outbound ──────────────────────────────────────────────────────────────
131
+
132
+ async sendMessage(to: string, text: string): Promise<void> {
133
+ if (!this.token) { log.warn('[telegram] Cannot send — no token'); return; }
134
+ this.stopTyping(to);
135
+ // Telegram caps messages at 4096 chars — split on the limit.
136
+ for (const chunk of splitMessage(text, MAX_MESSAGE_CHARS)) {
137
+ try {
138
+ await this.call('sendMessage', { chat_id: to, text: chunk, link_preview_options: { is_disabled: true } });
139
+ } catch (err: any) {
140
+ log.warn(`[telegram] sendMessage to ${to} failed: ${err.message}`);
141
+ return;
142
+ }
143
+ }
144
+ log.info(`[telegram] Sent message to ${to} (${text.length} chars)`);
145
+ }
146
+
147
+ /** Send an image natively (used by ChannelManager for <BlobyImage> tags). */
148
+ async sendImage(to: string, image: Buffer, caption?: string, mimetype?: string): Promise<void> {
149
+ if (!this.token) { log.warn('[telegram] Cannot send image — no token'); return; }
150
+ this.stopTyping(to);
151
+ try {
152
+ const form = new FormData();
153
+ form.append('chat_id', to);
154
+ if (caption) form.append('caption', caption.slice(0, 1024));
155
+ const ext = (mimetype?.split('/')[1] || 'png').replace('jpeg', 'jpg');
156
+ form.append('photo', new Blob([new Uint8Array(image)], { type: mimetype || 'image/png' }), `image.${ext}`);
157
+ const r = await fetch(this.api('sendPhoto'), { method: 'POST', body: form });
158
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
159
+ log.info(`[telegram] Sent image to ${to}`);
160
+ } catch (err: any) {
161
+ log.warn(`[telegram] sendImage to ${to} failed: ${err.message}`);
162
+ }
163
+ }
164
+
165
+ /** Show "typing…" — refreshes every 5s since Telegram's indicator expires. */
166
+ startTyping(to: string): void {
167
+ if (!this.token || !this.connected) return;
168
+ this.stopTyping(to);
169
+ let ticks = 0;
170
+ const send = () => {
171
+ // Cap the refresh (~2 min) so a turn that ends without a reply can't leave a stuck indicator.
172
+ if (++ticks > 24) { this.stopTyping(to); return; }
173
+ this.call('sendChatAction', { chat_id: to, action: 'typing' }).catch(() => {});
174
+ };
175
+ send();
176
+ this.typingIntervals.set(to, setInterval(send, TYPING_REFRESH_MS));
177
+ }
178
+
179
+ stopTyping(to: string): void {
180
+ const interval = this.typingIntervals.get(to);
181
+ if (interval) { clearInterval(interval); this.typingIntervals.delete(to); }
182
+ }
183
+
184
+ // ── Inbound poll loop ─────────────────────────────────────────────────────
185
+
186
+ private async pollLoop(): Promise<void> {
187
+ while (!this.intentionalDisconnect) {
188
+ this.pollAbort = new AbortController();
189
+ try {
190
+ const updates = await this.call('getUpdates', {
191
+ offset: this.offset,
192
+ timeout: POLL_TIMEOUT_S,
193
+ allowed_updates: ['message'],
194
+ }, this.pollAbort.signal, (POLL_TIMEOUT_S + 10) * 1000);
195
+
196
+ if (Array.isArray(updates)) {
197
+ for (const update of updates) {
198
+ this.offset = Math.max(this.offset, (update.update_id || 0) + 1);
199
+ try { await this.handleUpdate(update); }
200
+ catch (err: any) { log.warn(`[telegram] handleUpdate error: ${err.message}`); }
201
+ }
202
+ }
203
+ } catch (err: any) {
204
+ if (this.intentionalDisconnect) break;
205
+ // Watchdog timeout (the long-poll exceeded its deadline without a response —
206
+ // e.g. a silently dropped connection). A disconnect-driven abort is caught by
207
+ // the intentionalDisconnect check above, so any AbortError here is the timeout:
208
+ // re-open a fresh long-poll immediately rather than treating it as fatal.
209
+ if (err?.name === 'AbortError') continue;
210
+ // 409 = another getUpdates/webhook is consuming this bot — do not hot-loop.
211
+ if (String(err.message).includes('409')) {
212
+ log.warn('[telegram] getUpdates conflict (409) — another consumer holds this bot. Stopping poll.');
213
+ this.connected = false;
214
+ this.emitStatus();
215
+ return;
216
+ }
217
+ log.warn(`[telegram] getUpdates error: ${err.message} — retrying in 5s`);
218
+ await sleep(5000);
219
+ }
220
+ }
221
+ }
222
+
223
+ private async handleUpdate(update: any): Promise<void> {
224
+ const message = update.message;
225
+ if (!message) return;
226
+
227
+ const from = message.from || {};
228
+ if (from.is_bot) return; // ignore other bots (our own sends never come back here anyway)
229
+
230
+ const chat = message.chat || {};
231
+ const chatId = String(chat.id);
232
+ const fromUserId = String(from.id ?? '');
233
+ const isGroup = chat.type === 'group' || chat.type === 'supergroup';
234
+ const senderName = from.first_name
235
+ ? (from.last_name ? `${from.first_name} ${from.last_name}` : from.first_name)
236
+ : (from.username || undefined);
237
+
238
+ let rawText: string = message.text || message.caption || '';
239
+ const images: TelegramImageAttachment[] = [];
240
+
241
+ // Photo: download the largest available size.
242
+ if (Array.isArray(message.photo) && message.photo.length > 0) {
243
+ const largest = message.photo[message.photo.length - 1];
244
+ const img = await this.downloadFile(largest.file_id).catch(() => null);
245
+ if (img) images.push({ mediaType: 'image/jpeg', data: img.toString('base64') });
246
+ }
247
+
248
+ // Voice note / audio: download + transcribe.
249
+ const voice = message.voice || message.audio;
250
+ if (!rawText && voice?.file_id) {
251
+ if (!this.transcribe) {
252
+ await this.sendMessage(chatId, 'Voice transcription is off — add an OpenAI API key in your Bloby chat settings (the three-dots menu) to enable it.');
253
+ return;
254
+ }
255
+ const buf = await this.downloadFile(voice.file_id).catch(() => null);
256
+ if (buf) {
257
+ const transcript = await this.transcribe(buf.toString('base64')).catch(() => null);
258
+ if (transcript) {
259
+ rawText = transcript;
260
+ log.info(`[telegram] Transcribed voice: "${rawText.slice(0, 80)}"`);
261
+ } else {
262
+ await this.sendMessage(chatId, "I couldn't transcribe that voice message — if this keeps happening, add an OpenAI API key in your Bloby chat settings (the three-dots menu) to enable voice transcription.");
263
+ return;
264
+ }
265
+ }
266
+ }
267
+
268
+ if (!rawText && images.length === 0) return;
269
+ if (!rawText && images.length > 0) rawText = '(image)';
270
+
271
+ const text = escapeMessageText(rawText);
272
+
273
+ log.info(`[telegram] Message from ${fromUserId} (chat=${chatId}, group=${isGroup}, images=${images.length}): ${text.slice(0, 80)}`);
274
+
275
+ this.onMessage({
276
+ chatId,
277
+ fromUserId,
278
+ senderName,
279
+ text,
280
+ isGroup,
281
+ messageId: message.message_id,
282
+ images: images.length > 0 ? images : undefined,
283
+ });
284
+ }
285
+
286
+ /** Resolve a Telegram file_id to its bytes (getFile → download from the file CDN). */
287
+ private async downloadFile(fileId: string): Promise<Buffer | null> {
288
+ const file = await this.call('getFile', { file_id: fileId });
289
+ const filePath = file?.file_path;
290
+ if (!filePath) return null;
291
+ const r = await fetch(`${TG_API}/file/bot${this.token}/${filePath}`);
292
+ if (!r.ok) throw new Error(`file download HTTP ${r.status}`);
293
+ const buf = Buffer.from(await r.arrayBuffer());
294
+ log.info(`[telegram] Downloaded file (${Math.round(buf.length / 1024)}KB)`);
295
+ return buf;
296
+ }
297
+
298
+ /** Call a Bot API method, returning `result` or throwing on `ok:false`. */
299
+ private async call(method: string, params: Record<string, any>, signal?: AbortSignal, timeoutMs = 30_000): Promise<any> {
300
+ // Always apply the watchdog timeout, AND honor the caller's abort signal (disconnect)
301
+ // when present — abort whichever fires first. The previous version dropped the timeout
302
+ // whenever a signal was passed, leaving the long-poll with no deadline.
303
+ const ctrl = new AbortController();
304
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
305
+ const onExternalAbort = () => ctrl.abort();
306
+ if (signal) {
307
+ if (signal.aborted) ctrl.abort();
308
+ else signal.addEventListener('abort', onExternalAbort, { once: true });
309
+ }
310
+ try {
311
+ const r = await fetch(this.api(method), {
312
+ method: 'POST',
313
+ headers: { 'Content-Type': 'application/json' },
314
+ body: JSON.stringify(params),
315
+ signal: ctrl.signal,
316
+ });
317
+ const data = await r.json().catch(() => ({}));
318
+ if (!r.ok || !data.ok) {
319
+ throw new Error(`${method} → ${r.status} ${data.description || ''}`.trim());
320
+ }
321
+ return data.result;
322
+ } finally {
323
+ clearTimeout(timer);
324
+ if (signal) signal.removeEventListener('abort', onExternalAbort);
325
+ }
326
+ }
327
+
328
+ private emitStatus() {
329
+ this.onStatusChange(this.getStatus());
330
+ }
331
+ }
332
+
333
+ // ── helpers ──────────────────────────────────────────────────────────────────
334
+
335
+ function sleep(ms: number): Promise<void> {
336
+ return new Promise((resolve) => setTimeout(resolve, ms));
337
+ }
338
+
339
+ /** Split a long message into <=limit-char chunks, preferring newline boundaries. */
340
+ function splitMessage(text: string, limit: number): string[] {
341
+ if (text.length <= limit) return [text];
342
+ const chunks: string[] = [];
343
+ let rest = text;
344
+ while (rest.length > limit) {
345
+ let cut = rest.lastIndexOf('\n', limit);
346
+ if (cut < limit * 0.5) cut = limit; // no good newline — hard cut
347
+ chunks.push(rest.slice(0, cut));
348
+ rest = rest.slice(cut).replace(/^\n/, '');
349
+ }
350
+ if (rest) chunks.push(rest);
351
+ return chunks;
352
+ }
353
+
354
+ /** Mirror WhatsApp's anti-injection escaping so message content can't fake channel/role tags. */
355
+ function escapeMessageText(text: string): string {
356
+ return text
357
+ .replace(/\[Telegram\s*\|/gi, '(Telegram|')
358
+ .replace(/\[WhatsApp\s*\|/gi, '(WhatsApp|')
359
+ .replace(/\[\s*(admin|customer)\s*\]/gi, '($1)');
360
+ }
@@ -78,13 +78,17 @@ export interface RoutingTarget {
78
78
  /** Which surface triggered this turn. Drives whether the WA reply carries a "🤖 Bot:" prefix.
79
79
  * 'workspace' is a dashboard surface like 'chat' (broadcast-driven, optional WA self-chat mirror)
80
80
  * but isolated for telemetry / future per-surface routing. */
81
- surface: 'chat' | 'whatsapp' | 'alexa' | 'workspace';
81
+ surface: 'chat' | 'whatsapp' | 'alexa' | 'telegram' | 'workspace';
82
82
  /** WhatsApp JID to deliver the reply to.
83
83
  * - 'whatsapp' surface → the originating chat JID (group or peer).
84
84
  * - 'chat' surface → optionally the user's own number (self-chat mirror), or undefined.
85
85
  * When undefined, no WhatsApp send happens — the reply only reaches the dashboard via broadcast.
86
86
  */
87
87
  waSendTo?: string;
88
+ /** Telegram chat id to deliver the reply to ('telegram' surface). */
89
+ telegramChatId?: string;
90
+ /** Telegram message id of the inbound that triggered this turn (for reply-to / reactions). */
91
+ telegramMessageId?: number;
88
92
  isGroup?: boolean;
89
93
  isSelfChat?: boolean;
90
94
  /** When set, the assistant's reply is appended to this customer buffer (assistant-mode context). */
@@ -518,11 +518,10 @@ export class WhatsAppChannel implements ChannelProvider {
518
518
  }
519
519
  }
520
520
 
521
- // Skip if no text AND no images
522
- if (!rawText && images.length === 0) continue;
523
-
524
- // Use a default text for image-only messages
525
- if (!rawText && images.length > 0) {
521
+ // Skip if no text AND no images; otherwise default text for image-only
522
+ // messages. Collapsing both branches also narrows `rawText` to `string`.
523
+ if (!rawText) {
524
+ if (images.length === 0) continue;
526
525
  rawText = '(image)';
527
526
  }
528
527
 
@@ -60,6 +60,8 @@ const PROVIDERS = [
60
60
 
61
61
  const MODELS: Record<string, { id: string; label: string }[]> = {
62
62
  anthropic: [
63
+ { id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M context)' },
64
+ { id: 'claude-opus-4-8', label: 'Opus 4.8' },
63
65
  { id: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M context)' },
64
66
  { id: 'claude-opus-4-7', label: 'Opus 4.7' },
65
67
  { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6 (1M context)' },
@@ -3513,6 +3515,21 @@ export default function OnboardWizard({ onComplete, isInitialSetup = false, onSa
3513
3515
  {/* ── Auth flow: Anthropic ── */}
3514
3516
  {provider === 'anthropic' && (
3515
3517
  <div className="space-y-2.5">
3518
+ {/* Anthropic third-party usage policy notice — shown to anyone considering Claude. */}
3519
+ <div className="rounded-xl border border-amber-500/20 bg-amber-500/[0.06] px-4 py-3.5">
3520
+ <div className="flex items-center gap-2 mb-2">
3521
+ <TriangleAlert className="h-4 w-4 text-amber-400 shrink-0" />
3522
+ <h3 className="text-[12.5px] font-semibold text-amber-200/90">Anthropic Third-Party App Policy Update</h3>
3523
+ </div>
3524
+ <div className="space-y-2 text-amber-100/70 text-[12px] leading-relaxed">
3525
+ <p>Starting June 15, 2026, Anthropic will provide a separate Third-Party App credit equal to the amount you pay for your subscription.</p>
3526
+ <p>For example, if you have the Max 5x plan at $100/month, you will receive $100 in credits to use with third-party tools like Bloby.</p>
3527
+ <p>Unfortunately, this is only a fraction of the usage Bloby users had before. We don&apos;t control Anthropic&apos;s rules, but we do need to follow them.</p>
3528
+ <p>The best alternative right now is a <span className="font-medium text-amber-100/90">ChatGPT subscription</span>, which also offers $100 and $200 plans with much higher usage limits for Bloby.</p>
3529
+ <p>In the short term, Bloby will be optimized for ChatGPT. In the long term, we are building our own model harness so Bloby has more control, more flexibility, and does not depend too heavily on providers that can change their rules at any moment.</p>
3530
+ </div>
3531
+ </div>
3532
+
3516
3533
  {isConnected && (
3517
3534
  <div className="space-y-2.5">
3518
3535
  <div className="bg-emerald-500/8 border border-emerald-500/15 rounded-lg px-3.5 py-2.5">
@@ -88,6 +88,14 @@ function preprocessContent(text: string): string {
88
88
  );
89
89
  }
90
90
 
91
+ const telegramLink = '[connect-telegram](/api/channels/telegram/pair-page)';
92
+ if (!out.includes(telegramLink)) {
93
+ out = out.replace(
94
+ /`?(?:http:\/\/localhost:\d+)?\/api\/channels\/telegram\/pair-page`?/g,
95
+ telegramLink,
96
+ );
97
+ }
98
+
91
99
  return out;
92
100
  }
93
101
 
@@ -381,6 +389,27 @@ export default function MessageBubble({ role, content, timestamp, hasAttachments
381
389
  </a>
382
390
  );
383
391
  }
392
+ if (href?.includes('/api/channels/telegram/pair-page')) {
393
+ return (
394
+ <a
395
+ href={href}
396
+ target="_blank"
397
+ rel="noopener noreferrer"
398
+ className="flex items-center gap-3 my-2 px-3.5 py-2.5 rounded-xl bg-[#229ED9]/10 border border-[#229ED9]/20 hover:bg-[#229ED9]/15 transition-colors no-underline"
399
+ >
400
+ <div className="flex items-center justify-center w-8 h-8 rounded-lg bg-[#229ED9] shrink-0">
401
+ <svg viewBox="0 0 24 24" className="w-4 h-4 fill-white">
402
+ <path d="M9.78 18.65l.28-4.23 7.68-6.92c.34-.31-.07-.46-.52-.19L7.74 13.3 3.64 12c-.88-.25-.89-.86.2-1.3l15.97-6.16c.73-.33 1.43.18 1.15 1.3l-2.72 12.81c-.19.91-.74 1.13-1.5.71l-4.13-3.05-1.99 1.93c-.23.23-.42.42-.83.42z"/>
403
+ </svg>
404
+ </div>
405
+ <div className="flex flex-col min-w-0">
406
+ <span className="text-[#229ED9] font-medium text-sm">Click here to connect Telegram</span>
407
+ <span className="text-muted-foreground text-xs">Opens the Telegram pairing page</span>
408
+ </div>
409
+ <ExternalLink className="w-3.5 h-3.5 text-muted-foreground/50 ml-auto shrink-0" />
410
+ </a>
411
+ );
412
+ }
384
413
  return (
385
414
  <a
386
415
  href={href}
@@ -213,6 +213,11 @@ async function buildConversationOptions(
213
213
 
214
214
  return {
215
215
  model,
216
+ // Reasoning effort. 'high' = deep reasoning while staying more token-efficient
217
+ // than the CLI's xhigh default on Opus 4.7/4.8 — meaningful given Anthropic's
218
+ // tighter third-party usage limits. Supported on Opus 4.6+/Sonnet 4.6; silently
219
+ // ignored by models without effort support.
220
+ effort: 'high',
216
221
  cwd: WORKSPACE_DIR,
217
222
  permissionMode: 'bypassPermissions',
218
223
  allowDangerouslySkipPermissions: true,
@@ -648,6 +653,7 @@ export async function startBlobyAgentQuery(
648
653
  prompt: sdkPrompt,
649
654
  options: {
650
655
  model,
656
+ effort: 'high', // see buildConversationOptions — token-efficient deep reasoning
651
657
  cwd: WORKSPACE_DIR,
652
658
  permissionMode: 'bypassPermissions',
653
659
  allowDangerouslySkipPermissions: true,
@@ -762,6 +768,7 @@ export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryR
762
768
  prompt: req.message,
763
769
  options: {
764
770
  cwd: WORKSPACE_DIR,
771
+ effort: 'high', // see buildConversationOptions — token-efficient deep reasoning
765
772
  permissionMode: 'bypassPermissions',
766
773
  allowDangerouslySkipPermissions: true,
767
774
  maxTurns,
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { log } from '../../../shared/logger.js';
14
14
  import { WORKSPACE_DIR } from '../../../shared/paths.js';
15
- import type { SavedFile } from '../file-saver.js';
15
+ import type { SavedFile } from '../../file-saver.js';
16
16
  import { assembleSystemPrompt } from '../../../worker/prompts/prompt-assembler.js';
17
17
  import fs from 'fs';
18
18
  import path from 'path';
@@ -12,7 +12,14 @@ export function safeResolve(cwd: string, requested: string): string {
12
12
  if (!requested || typeof requested !== 'string') {
13
13
  throw new Error('Missing file path');
14
14
  }
15
- const root = fs.realpathSync.native ? fs.realpathSync(cwd) : path.resolve(cwd);
15
+ // Canonicalize cwd (resolves symlinks) so the traversal check below compares real
16
+ // paths. Falls back to a plain resolve when cwd doesn't exist yet (realpath throws).
17
+ let root: string;
18
+ try {
19
+ root = fs.realpathSync.native(cwd);
20
+ } catch {
21
+ root = path.resolve(cwd);
22
+ }
16
23
  const abs = path.isAbsolute(requested)
17
24
  ? path.normalize(requested)
18
25
  : path.normalize(path.join(root, requested));