@0xmaxma/claude-gateway 1.7.8 → 1.7.10

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.
Files changed (125) hide show
  1. package/README.md +11 -3
  2. package/dist/agent/dreaming/accept.d.ts.map +1 -1
  3. package/dist/agent/dreaming/accept.js +4 -1
  4. package/dist/agent/dreaming/accept.js.map +1 -1
  5. package/dist/agent/dreaming/config.d.ts.map +1 -1
  6. package/dist/agent/dreaming/config.js +2 -0
  7. package/dist/agent/dreaming/config.js.map +1 -1
  8. package/dist/agent/dreaming/reviewer.d.ts.map +1 -1
  9. package/dist/agent/dreaming/reviewer.js +9 -2
  10. package/dist/agent/dreaming/reviewer.js.map +1 -1
  11. package/dist/agent/dreaming/staleness.d.ts +7 -1
  12. package/dist/agent/dreaming/staleness.d.ts.map +1 -1
  13. package/dist/agent/dreaming/staleness.js +17 -2
  14. package/dist/agent/dreaming/staleness.js.map +1 -1
  15. package/dist/agent/dreaming/types.d.ts +17 -1
  16. package/dist/agent/dreaming/types.d.ts.map +1 -1
  17. package/dist/agent/knowledge/archive-db.d.ts +43 -0
  18. package/dist/agent/knowledge/archive-db.d.ts.map +1 -1
  19. package/dist/agent/knowledge/archive-db.js +130 -0
  20. package/dist/agent/knowledge/archive-db.js.map +1 -1
  21. package/dist/agent/knowledge/config.d.ts +3 -1
  22. package/dist/agent/knowledge/config.d.ts.map +1 -1
  23. package/dist/agent/knowledge/config.js +30 -1
  24. package/dist/agent/knowledge/config.js.map +1 -1
  25. package/dist/agent/knowledge/index.d.ts +11 -4
  26. package/dist/agent/knowledge/index.d.ts.map +1 -1
  27. package/dist/agent/knowledge/index.js +22 -1
  28. package/dist/agent/knowledge/index.js.map +1 -1
  29. package/dist/agent/knowledge/indexer.d.ts +0 -7
  30. package/dist/agent/knowledge/indexer.d.ts.map +1 -1
  31. package/dist/agent/knowledge/indexer.js +34 -7
  32. package/dist/agent/knowledge/indexer.js.map +1 -1
  33. package/dist/agent/knowledge/lifecycle.d.ts +20 -4
  34. package/dist/agent/knowledge/lifecycle.d.ts.map +1 -1
  35. package/dist/agent/knowledge/lifecycle.js +33 -4
  36. package/dist/agent/knowledge/lifecycle.js.map +1 -1
  37. package/dist/agent/knowledge/reflection.d.ts +114 -0
  38. package/dist/agent/knowledge/reflection.d.ts.map +1 -0
  39. package/dist/agent/knowledge/reflection.js +447 -0
  40. package/dist/agent/knowledge/reflection.js.map +1 -0
  41. package/dist/agent/knowledge/shared-dedup.d.ts +85 -0
  42. package/dist/agent/knowledge/shared-dedup.d.ts.map +1 -0
  43. package/dist/agent/knowledge/shared-dedup.js +187 -0
  44. package/dist/agent/knowledge/shared-dedup.js.map +1 -0
  45. package/dist/agent/knowledge/shared-promote.d.ts +82 -7
  46. package/dist/agent/knowledge/shared-promote.d.ts.map +1 -1
  47. package/dist/agent/knowledge/shared-promote.js +244 -11
  48. package/dist/agent/knowledge/shared-promote.js.map +1 -1
  49. package/dist/agent/knowledge/shared-staleness.d.ts +51 -0
  50. package/dist/agent/knowledge/shared-staleness.d.ts.map +1 -0
  51. package/dist/agent/knowledge/shared-staleness.js +149 -0
  52. package/dist/agent/knowledge/shared-staleness.js.map +1 -0
  53. package/dist/agent/knowledge/shared-writer.d.ts +19 -0
  54. package/dist/agent/knowledge/shared-writer.d.ts.map +1 -1
  55. package/dist/agent/knowledge/shared-writer.js +42 -0
  56. package/dist/agent/knowledge/shared-writer.js.map +1 -1
  57. package/dist/agent/knowledge/types.d.ts +24 -0
  58. package/dist/agent/knowledge/types.d.ts.map +1 -1
  59. package/dist/agent/runner.d.ts +7 -0
  60. package/dist/agent/runner.d.ts.map +1 -1
  61. package/dist/agent/runner.js +106 -16
  62. package/dist/agent/runner.js.map +1 -1
  63. package/dist/agent/workspace-loader.d.ts +17 -2
  64. package/dist/agent/workspace-loader.d.ts.map +1 -1
  65. package/dist/agent/workspace-loader.js +17 -1
  66. package/dist/agent/workspace-loader.js.map +1 -1
  67. package/dist/api/gateway-router.d.ts.map +1 -1
  68. package/dist/api/gateway-router.js +2 -1
  69. package/dist/api/gateway-router.js.map +1 -1
  70. package/dist/api/line-webhook-router.d.ts.map +1 -1
  71. package/dist/api/line-webhook-router.js +7 -15
  72. package/dist/api/line-webhook-router.js.map +1 -1
  73. package/dist/api/slack-client.d.ts +25 -2
  74. package/dist/api/slack-client.d.ts.map +1 -1
  75. package/dist/api/slack-client.js +102 -0
  76. package/dist/api/slack-client.js.map +1 -1
  77. package/dist/api/slack-webhook-router.d.ts +15 -1
  78. package/dist/api/slack-webhook-router.d.ts.map +1 -1
  79. package/dist/api/slack-webhook-router.js +128 -1
  80. package/dist/api/slack-webhook-router.js.map +1 -1
  81. package/dist/apps/compose-generator.js +7 -4
  82. package/dist/apps/compose-generator.js.map +1 -1
  83. package/dist/apps/installer.d.ts +86 -0
  84. package/dist/apps/installer.d.ts.map +1 -1
  85. package/dist/apps/installer.js +440 -52
  86. package/dist/apps/installer.js.map +1 -1
  87. package/dist/index.js +37 -1
  88. package/dist/index.js.map +1 -1
  89. package/dist/session/compactor.d.ts +32 -2
  90. package/dist/session/compactor.d.ts.map +1 -1
  91. package/dist/session/compactor.js +134 -19
  92. package/dist/session/compactor.js.map +1 -1
  93. package/dist/session/process.d.ts +20 -3
  94. package/dist/session/process.d.ts.map +1 -1
  95. package/dist/session/process.js +120 -5
  96. package/dist/session/process.js.map +1 -1
  97. package/dist/shared/image-sniff.d.ts +15 -0
  98. package/dist/shared/image-sniff.d.ts.map +1 -0
  99. package/dist/shared/image-sniff.js +29 -0
  100. package/dist/shared/image-sniff.js.map +1 -0
  101. package/dist/shell/claude-pty-shell.js +146 -27
  102. package/dist/shell/claude-pty-shell.js.map +1 -1
  103. package/dist/shell/draft-phantom.d.ts +49 -0
  104. package/dist/shell/draft-phantom.d.ts.map +1 -0
  105. package/dist/shell/draft-phantom.js +59 -0
  106. package/dist/shell/draft-phantom.js.map +1 -0
  107. package/dist/shell/submit-diag.d.ts +67 -0
  108. package/dist/shell/submit-diag.d.ts.map +1 -0
  109. package/dist/shell/submit-diag.js +69 -0
  110. package/dist/shell/submit-diag.js.map +1 -0
  111. package/dist/types.d.ts +25 -0
  112. package/dist/types.d.ts.map +1 -1
  113. package/mcp/tools/discord/module.ts +24 -17
  114. package/mcp/tools/memory/archive-reader.test.ts +25 -1
  115. package/mcp/tools/memory/archive-reader.ts +70 -12
  116. package/mcp/tools/memory/archive-writer.test.ts +168 -0
  117. package/mcp/tools/memory/archive-writer.ts +137 -0
  118. package/mcp/tools/memory/module.test.ts +187 -0
  119. package/mcp/tools/memory/module.ts +192 -6
  120. package/mcp/tools/shared/limits.ts +14 -0
  121. package/mcp/tools/slack/module.ts +66 -4
  122. package/mcp/tools/telegram/module.ts +11 -3
  123. package/mcp/tools/telegram/receiver-server.ts +10 -3
  124. package/mcp/tools/telegram/typing.ts +178 -34
  125. package/package.json +1 -1
@@ -11,6 +11,7 @@
11
11
  * bot token at any time, so this module always sends directly from the
12
12
  * subprocess.
13
13
  */
14
+ import * as fs from 'fs';
14
15
  import type { ToolModule, McpToolDefinition, McpToolResult, ToolVisibility } from '../../types';
15
16
  // mcp/ ships as source (package.json `files` lists "mcp/", not "src/") and
16
17
  // runs directly under bun — it may only import a compiled dist/ artifact,
@@ -20,11 +21,17 @@ import type { ToolModule, McpToolDefinition, McpToolResult, ToolVisibility } fro
20
21
  // "Cannot find module" from an installed package). `npm run build` must have
21
22
  // run at least once for this import to resolve locally.
22
23
  import { SlackClient } from '../../../dist/api/slack-client.js';
24
+ import { MAX_ATTACHMENT_BYTES } from '../shared/limits';
23
25
 
24
26
  export class SlackModule implements ToolModule {
25
27
  id = 'slack';
26
28
  toolVisibility: ToolVisibility = 'current-channel';
27
29
 
30
+ // Files already delivered this session. Small models sometimes retry
31
+ // slack_reply after a transient send hiccup even though the upload
32
+ // succeeded, which spams duplicate images. We never re-send the same file.
33
+ private readonly sentFiles = new Set<string>();
34
+
28
35
  isEnabled(): boolean {
29
36
  return process.env.GATEWAY_ORIGIN_CHANNEL === 'slack';
30
37
  }
@@ -36,6 +43,7 @@ export class SlackModule implements ToolModule {
36
43
  description:
37
44
  'Send a reply to the current Slack conversation. ' +
38
45
  'Pass chat_id (the Slack channel/DM id shown in the <channel> tag) and text. ' +
46
+ 'Optionally pass files (absolute paths) to attach images or documents. ' +
39
47
  'Also pass message_id from the <channel> tag when present — it clears the ' +
40
48
  '⏳ "seen" reaction the gateway left on the inbound message. ' +
41
49
  'Pass thread_id to reply inside the same thread instead of top-level.',
@@ -50,6 +58,14 @@ export class SlackModule implements ToolModule {
50
58
  type: 'string',
51
59
  description: 'Message text.',
52
60
  },
61
+ files: {
62
+ type: 'array',
63
+ items: { type: 'string' },
64
+ description:
65
+ 'Absolute file paths to attach (e.g. a generate_image result path). ' +
66
+ 'Optional — text can be sent alone, files can be sent alone, or both ' +
67
+ 'together as one message with the text as caption.',
68
+ },
53
69
  message_id: {
54
70
  type: 'string',
55
71
  description:
@@ -63,7 +79,9 @@ export class SlackModule implements ToolModule {
63
79
  'to a threaded message stays in-thread.',
64
80
  },
65
81
  },
66
- required: ['chat_id', 'text'],
82
+ // `text` is NOT required: a files-only reply (an image with no caption)
83
+ // is a legitimate send.
84
+ required: ['chat_id'],
67
85
  },
68
86
  },
69
87
  ];
@@ -79,12 +97,26 @@ export class SlackModule implements ToolModule {
79
97
  const text = typeof args.text === 'string' ? args.text : '';
80
98
  const messageId = typeof args.message_id === 'string' ? args.message_id : '';
81
99
  const threadId = typeof args.thread_id === 'string' ? args.thread_id : '';
100
+ const requested = Array.isArray(args.files) ? (args.files as unknown[]) : [];
82
101
  const token = process.env.SLACK_BOT_TOKEN ?? '';
83
102
 
84
103
  if (!chatId) {
85
104
  return { content: [{ type: 'text', text: 'slack_reply: missing chat_id' }], isError: true };
86
105
  }
87
- if (!text) {
106
+
107
+ // Drop files already delivered successfully this session (retry-dedup): a
108
+ // small model sometimes retries slack_reply after a transient hiccup even
109
+ // though the upload landed, which would spam duplicate images.
110
+ const files = requested.filter(
111
+ (f): f is string => typeof f === 'string' && !this.sentFiles.has(f),
112
+ );
113
+
114
+ // Nothing new to say or send — the whole reply is a duplicate retry. No-op
115
+ // success so the agent treats it as delivered and stops retrying.
116
+ if (!text && files.length === 0 && requested.length > 0) {
117
+ return { content: [{ type: 'text', text: 'already sent (duplicate suppressed)' }] };
118
+ }
119
+ if (!text && files.length === 0) {
88
120
  return { content: [{ type: 'text', text: 'slack_reply: text cannot be empty' }], isError: true };
89
121
  }
90
122
  if (!token) {
@@ -93,16 +125,46 @@ export class SlackModule implements ToolModule {
93
125
 
94
126
  const client = new SlackClient({ botToken: token, logDir: process.env.GATEWAY_WORKSPACE_DIR ?? '/tmp' });
95
127
  try {
96
- const sent = await client.postMessage(chatId, text, threadId || undefined);
128
+ // Size-check before any upload starts, so an oversized file fails fast
129
+ // instead of half-way through a multi-file batch.
130
+ for (const f of files) {
131
+ const st = fs.statSync(f);
132
+ if (st.size > MAX_ATTACHMENT_BYTES) {
133
+ throw new Error(`file too large: ${f} (${(st.size / 1024 / 1024).toFixed(1)}MB, max 50MB)`);
134
+ }
135
+ }
136
+
137
+ // With files, ONE uploadFiles call carries both the attachments and the
138
+ // text (as Slack's initial_comment) — never also postMessage, which would
139
+ // split one reply into two Slack messages.
140
+ const sent =
141
+ files.length > 0
142
+ ? await client.uploadFiles(chatId, files, {
143
+ threadTs: threadId || undefined,
144
+ initialComment: text || undefined,
145
+ })
146
+ : await client.postMessage(chatId, text, threadId || undefined);
97
147
  if (!sent.ok) {
98
148
  return { content: [{ type: 'text', text: `slack_reply failed: ${sent.error}` }], isError: true };
99
149
  }
150
+ // Mark as sent only AFTER the send succeeds — a genuine failure leaves
151
+ // them eligible for a retry rather than silently dropped.
152
+ for (const f of files) this.sentFiles.add(f);
100
153
  // Best-effort: clear the ack-reaction the webhook left on the inbound
101
154
  // message. Never blocks or fails the reply itself on a reaction error.
102
155
  if (messageId) {
103
156
  void client.removeReaction(chatId, messageId).catch(() => {});
104
157
  }
105
- return { content: [{ type: 'text', text: 'Sent message to Slack.' }] };
158
+ return {
159
+ content: [
160
+ {
161
+ type: 'text',
162
+ text: files.length > 0
163
+ ? `Sent message to Slack (${files.length} file(s)).`
164
+ : 'Sent message to Slack.',
165
+ },
166
+ ],
167
+ };
106
168
  } catch (err) {
107
169
  return {
108
170
  content: [{ type: 'text', text: `slack_reply failed: ${(err as Error).message}` }],
@@ -8,6 +8,7 @@ import * as fs from 'fs';
8
8
  import * as os from 'os';
9
9
  import { migrateAccess, defaultAccess } from './pure';
10
10
  import { chunkText, htmlToPlain } from './typing';
11
+ import { MAX_ATTACHMENT_BYTES } from '../shared/limits';
11
12
  import type {
12
13
  ChannelModule,
13
14
  ChannelCapabilities,
@@ -20,7 +21,6 @@ import type {
20
21
  } from '../../types';
21
22
 
22
23
  const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
23
- const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024;
24
24
  const MAX_CHUNK_LIMIT = 4096;
25
25
 
26
26
  export class TelegramModule implements ChannelModule {
@@ -363,10 +363,18 @@ export class TelegramModule implements ChannelModule {
363
363
  }
364
364
  }
365
365
 
366
- // Write .replied marker
366
+ // Write .replied marker, tagged with the live typing-signal timestamp as
367
+ // this turn's id — the receiver (typing.ts) only treats a queued
368
+ // auto-forward as "already replied" when its own turnId matches this one,
369
+ // so a marker left over from an earlier turn can never suppress a later
370
+ // turn's forward (see typing.ts's isEntryAlreadyReplied()).
367
371
  try {
368
372
  fs.mkdirSync(this.typingDir, { recursive: true });
369
- fs.writeFileSync(path.join(this.typingDir, `${chat_id}.replied`), sendText);
373
+ let turnId: string | null = null;
374
+ try {
375
+ turnId = fs.readFileSync(path.join(this.typingDir, chat_id), 'utf8').trim() || null;
376
+ } catch {}
377
+ fs.writeFileSync(path.join(this.typingDir, `${chat_id}.replied`), JSON.stringify({ text: sendText, turnId }));
370
378
  } catch {}
371
379
 
372
380
  const result = sentIds.length === 1
@@ -39,6 +39,7 @@ import { createIncidentStore } from '../../../dist/agent/incident-store.js'
39
39
  import type { RecoveryOutcome } from '../../../dist/agent/incident.js'
40
40
  import { initDedupDir, isDuplicate as _isDuplicate, pruneDedup as _pruneDedup } from './dedup'
41
41
  import { normalizeTelegramLineBreaks, resolveTelegramReplyFormat, migrateAccess, telegramDisplayName } from './pure'
42
+ import { MAX_ATTACHMENT_BYTES } from '../shared/limits'
42
43
 
43
44
  // Standalone fallback: default state dir to ~/.claude/channels/telegram
44
45
  const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
@@ -336,7 +337,6 @@ function defaultAccess(): Access {
336
337
  }
337
338
 
338
339
  const MAX_CHUNK_LIMIT = 4096
339
- const MAX_ATTACHMENT_BYTES = 50 * 1024 * 1024
340
340
 
341
341
  // reply's files param takes any path. Claude can already Read+paste file
342
342
  // contents, so this isn't a new exfil channel for arbitrary paths — but the
@@ -783,10 +783,17 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => {
783
783
 
784
784
  // Typing persists until agent-runner sees result event + delay.
785
785
  // Removed signalReplyDone() — reply does not mean done, agent may continue working.
786
- // Write .replied marker so auto-forward in typing.ts skips duplicate send.
786
+ // Write .replied marker so auto-forward in typing.ts skips duplicate send —
787
+ // tagged with the live typing-signal timestamp as this turn's id, so a
788
+ // marker left over from an earlier turn can never suppress a later
789
+ // turn's forward (see typing.ts's isEntryAlreadyReplied()).
787
790
  try {
788
791
  mkdirSync(TYPING_DIR, { recursive: true })
789
- writeFileSync(join(TYPING_DIR, `${chat_id}.replied`), sendText)
792
+ let turnId: string | null = null
793
+ try {
794
+ turnId = readFileSync(join(TYPING_DIR, chat_id), 'utf8').trim() || null
795
+ } catch {}
796
+ writeFileSync(join(TYPING_DIR, `${chat_id}.replied`), JSON.stringify({ text: sendText, turnId }))
790
797
  } catch { /* non-fatal */ }
791
798
 
792
799
  const result =
@@ -173,6 +173,47 @@ export const STATUS_EMOJI: Record<string, string> = {
173
173
  error: '😱',
174
174
  }
175
175
 
176
+ /**
177
+ * Telegram reports failures as `description` on a GrammyError; anything else
178
+ * thrown here (a network error, a plain Error in tests) carries `message`.
179
+ *
180
+ * Read structurally rather than with `err instanceof GrammyError &&
181
+ * err.error_code`, the convention in receiver-server.ts, for two reasons: this
182
+ * module deliberately has no grammy import — it takes an injected BotApi so
183
+ * tests can drive it — and the two cases below are both HTTP 400, so the code
184
+ * alone cannot tell them apart. The description is the only thing that can.
185
+ */
186
+ function errorText(err: unknown): string {
187
+ if (typeof err === 'object' && err !== null) {
188
+ const e = err as { description?: unknown; message?: unknown }
189
+ if (typeof e.description === 'string') return e.description
190
+ if (typeof e.message === 'string') return e.message
191
+ }
192
+ return String(err)
193
+ }
194
+
195
+ /**
196
+ * Telegram rejects an edit whose new text is byte-identical to what the
197
+ * message already shows. That is not a failure to recover from — the message
198
+ * is intact and already correct — so the status loop must keep editing it
199
+ * rather than concluding it is gone and posting a replacement.
200
+ */
201
+ export function isNotModifiedError(err: unknown): boolean {
202
+ return /message is not modified/i.test(errorText(err))
203
+ }
204
+
205
+ /**
206
+ * The message can never be edited again: deleted, or past Telegram's edit
207
+ * window. Only these justify dropping the id and sending a fresh message.
208
+ * A transient failure (network, 429) deliberately does NOT — the message is
209
+ * still there, and re-sending would leave a duplicate behind.
210
+ */
211
+ export function isMessageGoneError(err: unknown): boolean {
212
+ return /message to (?:edit|be edited) not found|message can'?t be edited|message not found/i.test(
213
+ errorText(err),
214
+ )
215
+ }
216
+
176
217
  export function parseStatusFile(content: string): { status: string; detail?: string } {
177
218
  try {
178
219
  const parsed = JSON.parse(content);
@@ -193,6 +234,10 @@ export interface WorkingState {
193
234
  currentReaction: string | null
194
235
  lastDetail: string | null
195
236
  recentDetails: string[]
237
+ /** Text the status message currently displays. An edit that would rewrite a
238
+ * message with the exact text it already has is rejected by Telegram, so it
239
+ * is never issued. */
240
+ lastStatusText: string | null
196
241
  /** Last stage an incident was raised for — dedupes the turn-trace watchdog
197
242
  * so one contiguous stalled episode emits a single incident, not one per
198
243
  * 15s tick. Reset to null once the turn is no longer stalled. */
@@ -216,6 +261,78 @@ export interface FsApi {
216
261
  statSync(path: string): { mtimeMs: number }
217
262
  }
218
263
 
264
+ /**
265
+ * A single queued auto-forward. `turnId` identifies the turn that produced it
266
+ * (the live typing-signal timestamp read at write time) — `null` when no
267
+ * signal file existed yet (e.g. an orphan forward from an autonomous wake
268
+ * with no inbound message). Writers: `AgentRunner.writeAutoForward()`.
269
+ */
270
+ export interface ForwardEntry {
271
+ text: string
272
+ format: 'text' | 'html'
273
+ turnId: string | null
274
+ }
275
+
276
+ function normalizeForwardEntry(raw: unknown): ForwardEntry | null {
277
+ if (raw && typeof raw === 'object' && typeof (raw as { text?: unknown }).text === 'string') {
278
+ const o = raw as { text: string; format?: unknown; turnId?: unknown }
279
+ return {
280
+ text: o.text,
281
+ format: o.format === 'html' ? 'html' : 'text',
282
+ turnId: typeof o.turnId === 'string' ? o.turnId : null,
283
+ }
284
+ }
285
+ return null
286
+ }
287
+
288
+ /**
289
+ * Parse `.forward` file content into an ordered queue of entries. Accepts the
290
+ * current queue format (a JSON array), the pre-queue single-object format,
291
+ * and the original plain-text format — so an in-flight file survives a
292
+ * rolling deploy in either direction instead of losing its content.
293
+ */
294
+ export function parseForwardQueue(raw: string): ForwardEntry[] {
295
+ const trimmed = raw.trim()
296
+ if (!trimmed) return []
297
+ try {
298
+ const parsed = JSON.parse(trimmed)
299
+ if (Array.isArray(parsed)) {
300
+ return parsed.map(normalizeForwardEntry).filter((e): e is ForwardEntry => e !== null)
301
+ }
302
+ const single = normalizeForwardEntry(parsed)
303
+ return single ? [single] : []
304
+ } catch {
305
+ return [{ text: trimmed, format: 'text', turnId: null }]
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Parse `.replied` marker content. Current format is `{ text, turnId }`; a
311
+ * legacy marker (plain text, or JSON without a `turnId`) parses to
312
+ * `turnId: null`, which — per `resolveForwardDelivery` below — never
313
+ * satisfies dedup, so a marker from a version predating this format degrades
314
+ * to "deliver" rather than risking a silent drop.
315
+ */
316
+ function parseRepliedTurnId(raw: string): string | null {
317
+ try {
318
+ const parsed = JSON.parse(raw.trim()) as { turnId?: unknown }
319
+ if (parsed && typeof parsed === 'object' && typeof parsed.turnId === 'string') {
320
+ return parsed.turnId
321
+ }
322
+ } catch {}
323
+ return null
324
+ }
325
+
326
+ /**
327
+ * Turn-scoped dedup: an entry is already covered by the reply tool only when
328
+ * both sides captured the SAME live turn id at write time. A `null` turnId on
329
+ * either side never matches — favors an extra/duplicate delivery over a
330
+ * silently dropped one.
331
+ */
332
+ function isEntryAlreadyReplied(entry: ForwardEntry, repliedTurnId: string | null): boolean {
333
+ return repliedTurnId !== null && entry.turnId !== null && entry.turnId === repliedTurnId
334
+ }
335
+
219
336
  /**
220
337
  * Deliver auto-forwarded turn text to a chat, never silently dropping content.
221
338
  * Each chunk that fails as HTML (e.g. Telegram rejects an entity the balancer
@@ -292,22 +409,18 @@ export function drainOrphanForwards(
292
409
  // Remove BEFORE sending so a slow/failing send can't double-deliver.
293
410
  fsApi.rmSync(forwardPath, { force: true })
294
411
  // Mirror stop()'s dedup: a lingering .replied with no typing state means
295
- // the agent already sent this turn's text via the reply tool.
412
+ // the agent already sent SOME turn's text via the reply tool — but only
413
+ // entries whose turnId matches that marker's turn are actually covered.
296
414
  const repliedPath = `${typingDir}/${chatId}.replied`
415
+ let repliedTurnId: string | null = null
297
416
  if (fsApi.existsSync(repliedPath)) {
417
+ try { repliedTurnId = parseRepliedTurnId(fsApi.readFileSync(repliedPath, 'utf8')) } catch {}
298
418
  fsApi.rmSync(repliedPath, { force: true })
299
- continue
300
419
  }
301
- let text = raw
302
- let parseMode: 'HTML' | undefined
303
- try {
304
- const parsed = JSON.parse(raw) as { text: string; format: string }
305
- text = parsed.text
306
- parseMode = parsed.format === 'html' ? 'HTML' : undefined
307
- } catch {
308
- // Old format: plain text
420
+ for (const entry of parseForwardQueue(raw)) {
421
+ if (!entry.text || isEntryAlreadyReplied(entry, repliedTurnId)) continue
422
+ void deliverForwardText(botApi, chatId, entry.text, entry.format === 'html' ? 'HTML' : undefined)
309
423
  }
310
- if (text) void deliverForwardText(botApi, chatId, text, parseMode)
311
424
  }
312
425
  }
313
426
 
@@ -472,30 +585,29 @@ export function createWorkingStateManager(
472
585
  }
473
586
  } catch {}
474
587
  }
475
- // Auto-forward result text to Telegram if the agent did not already reply with the same text.
588
+ // Auto-forward queued turn text to Telegram, skipping only the entries the
589
+ // reply tool already sent (same turnId as the .replied marker) — turn A's
590
+ // leftover marker must never suppress turn B's forward. See
591
+ // isEntryAlreadyReplied() / the module docstring for the race this guards.
476
592
  const forwardPath = forwardFilePath(chatId)
477
593
  const repliedPath = repliedFilePath(chatId)
478
594
  if (fsApi.existsSync(forwardPath)) {
595
+ let entries: ForwardEntry[] = []
479
596
  try {
480
- const raw = fsApi.readFileSync(forwardPath, 'utf8').trim()
481
- let forwardText: string
482
- let parseMode: 'HTML' | undefined
483
- try {
484
- const parsed = JSON.parse(raw) as { text: string; format: string }
485
- forwardText = parsed.text
486
- parseMode = parsed.format === 'html' ? 'HTML' : undefined
487
- } catch {
488
- // Fallback: treat as plain text (old format compatibility)
489
- forwardText = raw
490
- parseMode = undefined
491
- }
492
- // Skip if the reply tool already sent a message (agent already replied)
493
- const alreadyReplied = fsApi.existsSync(repliedPath)
494
- if (!alreadyReplied && forwardText) {
495
- await deliverForwardText(botApi, chatId, forwardText, parseMode)
496
- }
597
+ entries = parseForwardQueue(fsApi.readFileSync(forwardPath, 'utf8'))
497
598
  } catch {}
599
+ // Remove BEFORE delivering (mirrors drainOrphanForwards' same comment):
600
+ // delivery can take a while (multiple chunks, retries), and a
601
+ // writeAutoForward() append landing mid-delivery must start a fresh
602
+ // file rather than have this call's cleanup silently discard it.
498
603
  fsApi.rmSync(forwardPath, { force: true })
604
+ const repliedTurnId = fsApi.existsSync(repliedPath)
605
+ ? parseRepliedTurnId(fsApi.readFileSync(repliedPath, 'utf8'))
606
+ : null
607
+ for (const entry of entries) {
608
+ if (!entry.text || isEntryAlreadyReplied(entry, repliedTurnId)) continue
609
+ await deliverForwardText(botApi, chatId, entry.text, entry.format === 'html' ? 'HTML' : undefined)
610
+ }
499
611
  }
500
612
  // NOTE: interactive-menu (.menu) delivery is handled by an independent poller
501
613
  // in receiver-server.ts (drainMenuFiles), NOT here. A menu can be emitted
@@ -551,6 +663,7 @@ export function createWorkingStateManager(
551
663
  currentReaction: null,
552
664
  lastDetail: null,
553
665
  recentDetails: [],
666
+ lastStatusText: null,
554
667
  lastIncidentStage: null,
555
668
  }
556
669
  states.set(chatId, state)
@@ -583,13 +696,44 @@ export function createWorkingStateManager(
583
696
  if (s.statusMessageId === null) {
584
697
  try {
585
698
  const sent = await botApi.sendMessage(chatId, text)
586
- s.statusMessageId = sent.message_id
699
+ // Re-read rather than writing through the captured `s`: the turn
700
+ // can end while the send is in flight, and the state this records
701
+ // against must be the live one or none at all.
702
+ const current = states.get(chatId)
703
+ if (current) {
704
+ current.statusMessageId = sent.message_id
705
+ current.lastStatusText = text
706
+ }
587
707
  } catch {}
588
- } else {
589
- await botApi.editMessageText(chatId, s.statusMessageId, text).catch(async () => {
708
+ } else if (text !== s.lastStatusText) {
709
+ // Nothing to say, so nothing is sent. The cost is that a status
710
+ // message deleted by hand is not noticed until the next edit — but
711
+ // the elapsed line advances every minute even at `Xh Ym`, so that
712
+ // wait is bounded, and it buys immunity from the duplicate loop.
713
+ try {
714
+ await botApi.editMessageText(chatId, s.statusMessageId, text)
715
+ s.lastStatusText = text
716
+ } catch (err) {
590
717
  const current = states.get(chatId)
591
- if (current) current.statusMessageId = null
592
- })
718
+ if (!current) {
719
+ // The turn ended while the edit was in flight.
720
+ } else if (isNotModifiedError(err)) {
721
+ // The message already shows this text — nothing to recover from.
722
+ current.lastStatusText = text
723
+ } else if (isMessageGoneError(err)) {
724
+ current.statusMessageId = null
725
+ current.lastStatusText = null
726
+ } else {
727
+ // Keep the id — the message is still there, and the next tick
728
+ // edits it again; re-sending would leave a duplicate behind.
729
+ // Logged because a silently swallowed edit failure is exactly
730
+ // what let the duplicate loop run unnoticed: if Telegram ever
731
+ // returns something this does not classify, it should be
732
+ // visible in the receiver log rather than inferred from
733
+ // symptoms.
734
+ console.error(`[typing] status edit failed (retrying next tick): ${errorText(err)}`)
735
+ }
736
+ }
593
737
  }
594
738
  } finally {
595
739
  statusUpdatePending = false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmaxma/claude-gateway",
3
- "version": "1.7.8",
3
+ "version": "1.7.10",
4
4
  "description": "Multi-agent gateway for Claude",
5
5
  "repository": {
6
6
  "type": "git",