@0xmaxma/claude-gateway 1.3.21 → 1.3.22
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/agent/runner.d.ts.map +1 -1
- package/dist/agent/runner.js +22 -4
- package/dist/agent/runner.js.map +1 -1
- package/dist/shell/claude-pty-shell.js +25 -1
- package/dist/shell/claude-pty-shell.js.map +1 -1
- package/dist/shell/orphan-wake.d.ts +37 -0
- package/dist/shell/orphan-wake.d.ts.map +1 -0
- package/dist/shell/orphan-wake.js +29 -0
- package/dist/shell/orphan-wake.js.map +1 -0
- package/dist/shell/screen.d.ts +4 -2
- package/dist/shell/screen.d.ts.map +1 -1
- package/dist/shell/screen.js +55 -6
- package/dist/shell/screen.js.map +1 -1
- package/mcp/tools/telegram/receiver-server.ts +16 -1
- package/mcp/tools/telegram/typing.ts +193 -29
- package/package.json +1 -1
|
@@ -15,28 +15,113 @@
|
|
|
15
15
|
|
|
16
16
|
export const TELEGRAM_MAX_CHARS = 4096
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Telegram rejects a message whose HTML entities are unbalanced, so a chunk cut
|
|
20
|
+
* that falls inside a <pre><code>…</code></pre> block must close the open tags
|
|
21
|
+
* at the end of the chunk and reopen them at the start of the next one.
|
|
22
|
+
* Reserve room for that worst-case suffix (</a></code></pre></b></i>) plus the
|
|
23
|
+
* mirrored reopening prefix so balancing never pushes a chunk past the limit.
|
|
24
|
+
*/
|
|
25
|
+
const HTML_BALANCE_HEADROOM = 64
|
|
26
|
+
|
|
27
|
+
/** Tags toTelegramHtml() emits — the only ones balancing needs to understand. */
|
|
28
|
+
const BALANCED_TAGS = ['b', 'i', 'code', 'pre', 'a'] as const
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Scan an HTML fragment (as produced by toTelegramHtml — no attributes except
|
|
32
|
+
* <a href>, no self-closing forms) and return the stack of tags still open at
|
|
33
|
+
* the end, as full opening-tag strings in opening order.
|
|
34
|
+
*/
|
|
35
|
+
export function openTagStack(html: string): string[] {
|
|
36
|
+
const stack: string[] = []
|
|
37
|
+
// Attribute part tolerates '>' inside quoted values (<a href="a>b">).
|
|
38
|
+
const re = /<(\/?)([a-z]+)((?:\s(?:"[^"]*"|[^>])*)?)>/g
|
|
39
|
+
let m: RegExpExecArray | null
|
|
40
|
+
while ((m = re.exec(html)) !== null) {
|
|
41
|
+
const closing = m[1] === '/'
|
|
42
|
+
const name = m[2]
|
|
43
|
+
if (!(BALANCED_TAGS as readonly string[]).includes(name)) continue
|
|
44
|
+
if (closing) {
|
|
45
|
+
// toTelegramHtml emits well-nested pairs, so the match is always the top.
|
|
46
|
+
const top = stack.length - 1
|
|
47
|
+
if (top >= 0 && /^<([a-z]+)/.exec(stack[top])?.[1] === name) stack.pop()
|
|
48
|
+
} else {
|
|
49
|
+
stack.push(`<${name}${m[3] ?? ''}>`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return stack
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Strip Telegram-HTML tags and unescape entities → plain-text equivalent. */
|
|
56
|
+
export function htmlToPlain(html: string): string {
|
|
57
|
+
return html
|
|
58
|
+
.replace(/<[^>]+>/g, '')
|
|
59
|
+
.replace(/</g, '<')
|
|
60
|
+
.replace(/>/g, '>')
|
|
61
|
+
.replace(/"/g, '"')
|
|
62
|
+
.replace(/&/g, '&')
|
|
63
|
+
}
|
|
64
|
+
|
|
18
65
|
/**
|
|
19
66
|
* Split text into chunks that fit within Telegram's message size limit.
|
|
20
67
|
* Prefers paragraph → line → space boundaries over hard cuts.
|
|
21
|
-
* When htmlSafe=true, avoids cutting inside an HTML tag (e.g. <code>, <b>)
|
|
68
|
+
* When htmlSafe=true, avoids cutting inside an HTML tag (e.g. <code>, <b>) AND
|
|
69
|
+
* keeps every chunk entity-balanced: tags left open at a cut are closed at the
|
|
70
|
+
* chunk's end and reopened at the next chunk's start, so no chunk is ever
|
|
71
|
+
* rejected by Telegram's HTML parser for an unclosed <pre>/<code>/<b>.
|
|
22
72
|
*/
|
|
23
73
|
export function chunkText(text: string, limit = TELEGRAM_MAX_CHARS, htmlSafe = false): string[] {
|
|
24
74
|
if (text.length <= limit) return [text]
|
|
25
75
|
const out: string[] = []
|
|
26
76
|
let rest = text
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
77
|
+
const effLimit = htmlSafe ? limit - HTML_BALANCE_HEADROOM : limit
|
|
78
|
+
// If cut lands inside an open tag (<...>), move cut to before the '<'
|
|
79
|
+
const avoidMidTag = (cut: number): number => {
|
|
80
|
+
const tagStart = rest.lastIndexOf('<', cut)
|
|
81
|
+
const tagEnd = rest.lastIndexOf('>', cut)
|
|
82
|
+
return tagStart > tagEnd ? tagStart : cut
|
|
83
|
+
}
|
|
84
|
+
const closersFor = (open: string[]): string =>
|
|
85
|
+
open.map((t) => `</${/^<([a-z]+)/.exec(t)![1]}>`).reverse().join('')
|
|
86
|
+
// Cut at `cut`, balancing tags across the boundary when htmlSafe.
|
|
87
|
+
const splitAt = (cut: number): { head: string; tail: string } => {
|
|
88
|
+
let head = rest.slice(0, cut)
|
|
89
|
+
let tail = rest.slice(cut).replace(/^\n+/, '')
|
|
32
90
|
if (htmlSafe) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
91
|
+
const open = openTagStack(head)
|
|
92
|
+
if (open.length) {
|
|
93
|
+
head += closersFor(open)
|
|
94
|
+
tail = open.join('') + tail
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { head, tail }
|
|
98
|
+
}
|
|
99
|
+
while (rest.length > effLimit) {
|
|
100
|
+
const para = rest.lastIndexOf('\n\n', effLimit)
|
|
101
|
+
const line = rest.lastIndexOf('\n', effLimit)
|
|
102
|
+
const space = rest.lastIndexOf(' ', effLimit)
|
|
103
|
+
let cut = para > effLimit / 2 ? para : line > effLimit / 2 ? line : space > 0 ? space : effLimit
|
|
104
|
+
if (htmlSafe) cut = avoidMidTag(cut)
|
|
105
|
+
let { head, tail } = splitAt(cut)
|
|
106
|
+
// Forward-progress guard: when the chosen boundary sits right after an
|
|
107
|
+
// opening tag (e.g. "<b> " + one unbroken >limit token), the reopened tag
|
|
108
|
+
// prefix can re-add as much as the cut removed and `rest` never shrinks —
|
|
109
|
+
// an infinite loop that would hang the whole receiver. Retry with a hard
|
|
110
|
+
// cut at effLimit; if even that cannot shrink (degenerate tag-heavy input,
|
|
111
|
+
// e.g. a single huge <a href>), emit the remainder as one oversized chunk
|
|
112
|
+
// and stop — Telegram rejects it and the plain-text retry rescues the
|
|
113
|
+
// content, which beats hanging the process.
|
|
114
|
+
if (tail.length >= rest.length) {
|
|
115
|
+
cut = htmlSafe ? avoidMidTag(effLimit) : effLimit
|
|
116
|
+
;({ head, tail } = splitAt(cut))
|
|
117
|
+
if (tail.length >= rest.length) {
|
|
118
|
+
out.push(rest)
|
|
119
|
+
rest = ''
|
|
120
|
+
break
|
|
121
|
+
}
|
|
37
122
|
}
|
|
38
|
-
out.push(
|
|
39
|
-
rest =
|
|
123
|
+
out.push(head)
|
|
124
|
+
rest = tail
|
|
40
125
|
}
|
|
41
126
|
if (rest) out.push(rest)
|
|
42
127
|
return out
|
|
@@ -111,6 +196,101 @@ export interface FsApi {
|
|
|
111
196
|
statSync(path: string): { mtimeMs: number }
|
|
112
197
|
}
|
|
113
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Deliver auto-forwarded turn text to a chat, never silently dropping content.
|
|
201
|
+
* Each chunk that fails as HTML (e.g. Telegram rejects an entity the balancer
|
|
202
|
+
* didn't anticipate) is retried as plain text — the user always gets the words,
|
|
203
|
+
* worst case without formatting. The generic "could not be delivered" notice is
|
|
204
|
+
* a last resort reserved for chunks that fail even as plain text (network/API
|
|
205
|
+
* outage), and is sent at most once.
|
|
206
|
+
*/
|
|
207
|
+
export async function deliverForwardText(
|
|
208
|
+
botApi: Pick<BotApi, 'sendMessage'>,
|
|
209
|
+
chatId: string,
|
|
210
|
+
forwardText: string,
|
|
211
|
+
parseMode: 'HTML' | undefined,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
const msgOpts = parseMode ? { parse_mode: parseMode } : {}
|
|
214
|
+
const chunks = chunkText(forwardText, TELEGRAM_MAX_CHARS, parseMode === 'HTML')
|
|
215
|
+
let deliveryFailed = false
|
|
216
|
+
for (const part of chunks) {
|
|
217
|
+
try {
|
|
218
|
+
await botApi.sendMessage(chatId, part, msgOpts)
|
|
219
|
+
} catch {
|
|
220
|
+
const plain = parseMode === 'HTML' ? htmlToPlain(part) : part
|
|
221
|
+
try {
|
|
222
|
+
await botApi.sendMessage(chatId, plain)
|
|
223
|
+
} catch {
|
|
224
|
+
deliveryFailed = true
|
|
225
|
+
break
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (deliveryFailed) {
|
|
230
|
+
await botApi.sendMessage(
|
|
231
|
+
chatId,
|
|
232
|
+
'⚠️ Claude responded but the message could not be delivered. Please try asking again.',
|
|
233
|
+
).catch(() => {})
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Orphan auto-forward delivery (one poll pass). The typing-loop teardown
|
|
239
|
+
* (stop()) normally drains `<chatId>.forward`, but a forward can be written
|
|
240
|
+
* with NO typing loop running at all: an autonomous wake (a background Task
|
|
241
|
+
* finished and Claude continued on its own — e.g. writing up a plan) has no
|
|
242
|
+
* inbound message, so nothing ever started typing and stop() never runs.
|
|
243
|
+
* Without this drain that text sits on disk forever and the user sees a
|
|
244
|
+
* silent chat. Chats with a live typing state are skipped — stop() owns
|
|
245
|
+
* their delivery (including the .replied dedup) and draining them here
|
|
246
|
+
* would double-send.
|
|
247
|
+
*/
|
|
248
|
+
export function drainOrphanForwards(
|
|
249
|
+
typingDir: string,
|
|
250
|
+
activeChatIds: { has(chatId: string): boolean },
|
|
251
|
+
botApi: Pick<BotApi, 'sendMessage'>,
|
|
252
|
+
fsApi: Pick<FsApi, 'existsSync' | 'rmSync' | 'readFileSync'> & { readdirSync(path: string): string[] },
|
|
253
|
+
): void {
|
|
254
|
+
let files: string[]
|
|
255
|
+
try {
|
|
256
|
+
files = fsApi.readdirSync(typingDir)
|
|
257
|
+
} catch {
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
for (const name of files) {
|
|
261
|
+
if (!name.endsWith('.forward')) continue
|
|
262
|
+
const chatId = name.slice(0, -'.forward'.length)
|
|
263
|
+
if (activeChatIds.has(chatId)) continue
|
|
264
|
+
const forwardPath = `${typingDir}/${name}`
|
|
265
|
+
let raw: string
|
|
266
|
+
try {
|
|
267
|
+
raw = fsApi.readFileSync(forwardPath, 'utf8').trim()
|
|
268
|
+
} catch {
|
|
269
|
+
fsApi.rmSync(forwardPath, { force: true })
|
|
270
|
+
continue
|
|
271
|
+
}
|
|
272
|
+
// Remove BEFORE sending so a slow/failing send can't double-deliver.
|
|
273
|
+
fsApi.rmSync(forwardPath, { force: true })
|
|
274
|
+
// Mirror stop()'s dedup: a lingering .replied with no typing state means
|
|
275
|
+
// the agent already sent this turn's text via the reply tool.
|
|
276
|
+
const repliedPath = `${typingDir}/${chatId}.replied`
|
|
277
|
+
if (fsApi.existsSync(repliedPath)) {
|
|
278
|
+
fsApi.rmSync(repliedPath, { force: true })
|
|
279
|
+
continue
|
|
280
|
+
}
|
|
281
|
+
let text = raw
|
|
282
|
+
let parseMode: 'HTML' | undefined
|
|
283
|
+
try {
|
|
284
|
+
const parsed = JSON.parse(raw) as { text: string; format: string }
|
|
285
|
+
text = parsed.text
|
|
286
|
+
parseMode = parsed.format === 'html' ? 'HTML' : undefined
|
|
287
|
+
} catch {
|
|
288
|
+
// Old format: plain text
|
|
289
|
+
}
|
|
290
|
+
if (text) void deliverForwardText(botApi, chatId, text, parseMode)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
114
294
|
export function createWorkingStateManager(
|
|
115
295
|
typingDir: string,
|
|
116
296
|
botApi: BotApi,
|
|
@@ -191,23 +371,7 @@ export function createWorkingStateManager(
|
|
|
191
371
|
// Skip if the reply tool already sent a message (agent already replied)
|
|
192
372
|
const alreadyReplied = fsApi.existsSync(repliedPath)
|
|
193
373
|
if (!alreadyReplied && forwardText) {
|
|
194
|
-
|
|
195
|
-
const chunks = chunkText(forwardText, TELEGRAM_MAX_CHARS, parseMode === 'HTML')
|
|
196
|
-
let deliveryFailed = false
|
|
197
|
-
for (const part of chunks) {
|
|
198
|
-
try {
|
|
199
|
-
await botApi.sendMessage(chatId, part, msgOpts)
|
|
200
|
-
} catch {
|
|
201
|
-
deliveryFailed = true
|
|
202
|
-
break
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
if (deliveryFailed) {
|
|
206
|
-
await botApi.sendMessage(
|
|
207
|
-
chatId,
|
|
208
|
-
'⚠️ Claude responded but the message could not be delivered. Please try asking again.',
|
|
209
|
-
).catch(() => {})
|
|
210
|
-
}
|
|
374
|
+
await deliverForwardText(botApi, chatId, forwardText, parseMode)
|
|
211
375
|
}
|
|
212
376
|
} catch {}
|
|
213
377
|
fsApi.rmSync(forwardPath, { force: true })
|