@0xmaxma/claude-gateway 1.3.24 → 1.3.31

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 (92) hide show
  1. package/README.md +172 -50
  2. package/config.template.json +6 -2
  3. package/dist/agent/incident-store.d.ts +89 -0
  4. package/dist/agent/incident-store.d.ts.map +1 -0
  5. package/dist/agent/incident-store.js +299 -0
  6. package/dist/agent/incident-store.js.map +1 -0
  7. package/dist/agent/incident.d.ts +156 -0
  8. package/dist/agent/incident.d.ts.map +1 -0
  9. package/dist/agent/incident.js +177 -0
  10. package/dist/agent/incident.js.map +1 -0
  11. package/dist/agent/recovery-executor.d.ts +117 -0
  12. package/dist/agent/recovery-executor.d.ts.map +1 -0
  13. package/dist/agent/recovery-executor.js +168 -0
  14. package/dist/agent/recovery-executor.js.map +1 -0
  15. package/dist/agent/recovery-policy.d.ts +97 -0
  16. package/dist/agent/recovery-policy.d.ts.map +1 -0
  17. package/dist/agent/recovery-policy.js +164 -0
  18. package/dist/agent/recovery-policy.js.map +1 -0
  19. package/dist/agent/runner.d.ts +44 -0
  20. package/dist/agent/runner.d.ts.map +1 -1
  21. package/dist/agent/runner.js +272 -2
  22. package/dist/agent/runner.js.map +1 -1
  23. package/dist/agent/safe-mode.d.ts +61 -0
  24. package/dist/agent/safe-mode.d.ts.map +1 -0
  25. package/dist/agent/safe-mode.js +102 -0
  26. package/dist/agent/safe-mode.js.map +1 -0
  27. package/dist/agent/triage.d.ts +94 -0
  28. package/dist/agent/triage.d.ts.map +1 -0
  29. package/dist/agent/triage.js +209 -0
  30. package/dist/agent/triage.js.map +1 -0
  31. package/dist/agent/turn-trace.d.ts +120 -0
  32. package/dist/agent/turn-trace.d.ts.map +1 -0
  33. package/dist/agent/turn-trace.js +122 -0
  34. package/dist/agent/turn-trace.js.map +1 -0
  35. package/dist/api/gateway-router.d.ts +21 -0
  36. package/dist/api/gateway-router.d.ts.map +1 -1
  37. package/dist/api/gateway-router.js +58 -17
  38. package/dist/api/gateway-router.js.map +1 -1
  39. package/dist/api/line-pending-senders.d.ts +7 -2
  40. package/dist/api/line-pending-senders.d.ts.map +1 -1
  41. package/dist/api/line-pending-senders.js +7 -2
  42. package/dist/api/line-pending-senders.js.map +1 -1
  43. package/dist/api/router.d.ts.map +1 -1
  44. package/dist/api/router.js +563 -172
  45. package/dist/api/router.js.map +1 -1
  46. package/dist/api/wizard-state.d.ts +1 -4
  47. package/dist/api/wizard-state.d.ts.map +1 -1
  48. package/dist/api/wizard-state.js.map +1 -1
  49. package/dist/config/migrator.d.ts +4 -0
  50. package/dist/config/migrator.d.ts.map +1 -1
  51. package/dist/config/migrator.js +60 -3
  52. package/dist/config/migrator.js.map +1 -1
  53. package/dist/discord/receiver.d.ts +1 -0
  54. package/dist/discord/receiver.d.ts.map +1 -1
  55. package/dist/discord/receiver.js +24 -6
  56. package/dist/discord/receiver.js.map +1 -1
  57. package/dist/index.js +3 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/session/process.d.ts +18 -0
  60. package/dist/session/process.d.ts.map +1 -1
  61. package/dist/session/process.js +61 -1
  62. package/dist/session/process.js.map +1 -1
  63. package/dist/shell/claude-pty-shell.js +59 -0
  64. package/dist/shell/claude-pty-shell.js.map +1 -1
  65. package/dist/shell/control-channel.d.ts +74 -0
  66. package/dist/shell/control-channel.d.ts.map +1 -0
  67. package/dist/shell/control-channel.js +114 -0
  68. package/dist/shell/control-channel.js.map +1 -0
  69. package/dist/telegram/receiver.d.ts +1 -0
  70. package/dist/telegram/receiver.d.ts.map +1 -1
  71. package/dist/telegram/receiver.js +18 -6
  72. package/dist/telegram/receiver.js.map +1 -1
  73. package/dist/types.d.ts +21 -0
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/ui/web-ui.d.ts.map +1 -1
  76. package/dist/ui/web-ui.js +103 -2
  77. package/dist/ui/web-ui.js.map +1 -1
  78. package/mcp/tools/discord/access.ts +136 -20
  79. package/mcp/tools/discord/client.ts +3 -1
  80. package/mcp/tools/discord/module.ts +75 -4
  81. package/mcp/tools/discord/skills/access/SKILL.md +66 -8
  82. package/mcp/tools/discord/skills/configure/SKILL.md +11 -1
  83. package/mcp/tools/discord/types.ts +21 -2
  84. package/mcp/tools/skills/handlers.ts +4 -2
  85. package/mcp/tools/telegram/dedup.ts +4 -1
  86. package/mcp/tools/telegram/module.ts +5 -14
  87. package/mcp/tools/telegram/pure.ts +169 -31
  88. package/mcp/tools/telegram/receiver-server.ts +271 -78
  89. package/mcp/tools/telegram/skills/access/SKILL.md +93 -23
  90. package/mcp/tools/telegram/skills/configure/SKILL.md +31 -26
  91. package/mcp/tools/telegram/typing.ts +124 -0
  92. package/package.json +3 -1
@@ -51,46 +51,44 @@ Read both state files and give the user a complete picture:
51
51
  (`123456789:...`).
52
52
 
53
53
  2. **Access** — read `{STATE_DIR}/access.json` (missing file
54
- = defaults: `dmPolicy: "pairing"`, empty allowlist). Show:
55
- - DM policy and what it means in one line
54
+ = defaults: `dmPolicy: "allowlist"`, `pairing: true`, empty allowlist). Show:
55
+ - DM policy (`open`/`allowlist`/`disabled`) and what it means in one line
56
+ - Pairing toggle (on/off) and what it means in one line
56
57
  - Allowed senders: count, and list display names or IDs
57
58
  - Pending pairings: count, with codes and display names if any
58
59
 
59
60
  3. **What next** — end with a concrete next step based on state:
60
61
  - No token → *"Run `/telegram:configure <token>` with the token from
61
62
  BotFather."*
62
- - Token set, policy is pairing, nobody allowed → *"DM your bot on
63
- Telegram. It replies with a code; approve with `/telegram:access pair
64
- <code>`."*
63
+ - Token set, `allowlist` + pairing on, nobody allowed → *"DM your bot on
64
+ Telegram. It replies with a code — approve it from the web Channels card
65
+ (or run `/telegram:access pair <code>` here)."*
65
66
  - Token set, someone allowed → *"Ready. DM your bot to reach the
66
67
  assistant."*
67
68
 
68
- **Push toward lockdown — always.** The goal for every setup is `allowlist`
69
- with a defined list. `pairing` is not a policy to stay on; it's a temporary
70
- way to capture Telegram user IDs you don't know. Once the IDs are in, pairing
71
- has done its job and should be turned off.
69
+ **The access model.** The base policy should be `allowlist` — only people on
70
+ the list reach the assistant. **Pairing** is an orthogonal on/off toggle
71
+ (default on) that sits on top of `allowlist`: when on, an unknown sender who
72
+ DMs the bot gets a one-time code that shows up in Pending, and the admin
73
+ approves it (from the web Channels card, or `/telegram:access pair <code>`).
74
+ It's a lightweight identity check, and it's fine to leave on as your standing
75
+ way to let new people in. Turn pairing **off** only if you want a hard
76
+ allowlist where strangers are dropped silently with no code.
72
77
 
73
78
  Drive the conversation this way:
74
79
 
75
80
  1. Read the allowlist. Tell the user who's in it.
76
81
  2. Ask: *"Is that everyone who should reach you through this bot?"*
77
- 3. **If yes and policy is still `pairing`** → *"Good. Let's lock it down so
78
- nobody else can trigger pairing codes:"* and offer to run
79
- `/telegram:access policy allowlist`. Do this proactively — don't wait to
80
- be asked.
81
- 4. **If no, people are missing** → *"Have them DM the bot; you'll approve
82
- each with `/telegram:access pair <code>`. Run this skill again once
83
- everyone's in and we'll lock it."*
84
- 5. **If the allowlist is empty and they haven't paired themselves yet** →
85
- *"DM your bot to capture your own ID first. Then we'll add anyone else
86
- and lock it down."*
87
- 6. **If policy is already `allowlist`** → confirm this is the locked state.
88
- If they need to add someone: *"They'll need to give you their numeric ID
89
- (have them message @userinfobot), or you can briefly flip to pairing:
90
- `/telegram:access policy pairing` → they DM → you pair → flip back."*
91
-
92
- Never frame `pairing` as the correct long-term choice. Don't skip the lockdown
93
- offer.
82
+ 3. **If someone's missing** → *"Leave pairing on: have them DM the bot, then
83
+ approve the code that appears in Pending (web card or `/telegram:access
84
+ pair <code>`)."*
85
+ 4. **If the allowlist is empty and they haven't paired themselves yet** →
86
+ *"DM your bot to capture your own ID first — approve the code and you're
87
+ in."*
88
+ 5. **If they want a hard lockdown** (no new codes for strangers) → offer to
89
+ run `/telegram:access pairing off`. This keeps `allowlist` but stops
90
+ minting codes; add people later by flipping pairing back on, or with
91
+ `/telegram:access allow <senderId>`.
94
92
 
95
93
  ### `<token>` — save it
96
94
 
@@ -116,3 +114,10 @@ Delete the `TELEGRAM_BOT_TOKEN=` line (or the file if that's the only line).
116
114
  or `/reload-plugins`. Say so after saving.
117
115
  - `access.json` is re-read on every inbound message — policy changes via
118
116
  `/telegram:access` take effect immediately, no restart.
117
+ - **Groups need more than allowlisting.** DMs work over long-polling, but in a
118
+ group Telegram's default **Privacy Mode** stops the bot from receiving plain
119
+ messages (it only gets `/commands`, @mentions, and replies), and commands are
120
+ dropped in groups anyway. To use groups, tell the user to promote the bot to
121
+ **Admin** in the group (or disable Privacy Mode in BotFather and re-add it),
122
+ then allowlist the group via `/telegram:access` (pairing code or `group allow
123
+ <groupId>`).
@@ -13,6 +13,17 @@
13
13
  * STATE_DIR/typing/{chatId}.error — written by AgentRunner on session failure
14
14
  */
15
15
 
16
+ // Import compiled dist/, not raw src/ — src/ is not published (files: ["mcp/"]),
17
+ // so a src/ import crashes this bun-run tool on installed packages. Enforced by
18
+ // tests/unit/mcp-no-src-imports.test.ts.
19
+ import {
20
+ classifyTurn,
21
+ type TurnObservation,
22
+ type TurnStage,
23
+ type TurnIncidentSink,
24
+ type TurnIncidentEvidence,
25
+ } from '../../../dist/agent/turn-trace.js'
26
+
16
27
  export const TELEGRAM_MAX_CHARS = 4096
17
28
 
18
29
  /**
@@ -145,6 +156,10 @@ export const ERROR_MESSAGES: Record<string, string> = {
145
156
  PROCESS_FAILED: '❌ Claude stopped unexpectedly. Please try sending a new message.',
146
157
  POOL_FULL: '⚠️ Too many concurrent sessions. Please try again in a moment.',
147
158
  SPAWN_FAILED: '❌ Failed to start Claude session. Please try again.',
159
+ // Epic #195, Phase 3: the interactive backend failed repeatedly, so the agent
160
+ // temporarily fell back to the headless backend to keep serving.
161
+ SAFE_MODE_ENABLED:
162
+ '⚠️ The interactive backend kept failing, so I switched to safe mode (headless) for now. Please resend your message — it should go through.',
148
163
  }
149
164
 
150
165
  export const STATUS_EMOJI: Record<string, string> = {
@@ -177,6 +192,10 @@ export interface WorkingState {
177
192
  currentReaction: string | null
178
193
  lastDetail: string | null
179
194
  recentDetails: string[]
195
+ /** Last stage an incident was raised for — dedupes the turn-trace watchdog
196
+ * so one contiguous stalled episode emits a single incident, not one per
197
+ * 15s tick. Reset to null once the turn is no longer stalled. */
198
+ lastIncidentStage: TurnStage | null
180
199
  }
181
200
 
182
201
  export interface BotApi {
@@ -295,6 +314,7 @@ export function createWorkingStateManager(
295
314
  typingDir: string,
296
315
  botApi: BotApi,
297
316
  fsApi: FsApi,
317
+ onIncident?: TurnIncidentSink,
298
318
  ) {
299
319
  const states = new Map<string, WorkingState>()
300
320
 
@@ -330,6 +350,106 @@ export function createWorkingStateManager(
330
350
  return `${typingDir}/${chatId}.replied`
331
351
  }
332
352
 
353
+ function menuFilePath(chatId: string): string {
354
+ return `${typingDir}/${chatId}.menu`
355
+ }
356
+
357
+ // ─── Turn-trace watchdog (Epic #195, Phase 1) ──────────────────────────────
358
+ // Read-only: builds an observation of this turn's on-disk artifacts, classifies
359
+ // the current pipeline stage, and emits a (silent) incident when a stage stalls.
360
+ // It never mutates state or messages the user — the existing heartbeat warn/stop
361
+ // below remains the sole user-facing stalled behaviour.
362
+
363
+ function statMtime(path: string): number | null {
364
+ try {
365
+ return fsApi.existsSync(path) ? fsApi.statSync(path).mtimeMs : null
366
+ } catch {
367
+ return null
368
+ }
369
+ }
370
+
371
+ function readTurnObservation(chatId: string, startedAt: number): TurnObservation {
372
+ let statusLabel: string | null = null
373
+ const statusPath = statusFilePath(chatId)
374
+ if (fsApi.existsSync(statusPath)) {
375
+ try {
376
+ statusLabel = parseStatusFile(fsApi.readFileSync(statusPath, 'utf8')).status
377
+ } catch {}
378
+ }
379
+ return {
380
+ now: Date.now(),
381
+ // A live WorkingState means the signal file was written at startedAt.
382
+ signalAt: fsApi.existsSync(typingFilePath(chatId)) ? startedAt : null,
383
+ statusLabel,
384
+ heartbeatAt: statMtime(heartbeatFilePath(chatId)),
385
+ processingAt: statMtime(processingFilePath(chatId)),
386
+ forwardAt: statMtime(forwardFilePath(chatId)),
387
+ menuAt: statMtime(menuFilePath(chatId)),
388
+ repliedPresent: fsApi.existsSync(repliedFilePath(chatId)),
389
+ errorPresent: fsApi.existsSync(errorFilePath(chatId)),
390
+ }
391
+ }
392
+
393
+ function readFileSafe(path: string): string | null {
394
+ try {
395
+ return fsApi.existsSync(path) ? fsApi.readFileSync(path, 'utf8') : null
396
+ } catch {
397
+ return null
398
+ }
399
+ }
400
+
401
+ // Build the diagnostic evidence bundle for an incident from the same
402
+ // observation the classifier used, plus the raw status/error file contents.
403
+ // Cheap and read-only — no listing of the whole typing dir, just this turn's
404
+ // known artifacts. The store scrubs everything before it is persisted.
405
+ function readTurnEvidence(chatId: string, obs: TurnObservation): TurnIncidentEvidence {
406
+ const artifacts: string[] = []
407
+ if (obs.signalAt !== null) artifacts.push('signal')
408
+ if (obs.statusLabel !== null) artifacts.push(`status=${obs.statusLabel}`)
409
+ if (obs.heartbeatAt !== null) artifacts.push('heartbeat')
410
+ if (obs.processingAt !== null) artifacts.push('processing')
411
+ if (obs.forwardAt !== null) artifacts.push('forward')
412
+ if (obs.menuAt !== null) artifacts.push('menu')
413
+ if (obs.repliedPresent) artifacts.push('replied')
414
+ if (obs.errorPresent) artifacts.push('error')
415
+ return {
416
+ artifacts,
417
+ statusText: readFileSafe(statusFilePath(chatId)),
418
+ errorText: readFileSafe(errorFilePath(chatId)),
419
+ }
420
+ }
421
+
422
+ function checkTurnTrace(chatId: string, startedAt: number): void {
423
+ const state = states.get(chatId)
424
+ if (!state) return
425
+ const obs = readTurnObservation(chatId, startedAt)
426
+ const trace = classifyTurn(obs)
427
+ if (!trace.stalled) {
428
+ state.lastIncidentStage = null
429
+ return
430
+ }
431
+ // Dedupe: one incident per contiguous stalled-stage episode.
432
+ if (state.lastIncidentStage === trace.stage) return
433
+ state.lastIncidentStage = trace.stage
434
+ if (onIncident) {
435
+ // A fresh .processing sentinel (mtime >= startedAt) means the turn is
436
+ // genuinely mid-work (e.g. a long sub-agent), not silently wedged.
437
+ const midTurn = obs.processingAt !== null && obs.processingAt >= startedAt
438
+ onIncident(
439
+ {
440
+ chatId,
441
+ stage: trace.stage,
442
+ failureClass: trace.failureClass,
443
+ sinceMs: trace.sinceMs,
444
+ budgetMs: trace.budgetMs,
445
+ midTurn,
446
+ at: Date.now(),
447
+ },
448
+ readTurnEvidence(chatId, obs),
449
+ )
450
+ }
451
+ }
452
+
333
453
  async function stop(chatId: string): Promise<void> {
334
454
  const state = states.get(chatId)
335
455
  if (!state) return
@@ -430,6 +550,7 @@ export function createWorkingStateManager(
430
550
  currentReaction: null,
431
551
  lastDetail: null,
432
552
  recentDetails: [],
553
+ lastIncidentStage: null,
433
554
  }
434
555
  states.set(chatId, state)
435
556
 
@@ -530,6 +651,9 @@ export function createWorkingStateManager(
530
651
  // Heartbeat file is written by SessionProcess on every Claude stdout line.
531
652
  state.stalledInterval = setInterval(async () => {
532
653
  if (!states.has(chatId)) return
654
+ // Turn-trace watchdog: staged classification + incident telemetry. Silent
655
+ // and side-effect-free — the heartbeat warn/stop below is unchanged.
656
+ checkTurnTrace(chatId, startedAt)
533
657
  const hbPath = heartbeatFilePath(chatId)
534
658
  let lastActivity = startedAt
535
659
  if (fsApi.existsSync(hbPath)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@0xmaxma/claude-gateway",
3
- "version": "1.3.24",
3
+ "version": "1.3.31",
4
4
  "description": "Multi-agent gateway for Claude",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,7 +28,9 @@
28
28
  "build": "tsc",
29
29
  "start": "node --no-warnings=ExperimentalWarning --env-file-if-exists=.env dist/index.js",
30
30
  "postinstall": "if command -v bun >/dev/null 2>&1; then bun install --cwd mcp; else echo 'Warning: bun not found. Run `cd mcp && bun install` manually to enable MCP tools.'; fi",
31
+ "pretest": "npm run build",
31
32
  "test": "jest",
33
+ "pretest:unit": "npm run build",
32
34
  "test:unit": "jest --testPathPattern=unit",
33
35
  "test:e2e": "jest --testPathPattern=tests/e2e --forceExit",
34
36
  "integration": "jest --testPathPattern=integration --testTimeout=15000",