@deadragdoll/tellymcp 0.0.8 → 0.0.9

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/README-ru.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # TellyMCP
2
2
 
3
- [English](README.md) | [Русский](README-ru.md) | [Standalone Guide](STANDALONE.md) | [Standalone RU](STANDALONE-ru.md) | [Release Notes](VERSION.md)
3
+ [English](README.md) | [Русский](README-ru.md) | [Standalone Guide](STANDALONE.md) | [Standalone RU](STANDALONE-ru.md) | [Screenshots](screenshots/README.md) | [Gallery](screenshots/GALLERY.md) | [Release Notes](VERSION.md)
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/%40deadragdoll%2Ftellymcp)](https://www.npmjs.com/package/@deadragdoll/tellymcp)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/%40deadragdoll%2Ftellymcp)](https://www.npmjs.com/package/@deadragdoll/tellymcp)
@@ -239,6 +239,26 @@ tellymcp run --env .env
239
239
 
240
240
  - `https://your-host.example/api/mcp`
241
241
 
242
+ ## Mini App / Live
243
+
244
+ Если `WEBAPP_ENABLED=true`, в меню сессии появляется `🖥 Live`.
245
+
246
+ Mini App:
247
+
248
+ - обслуживается этим же Node service по `WEBAPP_BASE_PATH`
249
+ - в `client`-режиме тоже может открываться через общий gateway domain
250
+ - показывает видимую область tmux pane и отправляет ограниченный набор control actions
251
+ - валидирует Telegram `initData` на сервере
252
+ - умеет открываться в режимах:
253
+ - `default`
254
+ - `expand`
255
+ - `fullscreen` с fallback на `expand`
256
+ - после успешного bootstrap удаляет временное launcher-сообщение
257
+ - автоматически восстанавливается после короткого рестарта шлюза:
258
+ - краткий `502/503` переживается обычным polling
259
+ - если in-process WebApp session на шлюзе была потеряна и приходит `401/403`, Mini App сама делает повторный bootstrap
260
+ - в типовом restart-case переоткрывать `Live` не нужно
261
+
242
262
  ## Docker: только для инфраструктуры или для `gateway`-only
243
263
 
244
264
  Docker больше не является основным способом запуска TellyMCP, но один container path поддерживается:
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # TellyMCP
2
2
 
3
- [English](README.md) | [Русский](README-ru.md) | [Standalone Guide](STANDALONE.md) | [Standalone RU](STANDALONE-ru.md) | [Release Notes](VERSION.md)
3
+ [English](README.md) | [Русский](README-ru.md) | [Standalone Guide](STANDALONE.md) | [Standalone RU](STANDALONE-ru.md) | [Screenshots](screenshots/README.md) | [Gallery](screenshots/GALLERY.md) | [Release Notes](VERSION.md)
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/%40deadragdoll%2Ftellymcp)](https://www.npmjs.com/package/@deadragdoll/tellymcp)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/%40deadragdoll%2Ftellymcp)](https://www.npmjs.com/package/@deadragdoll/tellymcp)
@@ -521,6 +521,10 @@ The Mini App:
521
521
  - requires the Telegram user from `initData` to match the bound session user
522
522
  - resolves the active session from the bound Telegram user, so a session id in the URL is not required for normal use
523
523
  - deletes the temporary `Open Live View` launcher message after successful Mini App bootstrap
524
+ - auto-recovers after a short gateway restart:
525
+ - short `502/503` periods are tolerated by polling
526
+ - expired in-process WebApp sessions (`401/403`) trigger an automatic re-bootstrap
527
+ - in normal restart cases the user does not need to reopen `Live`
524
528
  - allows only a fixed control set:
525
529
  - `Esc`
526
530
  - `Tab`
package/TOOLS.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # Tools
2
2
 
3
- Version: `2026-05-17.1`
3
+ Version: `2026-05-18.1`
4
4
 
5
5
  Gateway/client runtime compatibility:
6
6
 
7
7
  - `TOOLS.md` hash sync and runtime version handshake are separate checks.
8
+ - The authoritative freshness check for `TOOLS.md` is the content hash, not this human-readable `Version:` line.
9
+ - Treat this `Version:` line only as a quick visual marker for humans and logs.
8
10
  - `TOOLS.md` sync tells the agent to refresh instructions.
9
11
  - `ws hello/hello_ack` checks `package_version`, `protocol_version`, and `capabilities`.
10
12
  - If protocol major versions differ, gateway transport is blocked until the older side is upgraded.
package/VERSION.md CHANGED
@@ -4,6 +4,34 @@ Public, user-facing release notes for published versions of `@deadragdoll/tellym
4
4
 
5
5
  For detailed engineering history, refactors, and internal development notes, see [CHANGELOG.md](CHANGELOG.md).
6
6
 
7
+ ## 0.0.9
8
+
9
+ ### Added
10
+ - Public screenshot docs:
11
+ - full screenshot index in `screenshots/README.md`
12
+ - shorter public gallery in `screenshots/GALLERY.md`
13
+ - More explicit Live launch modes from Telegram:
14
+ - `Fullscreen`
15
+ - `Expand`
16
+ - `Default`
17
+
18
+ ### Changed
19
+ - README and README-RU now surface screenshot links directly in the top navigation.
20
+ - The `Live` Mini App UI is more practical on phones:
21
+ - wrap/unwrap toggle moved into the status bar
22
+ - session name is shown as a compact state badge
23
+ - update timestamp is shorter and less noisy
24
+ - local `Live` screenshots are documented in a clearer flow near the launcher step
25
+
26
+ ### Fixed
27
+ - `Live` now reconnects more gracefully after a gateway restart:
28
+ - short `502/503` gaps recover through polling
29
+ - expired in-process WebApp sessions (`401/403`) trigger automatic re-bootstrap
30
+ - reopening the Mini App is no longer required in the normal restart case
31
+ - Runtime MCP metadata now reports the actual package version instead of stale hardcoded version data.
32
+ - Successful tmux nudges no longer overwrite `tmuxPaneId` with non-pane targets such as `backend:0.0`.
33
+ - If tmux itself is unavailable for a paired session with a saved tmux target, Telegram now receives an operational warning instead of leaving the signal only in backend logs.
34
+
7
35
  ## 0.0.8
8
36
 
9
37
  ### Added
@@ -32,6 +60,10 @@ For detailed engineering history, refactors, and internal development notes, see
32
60
  - Stale tmux pane ids like `%1 -> %2` no longer require manual user understanding before the service can try to wake the session again.
33
61
  - Broken tmux nudge targets are now visible to the user in Telegram, not only in backend logs.
34
62
  - `Share` inbox instructions are now explicit enough to reduce the chance that one agent re-delegates the task back into the collaboration graph.
63
+ - `Live` Mini App now survives a normal gateway restart much better:
64
+ - short `502/503` periods recover through polling
65
+ - lost in-process WebApp sessions (`401/403`) trigger automatic re-bootstrap
66
+ - reopening the Mini App is no longer required in the normal restart case
35
67
 
36
68
  ## 0.0.3
37
69
 
@@ -105,6 +105,11 @@ function normalizePrefixedPathname(pathname) {
105
105
  const stripped = pathname.slice(normalizedRootPrefix.length);
106
106
  return stripped.startsWith("/") ? stripped : `/${stripped || ""}`;
107
107
  }
108
+ function resolveLaunchModeOverride(value) {
109
+ return value === "default" || value === "expand" || value === "fullscreen"
110
+ ? value
111
+ : null;
112
+ }
108
113
  function normalizeBasePath(value) {
109
114
  const trimmed = value.trim().replace(/\/+$/u, "");
110
115
  if (!trimmed) {
@@ -152,9 +157,11 @@ function createMcpHttpHandler(runtime, input) {
152
157
  writeText(res, 405, "Method not allowed");
153
158
  return;
154
159
  }
160
+ const launchMode = resolveLaunchModeOverride(requestUrl.searchParams.get("launchMode")) ??
161
+ runtime.config.webapp.launchMode;
155
162
  writeHtml(res, 200, (0, assets_1.renderWebAppHtml)({
156
163
  basePath: publicWebAppBasePath,
157
- launchMode: runtime.config.webapp.launchMode,
164
+ launchMode,
158
165
  }));
159
166
  return;
160
167
  }
@@ -3,10 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createMcpServer = createMcpServer;
4
4
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
5
5
  const registry_1 = require("../../../shared/api/tool-registry/registry");
6
+ const versionHandshake_1 = require("../../../shared/lib/version/versionHandshake");
6
7
  function createMcpServer(tools) {
7
8
  const server = new mcp_js_1.McpServer({
8
9
  name: "tellymcp",
9
- version: "0.0.6",
10
+ version: (0, versionHandshake_1.getTellyMcpPackageVersion)(__dirname),
10
11
  });
11
12
  (0, registry_1.registerTools)(server, tools);
12
13
  return server;
@@ -100,6 +100,21 @@ body {
100
100
  border-color: rgba(87, 193, 255, 0.8);
101
101
  }
102
102
 
103
+ .btn.active {
104
+ border-color: rgba(87, 193, 255, 0.85);
105
+ background: linear-gradient(180deg, rgba(20, 54, 77, 0.98) 0%, rgba(14, 39, 57, 1) 100%);
106
+ color: #d9f3ff;
107
+ box-shadow: inset 0 0 0 1px rgba(87, 193, 255, 0.16);
108
+ }
109
+
110
+ .btn.toggle {
111
+ border-color: rgba(87, 193, 255, 0.42);
112
+ background: linear-gradient(180deg, rgba(31, 38, 52, 0.98) 0%, rgba(21, 27, 38, 1) 100%);
113
+ color: #cfe9ff;
114
+ box-shadow: inset 0 0 0 1px rgba(87, 193, 255, 0.08);
115
+ font-weight: 600;
116
+ }
117
+
103
118
  .statusbar {
104
119
  position: fixed;
105
120
  left: 0;
@@ -127,11 +142,32 @@ body {
127
142
  gap: 8px 16px;
128
143
  }
129
144
 
145
+ .status-toggle {
146
+ min-width: 72px;
147
+ padding: 4px 8px;
148
+ border-radius: 9px;
149
+ font-size: 12px;
150
+ line-height: 1.1;
151
+ }
152
+
130
153
  .session-label {
131
- color: var(--text);
154
+ display: inline-flex;
155
+ align-items: center;
156
+ min-height: 24px;
157
+ padding: 3px 8px;
158
+ border-radius: 999px;
159
+ border: 1px solid rgba(76, 217, 100, 0.42);
160
+ background: rgba(26, 42, 31, 0.92);
161
+ color: #d6ffe0;
132
162
  font-weight: 600;
133
163
  }
134
164
 
165
+ .session-label.error {
166
+ border-color: rgba(255, 116, 116, 0.5);
167
+ background: rgba(57, 23, 26, 0.94);
168
+ color: #ffd8d8;
169
+ }
170
+
135
171
  .ok {
136
172
  color: var(--success);
137
173
  }
@@ -146,6 +182,13 @@ body {
146
182
  font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
147
183
  }
148
184
 
185
+ .terminal.unwrap {
186
+ white-space: pre;
187
+ word-break: normal;
188
+ overflow-x: auto;
189
+ overflow-y: auto;
190
+ }
191
+
149
192
  .ansi-bold {
150
193
  font-weight: 700;
151
194
  }
@@ -194,6 +237,8 @@ const state = {
194
237
  timer: null,
195
238
  actionBusy: false,
196
239
  pollIntervalMs: 2000,
240
+ wrapEnabled: true,
241
+ recoverPromise: null,
197
242
  };
198
243
 
199
244
  const elements = {
@@ -201,6 +246,7 @@ const elements = {
201
246
  status: document.querySelector("[data-role=status]"),
202
247
  updated: document.querySelector("[data-role=updated]"),
203
248
  interrupt: document.querySelector("[data-role=interrupt]"),
249
+ wrap: document.querySelector("[data-role=wrap]"),
204
250
  type: document.querySelector("[data-role=type]"),
205
251
  esc: document.querySelector("[data-role=escape]"),
206
252
  tab: document.querySelector("[data-role=tab]"),
@@ -213,16 +259,80 @@ const elements = {
213
259
  };
214
260
 
215
261
  function setStatus(text, isError = false) {
216
- const sessionName = elements.session.textContent || "Live View";
217
- elements.status.textContent = text + " - " + sessionName;
262
+ elements.status.textContent = text;
218
263
  elements.status.classList.toggle("error", isError);
219
264
  elements.status.classList.toggle("ok", !isError);
265
+ elements.session.classList.toggle("error", isError);
266
+ elements.session.classList.toggle("ok", !isError);
220
267
  }
221
268
 
222
269
  function setUpdated(text) {
223
270
  elements.updated.textContent = text;
224
271
  }
225
272
 
273
+ function createHttpError(message, status) {
274
+ const error = new Error(message);
275
+ error.status = status;
276
+ return error;
277
+ }
278
+
279
+ function shouldRecoverWebAppSession(error) {
280
+ return (
281
+ error &&
282
+ typeof error === "object" &&
283
+ (error.status === 401 || error.status === 403)
284
+ );
285
+ }
286
+
287
+ function applyTmuxAvailability(hasTarget) {
288
+ elements.interrupt.disabled = !hasTarget;
289
+ elements.type.disabled = !hasTarget;
290
+ elements.esc.disabled = !hasTarget;
291
+ elements.tab.disabled = !hasTarget;
292
+ elements.slash.disabled = !hasTarget;
293
+ elements.del.disabled = !hasTarget;
294
+ elements.up.disabled = !hasTarget;
295
+ elements.down.disabled = !hasTarget;
296
+ elements.enter.disabled = !hasTarget;
297
+ }
298
+
299
+ function getWrapPreferenceKey() {
300
+ return "telegram-mcp-live-wrap";
301
+ }
302
+
303
+ function applyWrapMode(enabled) {
304
+ state.wrapEnabled = enabled;
305
+ elements.terminal.classList.toggle("unwrap", !enabled);
306
+ elements.wrap.classList.toggle("active", enabled);
307
+ elements.wrap.setAttribute("aria-pressed", enabled ? "true" : "false");
308
+ elements.wrap.textContent = enabled ? "Unwrap" : "Wrap";
309
+ elements.wrap.title = enabled
310
+ ? "Wrapping enabled. Tap to switch to horizontal scroll."
311
+ : "Horizontal scroll enabled. Tap to wrap lines.";
312
+ }
313
+
314
+ function loadWrapPreference() {
315
+ try {
316
+ const stored = window.localStorage.getItem(getWrapPreferenceKey());
317
+ if (stored === "off") {
318
+ applyWrapMode(false);
319
+ return;
320
+ }
321
+ } catch (_error) {
322
+ }
323
+
324
+ applyWrapMode(true);
325
+ }
326
+
327
+ function toggleWrapMode() {
328
+ const next = !state.wrapEnabled;
329
+ applyWrapMode(next);
330
+ try {
331
+ window.localStorage.setItem(getWrapPreferenceKey(), next ? "on" : "off");
332
+ } catch (_error) {
333
+ }
334
+ }
335
+
226
336
  function formatCapturedAt(value) {
227
337
  if (!value) {
228
338
  return "never";
@@ -234,12 +344,10 @@ function formatCapturedAt(value) {
234
344
  }
235
345
 
236
346
  return new Intl.DateTimeFormat(undefined, {
237
- year: "numeric",
238
- month: "2-digit",
239
- day: "2-digit",
240
347
  hour: "2-digit",
241
348
  minute: "2-digit",
242
349
  second: "2-digit",
350
+ hour12: false,
243
351
  }).format(date);
244
352
  }
245
353
 
@@ -561,6 +669,49 @@ async function bootstrap() {
561
669
  return response.json();
562
670
  }
563
671
 
672
+ function applyBootstrapPayload(bootstrapPayload) {
673
+ state.token = bootstrapPayload.token;
674
+ state.sessionId = bootstrapPayload.session_id;
675
+ state.pollIntervalMs = bootstrapPayload.poll_interval_ms || state.pollIntervalMs;
676
+ elements.session.textContent =
677
+ bootstrapPayload.session_label || bootstrapPayload.session_id;
678
+ elements.session.hidden = false;
679
+
680
+ const hasTmuxTarget = Boolean(bootstrapPayload.tmux_target);
681
+ applyTmuxAvailability(hasTmuxTarget);
682
+ setStatus(hasTmuxTarget ? "Live" : "No tmux target", !hasTmuxTarget);
683
+ }
684
+
685
+ async function recoverWebAppSession() {
686
+ if (state.recoverPromise) {
687
+ return state.recoverPromise;
688
+ }
689
+
690
+ state.recoverPromise = (async () => {
691
+ setStatus("Reconnecting...", true);
692
+ const bootstrapPayload = await bootstrap();
693
+ applyBootstrapPayload(bootstrapPayload);
694
+ return bootstrapPayload;
695
+ })().finally(() => {
696
+ state.recoverPromise = null;
697
+ });
698
+
699
+ return state.recoverPromise;
700
+ }
701
+
702
+ async function withRecoveredSession(operation) {
703
+ try {
704
+ return await operation();
705
+ } catch (error) {
706
+ if (!shouldRecoverWebAppSession(error)) {
707
+ throw error;
708
+ }
709
+
710
+ await recoverWebAppSession();
711
+ return operation();
712
+ }
713
+ }
714
+
564
715
  async function fetchVisibleBuffer() {
565
716
  const response = await fetch(config.basePath + "/api/view", {
566
717
  method: "GET",
@@ -571,7 +722,7 @@ async function fetchVisibleBuffer() {
571
722
 
572
723
  if (!response.ok) {
573
724
  const text = await response.text();
574
- throw new Error(text || "Failed to fetch visible buffer.");
725
+ throw createHttpError(text || "Failed to fetch visible buffer.", response.status);
575
726
  }
576
727
 
577
728
  return response.json();
@@ -584,21 +735,22 @@ async function sendAction(action) {
584
735
 
585
736
  state.actionBusy = true;
586
737
  try {
587
- const response = await fetch(config.basePath + "/api/action", {
588
- method: "POST",
589
- headers: {
590
- "content-type": "application/json",
591
- authorization: "Bearer " + state.token,
592
- },
593
- body: JSON.stringify({ action }),
594
- });
595
-
596
- if (!response.ok) {
597
- const text = await response.text();
598
- throw new Error(text || "Failed to send action.");
599
- }
738
+ await withRecoveredSession(async () => {
739
+ const response = await fetch(config.basePath + "/api/action", {
740
+ method: "POST",
741
+ headers: {
742
+ "content-type": "application/json",
743
+ authorization: "Bearer " + state.token,
744
+ },
745
+ body: JSON.stringify({ action }),
746
+ });
600
747
 
601
- await refreshVisibleBuffer();
748
+ if (!response.ok) {
749
+ const text = await response.text();
750
+ throw createHttpError(text || "Failed to send action.", response.status);
751
+ }
752
+ });
753
+ await withRecoveredSession(refreshVisibleBuffer);
602
754
  } finally {
603
755
  state.actionBusy = false;
604
756
  }
@@ -611,22 +763,26 @@ async function sendTextInput(text) {
611
763
 
612
764
  state.actionBusy = true;
613
765
  try {
614
- const response = await fetch(config.basePath + "/api/action", {
615
- method: "POST",
616
- headers: {
617
- "content-type": "application/json",
618
- authorization: "Bearer " + state.token,
619
- },
620
- body: JSON.stringify({ action: "text", text }),
621
- });
622
-
623
- if (!response.ok) {
624
- const text = await response.text();
625
- throw new Error(text || "Failed to send text.");
626
- }
766
+ await withRecoveredSession(async () => {
767
+ const response = await fetch(config.basePath + "/api/action", {
768
+ method: "POST",
769
+ headers: {
770
+ "content-type": "application/json",
771
+ authorization: "Bearer " + state.token,
772
+ },
773
+ body: JSON.stringify({ action: "text", text }),
774
+ });
627
775
 
776
+ if (!response.ok) {
777
+ const textResponse = await response.text();
778
+ throw createHttpError(
779
+ textResponse || "Failed to send text.",
780
+ response.status,
781
+ );
782
+ }
783
+ });
628
784
  setStatus("Text sent");
629
- await refreshVisibleBuffer();
785
+ await withRecoveredSession(refreshVisibleBuffer);
630
786
  } finally {
631
787
  state.actionBusy = false;
632
788
  }
@@ -646,9 +802,9 @@ function confirmInterrupt() {
646
802
  }
647
803
 
648
804
  async function refreshVisibleBuffer() {
649
- const payload = await fetchVisibleBuffer();
805
+ const payload = await withRecoveredSession(fetchVisibleBuffer);
650
806
  elements.terminal.innerHTML = renderAnsiToHtml(payload.content || "");
651
- setUpdated("Updated: " + formatCapturedAt(payload.captured_at));
807
+ setUpdated(formatCapturedAt(payload.captured_at));
652
808
  }
653
809
 
654
810
  function stopPolling() {
@@ -672,6 +828,10 @@ function startPolling() {
672
828
  }
673
829
 
674
830
  function bindUi() {
831
+ elements.wrap.addEventListener("click", () => {
832
+ toggleWrapMode();
833
+ });
834
+
675
835
  elements.interrupt.addEventListener("click", () => {
676
836
  confirmInterrupt()
677
837
  .then((ok) => {
@@ -748,32 +908,12 @@ async function applyLaunchMode() {
748
908
  async function main() {
749
909
  try {
750
910
  await applyLaunchMode();
911
+ loadWrapPreference();
751
912
  bindUi();
752
913
  setStatus("Authorizing Mini App...");
753
914
  const bootstrapPayload = await bootstrap();
754
- state.token = bootstrapPayload.token;
755
- state.sessionId = bootstrapPayload.session_id;
756
- state.pollIntervalMs = bootstrapPayload.poll_interval_ms || state.pollIntervalMs;
757
- elements.session.textContent =
758
- bootstrapPayload.session_label || bootstrapPayload.session_id;
759
-
760
- if (!bootstrapPayload.tmux_target) {
761
- elements.interrupt.disabled = true;
762
- elements.type.disabled = true;
763
- elements.esc.disabled = true;
764
- elements.tab.disabled = true;
765
- elements.slash.disabled = true;
766
- elements.del.disabled = true;
767
- elements.up.disabled = true;
768
- elements.down.disabled = true;
769
- elements.enter.disabled = true;
770
- setStatus("No tmux target", true);
771
- }
772
-
915
+ applyBootstrapPayload(bootstrapPayload);
773
916
  await refreshVisibleBuffer();
774
- if (bootstrapPayload.tmux_target) {
775
- setStatus("Connected");
776
- }
777
917
  startPolling();
778
918
  } catch (error) {
779
919
  const message = error instanceof Error ? error.message : String(error);
@@ -818,7 +958,8 @@ function renderWebAppHtml(input) {
818
958
  <span class="session-label" data-role="session" hidden>Live View</span>
819
959
  </div>
820
960
  <div class="status-right">
821
- <span data-role="updated">Updated: never</span>
961
+ <button class="btn toggle status-toggle active" data-role="wrap" type="button" aria-pressed="true">Wrap</button>
962
+ <span data-role="updated">never</span>
822
963
  </div>
823
964
  </div>
824
965
  </div>
@@ -1236,9 +1236,15 @@ class TelegramTransport {
1236
1236
  ...(input.project_name ? [`Проект: ${input.project_name}`] : []),
1237
1237
  `Сессия: ${input.source_session_label} -> ${input.target_session_label}`,
1238
1238
  "",
1239
- "Открой Live по кнопке ниже.",
1239
+ "Открой Live в нужном режиме по кнопкам ниже.",
1240
1240
  ].join("\n"), {
1241
- reply_markup: new grammy_1.InlineKeyboard().webApp("Open Live View", liveViewUrl),
1241
+ reply_markup: this.buildLiveViewLaunchKeyboard((mode) => this.buildLiveViewUrlForSessionTarget({
1242
+ targetSessionId: input.target_session_id,
1243
+ targetClientUuid: input.target_client_uuid,
1244
+ targetLocalSessionId: input.target_local_session_id,
1245
+ sourceClientUuid: input.source_client_uuid,
1246
+ launchMode: mode,
1247
+ })),
1242
1248
  }, {
1243
1249
  kind: "notification",
1244
1250
  sessionId: sourceSession.sessionId,
@@ -2533,6 +2539,12 @@ class TelegramTransport {
2533
2539
  : String(error),
2534
2540
  };
2535
2541
  if ((0, client_1.isTmuxUnavailableError)(error)) {
2542
+ void this.sessionStore.getSession(sessionId).then((session) => {
2543
+ if (!session?.tmuxTarget) {
2544
+ return;
2545
+ }
2546
+ return this.notifyTmuxUnavailable(sessionId, session, error);
2547
+ });
2536
2548
  this.logger.warn("tmux nudge skipped because tmux is unavailable", payload);
2537
2549
  return;
2538
2550
  }
@@ -2611,7 +2623,11 @@ class TelegramTransport {
2611
2623
  await this.sessionStore.setSession({
2612
2624
  ...session,
2613
2625
  tmuxTarget,
2614
- ...(tmuxTarget ? { tmuxPaneId: tmuxTarget } : {}),
2626
+ ...(tmuxTarget.startsWith("%")
2627
+ ? { tmuxPaneId: tmuxTarget }
2628
+ : session.tmuxPaneId
2629
+ ? { tmuxPaneId: session.tmuxPaneId }
2630
+ : {}),
2615
2631
  lastTmuxNudgeAt,
2616
2632
  });
2617
2633
  this.tmuxNudgeFailureNoticeAt.delete(sessionId);
@@ -2698,6 +2714,51 @@ class TelegramTransport {
2698
2714
  });
2699
2715
  }
2700
2716
  }
2717
+ async notifyTmuxUnavailable(sessionId, session, error) {
2718
+ const binding = await this.bindingStore.getBinding(sessionId);
2719
+ if (!binding) {
2720
+ return;
2721
+ }
2722
+ const nowMs = Date.now();
2723
+ const lastNoticeAt = this.tmuxNudgeFailureNoticeAt.get(sessionId);
2724
+ if (lastNoticeAt &&
2725
+ nowMs - lastNoticeAt < TMUX_NUDGE_FAILURE_NOTICE_COOLDOWN_MS) {
2726
+ return;
2727
+ }
2728
+ this.tmuxNudgeFailureNoticeAt.set(sessionId, nowMs);
2729
+ const sessionLabel = session.label ?? sessionId;
2730
+ const tmuxTarget = session.tmuxTarget ?? "unknown";
2731
+ const errorMessage = error instanceof Error ? error.message : String(error);
2732
+ try {
2733
+ await this.sendNotification({
2734
+ sessionId,
2735
+ sessionLabel: "TellyMCP",
2736
+ recipient: {
2737
+ telegramChatId: binding.telegramChatId,
2738
+ telegramUserId: binding.telegramUserId,
2739
+ },
2740
+ message: [
2741
+ `⚠️ Автоматический tmux nudge для сессии ${sessionLabel} пропущен.`,
2742
+ "tmux сейчас недоступен на этой машине.",
2743
+ `tmux target: ${tmuxTarget}`,
2744
+ `Ошибка: ${errorMessage}`,
2745
+ "Обычно это значит, что tmux session/server не запущен или недоступен по текущему socket path.",
2746
+ "Запусти tmux и агента внутри него, либо обнови/сними tmux target для этой сессии.",
2747
+ ].join("\n"),
2748
+ });
2749
+ }
2750
+ catch (notifyError) {
2751
+ this.logger.warn("Failed to deliver tmux unavailable notification", {
2752
+ sessionId,
2753
+ tmuxTarget,
2754
+ telegramChatId: binding.telegramChatId,
2755
+ telegramUserId: binding.telegramUserId,
2756
+ notifyError: notifyError instanceof Error
2757
+ ? (notifyError.stack ?? notifyError.message)
2758
+ : String(notifyError),
2759
+ });
2760
+ }
2761
+ }
2701
2762
  async sendTypingForSession(sessionId) {
2702
2763
  const binding = await this.bindingStore.getBinding(sessionId);
2703
2764
  if (!binding) {
@@ -4084,9 +4145,14 @@ class TelegramTransport {
4084
4145
  ? (0, relay_1.buildLiveRelaySessionId)(clientUuid, activeSessionId)
4085
4146
  : activeSessionId;
4086
4147
  const url = new URL(`${baseUrl}/live/${encodeURIComponent(liveSessionId)}`);
4148
+ url.searchParams.set("launchMode", this.config.webapp.launchMode);
4087
4149
  await ctx.answerCallbackQuery({ text: "Открываю Live View." });
4088
- const sent = await this.replyText(ctx, `🖥 Live: ${session?.label ?? activeSessionId}`, { kind: "menu", sessionId: activeSessionId }, {
4089
- reply_markup: new grammy_1.InlineKeyboard().webApp("Open Live View", url.toString()),
4150
+ const sent = await this.replyText(ctx, [`🖥 Live: ${session?.label ?? activeSessionId}`, "", "Выбери режим открытия:"].join("\n"), { kind: "menu", sessionId: activeSessionId }, {
4151
+ reply_markup: this.buildLiveViewLaunchKeyboard((mode) => {
4152
+ const modeUrl = new URL(url.toString());
4153
+ modeUrl.searchParams.set("launchMode", mode);
4154
+ return modeUrl.toString();
4155
+ }),
4090
4156
  });
4091
4157
  this.webAppLaunchRegistry.set(principal.telegramUserId, activeSessionId, this.config.webapp.initDataTtlSeconds, {
4092
4158
  telegramChatId: principal.telegramChatId,
@@ -4113,7 +4179,28 @@ class TelegramTransport {
4113
4179
  const liveSessionId = canUseRelay
4114
4180
  ? (0, relay_1.buildLiveRelaySessionId)(input.targetClientUuid, input.targetLocalSessionId, input.sourceClientUuid)
4115
4181
  : (input.targetLocalSessionId ?? input.targetSessionId);
4116
- return new URL(`${baseUrl}/live/${encodeURIComponent(liveSessionId)}`).toString();
4182
+ const url = new URL(`${baseUrl}/live/${encodeURIComponent(liveSessionId)}`);
4183
+ url.searchParams.set("launchMode", input.launchMode ?? this.config.webapp.launchMode);
4184
+ return url.toString();
4185
+ }
4186
+ buildLiveViewLaunchKeyboard(getUrl) {
4187
+ const keyboard = new grammy_1.InlineKeyboard();
4188
+ const modes = [
4189
+ { mode: "fullscreen", label: "Fullscreen" },
4190
+ { mode: "expand", label: "Expand" },
4191
+ { mode: "default", label: "Default" },
4192
+ ];
4193
+ for (const [index, { mode, label }] of modes.entries()) {
4194
+ const url = getUrl(mode);
4195
+ if (!url) {
4196
+ continue;
4197
+ }
4198
+ keyboard.webApp(label, url);
4199
+ if (index === 1) {
4200
+ keyboard.row();
4201
+ }
4202
+ }
4203
+ return keyboard;
4117
4204
  }
4118
4205
  clearPendingInteractionsForContext(ctx) {
4119
4206
  const principal = this.getPrincipalFromContext(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deadragdoll/tellymcp",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "TellyMCP - Telegram control plane for MCP-connected coding agents",
5
5
  "main": "dist/services/features/telegram-mcp/runtime.service.js",
6
6
  "bin": {