@tritard/waterbrother 0.15.10 → 0.15.11

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.md CHANGED
@@ -244,6 +244,8 @@ waterbrother channels list
244
244
  waterbrother channels status
245
245
  waterbrother channels show telegram
246
246
  waterbrother gateway status
247
+ waterbrother gateway pairings
248
+ waterbrother gateway pair telegram <user-id>
247
249
  waterbrother gateway run telegram
248
250
  waterbrother onboarding telegram
249
251
  waterbrother onboarding discord
@@ -260,6 +262,7 @@ Current Telegram limitation:
260
262
  - remote prompts run with `approval=never`
261
263
  - status, runtime inspection, and read-oriented prompts work best
262
264
  - mutating and approval-heavy work stay local until remote approvals land
265
+ - pairing is now explicit: first DM creates a pending request, then approve locally with `waterbrother gateway pair telegram <user-id>`
263
266
 
264
267
  ## Release flow
265
268
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tritard/waterbrother",
3
- "version": "0.15.10",
3
+ "version": "0.15.11",
4
4
  "description": "Waterbrother: bring-your-own-model coding CLI with local tools, sessions, operator modes, and approval controls",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -5,6 +5,8 @@ import { createToolRuntime } from "./tools.js";
5
5
  const BASE_SYSTEM_PROMPT = `You are Waterbrother, a coding assistant running in a local terminal.
6
6
  You can inspect and modify files with tools.
7
7
  When you use tools:
8
+ - Describe Waterbrother as a bring-your-own-model local coding CLI when you need to describe it.
9
+ - Do not call Waterbrother "Grok-powered", "xAI-powered", or otherwise provider-branded unless the user is explicitly asking about provider configuration or the active model.
8
10
  - Be precise and minimal.
9
11
  - Prefer read/search before write.
10
12
  - For large rewrites, design overhauls, or multi-section HTML/CSS edits, prefer reading the file once and then using write_file to replace the full file instead of chaining many replace_in_file calls.
package/src/channels.js CHANGED
@@ -178,16 +178,19 @@ export function buildChannelOnboardingPayload(serviceId) {
178
178
  prerequisites: [
179
179
  "Create a Telegram bot with BotFather",
180
180
  "Capture the bot token",
181
- "Choose manual pairing or an allowlist"
181
+ "Start with manual pairing so first-contact DMs create a pending approval request"
182
182
  ],
183
183
  commands: [
184
184
  "waterbrother config set-json channels '{\"telegram\":{\"enabled\":true,\"botToken\":\"YOUR_BOT_TOKEN\",\"pairingMode\":\"manual\",\"allowedUserIds\":[]}}'",
185
+ "waterbrother gateway pairings",
186
+ "waterbrother gateway pair telegram <user-id>",
185
187
  "waterbrother channels status",
186
188
  "waterbrother gateway status"
187
189
  ],
188
190
  notes: [
189
191
  "Start with direct messages only.",
190
- "Keep pairing manual until remote-control approvals are stable."
192
+ "Unknown DMs create a pending pairing request you approve locally.",
193
+ "Remote commands include /help, /status, /runtime, /sessions, /resume <id>, /new, and /clear."
191
194
  ]
192
195
  };
193
196
  }
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ import { getConfigPath, loadConfigLayers, resolveRuntimeConfig, saveConfig } fro
10
10
  import { createChatCompletion, createJsonCompletion, listModels } from "./model-client.js";
11
11
  import { buildChannelOnboardingPayload, getChannelStatuses, getGatewayStatus } from "./channels.js";
12
12
  import { runGatewayService } from "./gateway.js";
13
+ import { loadGatewayState, saveGatewayState } from "./gateway-state.js";
13
14
  import {
14
15
  getDefaultDesignModelForProvider,
15
16
  getDefaultModelForProvider,
@@ -125,6 +126,9 @@ const INTERACTIVE_COMMANDS = [
125
126
  { name: "/runtime-profile save <name>", description: "Save current runtime as a named profile" },
126
127
  { name: "/runtime-profile load <name>", description: "Load a named runtime profile" },
127
128
  { name: "/gateway", description: "Show messaging gateway readiness" },
129
+ { name: "/gateway pairings", description: "List pending Telegram pairing requests" },
130
+ { name: "/gateway pair <user-id>", description: "Approve a pending Telegram user id" },
131
+ { name: "/gateway unpair <user-id>", description: "Remove a paired Telegram user id" },
128
132
  { name: "/channels", description: "Show messaging channel readiness" },
129
133
  { name: "/agent", description: "Show active agent profile" },
130
134
  { name: "/agent <profile>", description: "Switch profile: coder|designer|reviewer|planner" },
@@ -236,6 +240,9 @@ Usage:
236
240
  waterbrother channels show <service>
237
241
  waterbrother gateway status
238
242
  waterbrother gateway run telegram
243
+ waterbrother gateway pairings [telegram]
244
+ waterbrother gateway pair [telegram] <user-id>
245
+ waterbrother gateway unpair [telegram] <user-id>
239
246
  waterbrother mcp list
240
247
  waterbrother commit [--push]
241
248
  waterbrother pr [--branch=<name>]
@@ -3217,6 +3224,83 @@ async function runGatewayCommand(positional, runtime, { cwd = process.cwd(), asJ
3217
3224
  return;
3218
3225
  }
3219
3226
 
3227
+ if (sub === "pairings") {
3228
+ const service = String(positional[2] || "telegram").trim().toLowerCase();
3229
+ if (service !== "telegram") {
3230
+ throw new Error("Usage: waterbrother gateway pairings [telegram]");
3231
+ }
3232
+ const state = await loadGatewayState(service);
3233
+ const pending = Object.values(state.pendingPairings || {});
3234
+ const paired = Array.isArray(runtime.channels?.telegram?.allowedUserIds)
3235
+ ? runtime.channels.telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean)
3236
+ : [];
3237
+ if (asJson) {
3238
+ printData({ service, pending, paired }, true);
3239
+ return;
3240
+ }
3241
+ console.log(`telegram paired users: ${paired.length ? paired.join(", ") : "none"}`);
3242
+ if (!pending.length) {
3243
+ console.log("telegram pending pairings: none");
3244
+ return;
3245
+ }
3246
+ console.log("telegram pending pairings:");
3247
+ for (const item of pending) {
3248
+ const username = String(item?.username || "").trim();
3249
+ const detail = username ? ` (${username})` : "";
3250
+ console.log(` ${item.userId}${detail}`);
3251
+ }
3252
+ return;
3253
+ }
3254
+
3255
+ if (sub === "pair" || sub === "unpair") {
3256
+ let service = String(positional[2] || "").trim().toLowerCase();
3257
+ let userId = String(positional[3] || "").trim();
3258
+ if (!userId) {
3259
+ userId = service;
3260
+ service = "telegram";
3261
+ }
3262
+ if (service !== "telegram" || !userId) {
3263
+ throw new Error(`Usage: waterbrother gateway ${sub} [telegram] <user-id>`);
3264
+ }
3265
+ const { userConfig, projectConfig } = await loadConfigLayers(cwd);
3266
+ const targetScope = projectConfig?.channels?.telegram ? "project" : "user";
3267
+ const targetConfig = targetScope === "project" ? { ...projectConfig } : { ...userConfig };
3268
+ const channels = targetConfig.channels && typeof targetConfig.channels === "object" ? { ...targetConfig.channels } : {};
3269
+ const telegram = channels.telegram && typeof channels.telegram === "object" ? { ...channels.telegram } : {};
3270
+ const allowed = new Set(Array.isArray(telegram.allowedUserIds) ? telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean) : []);
3271
+ if (sub === "pair") {
3272
+ allowed.add(userId);
3273
+ telegram.enabled = telegram.enabled !== false;
3274
+ if (!telegram.pairingMode) telegram.pairingMode = "manual";
3275
+ } else {
3276
+ allowed.delete(userId);
3277
+ }
3278
+ telegram.allowedUserIds = [...allowed];
3279
+ channels.telegram = telegram;
3280
+ targetConfig.channels = channels;
3281
+ await saveConfig(targetConfig, { scope: targetScope, cwd });
3282
+
3283
+ const state = await loadGatewayState(service);
3284
+ if (state.pendingPairings?.[userId]) {
3285
+ delete state.pendingPairings[userId];
3286
+ }
3287
+ if (sub === "unpair" && state.peers?.[userId]) {
3288
+ delete state.peers[userId];
3289
+ }
3290
+ await saveGatewayState(service, state);
3291
+
3292
+ const message =
3293
+ sub === "pair"
3294
+ ? `Paired Telegram user ${userId} via ${getConfigPath({ scope: targetScope, cwd })}`
3295
+ : `Unpaired Telegram user ${userId} via ${getConfigPath({ scope: targetScope, cwd })}`;
3296
+ if (asJson) {
3297
+ printData({ ok: true, service, userId, scope: targetScope, action: sub, allowedUserIds: telegram.allowedUserIds }, true);
3298
+ } else {
3299
+ console.log(message);
3300
+ }
3301
+ return;
3302
+ }
3303
+
3220
3304
  if (sub === "run") {
3221
3305
  const service = String(positional[2] || "").trim().toLowerCase();
3222
3306
  if (!service) {
@@ -3234,7 +3318,7 @@ async function runGatewayCommand(positional, runtime, { cwd = process.cwd(), asJ
3234
3318
  return;
3235
3319
  }
3236
3320
 
3237
- throw new Error("Usage: waterbrother gateway status|run <telegram>");
3321
+ throw new Error("Usage: waterbrother gateway status|run <telegram>|pairings [telegram]|pair [telegram] <user-id>|unpair [telegram] <user-id>");
3238
3322
  }
3239
3323
 
3240
3324
  async function runMcpCommand(positional, runtime, { cwd, asJson = false } = {}) {
@@ -6746,6 +6830,57 @@ Be concrete about surfaces — name actual pages/flows. Choose the best stack fo
6746
6830
  continue;
6747
6831
  }
6748
6832
 
6833
+ if (line === "/gateway pairings") {
6834
+ const state = await loadGatewayState("telegram");
6835
+ const pending = Object.values(state.pendingPairings || {});
6836
+ const paired = Array.isArray(context.runtime.channels?.telegram?.allowedUserIds)
6837
+ ? context.runtime.channels.telegram.allowedUserIds.map((value) => String(value || "").trim()).filter(Boolean)
6838
+ : [];
6839
+ console.log(`telegram paired users: ${paired.length ? paired.join(", ") : "none"}`);
6840
+ if (!pending.length) {
6841
+ console.log("telegram pending pairings: none");
6842
+ } else {
6843
+ console.log("telegram pending pairings:");
6844
+ for (const item of pending) {
6845
+ const username = String(item?.username || "").trim();
6846
+ console.log(` ${item.userId}${username ? ` (${username})` : ""}`);
6847
+ }
6848
+ }
6849
+ continue;
6850
+ }
6851
+
6852
+ if (line.startsWith("/gateway pair ")) {
6853
+ const userId = line.replace("/gateway pair", "").trim();
6854
+ if (!userId) {
6855
+ console.log("Usage: /gateway pair <user-id>");
6856
+ continue;
6857
+ }
6858
+ try {
6859
+ await runGatewayCommand(["gateway", "pair", "telegram", userId], context.runtime, { cwd: context.cwd, asJson: false });
6860
+ const { config } = await loadConfigLayers(context.cwd);
6861
+ context.runtime.channels = config.channels || context.runtime.channels;
6862
+ } catch (error) {
6863
+ console.log(`gateway pair failed: ${error instanceof Error ? error.message : String(error)}`);
6864
+ }
6865
+ continue;
6866
+ }
6867
+
6868
+ if (line.startsWith("/gateway unpair ")) {
6869
+ const userId = line.replace("/gateway unpair", "").trim();
6870
+ if (!userId) {
6871
+ console.log("Usage: /gateway unpair <user-id>");
6872
+ continue;
6873
+ }
6874
+ try {
6875
+ await runGatewayCommand(["gateway", "unpair", "telegram", userId], context.runtime, { cwd: context.cwd, asJson: false });
6876
+ const { config } = await loadConfigLayers(context.cwd);
6877
+ context.runtime.channels = config.channels || context.runtime.channels;
6878
+ } catch (error) {
6879
+ console.log(`gateway unpair failed: ${error instanceof Error ? error.message : String(error)}`);
6880
+ }
6881
+ continue;
6882
+ }
6883
+
6749
6884
  if (line === "/channels") {
6750
6885
  for (const status of getChannelStatuses(context.runtime)) {
6751
6886
  console.log(formatChannelStatusLine(status));
@@ -20,11 +20,12 @@ export async function loadGatewayState(serviceId) {
20
20
  const parsed = JSON.parse(raw);
21
21
  return {
22
22
  offset: Number.isFinite(Number(parsed?.offset)) ? Math.max(0, Math.floor(Number(parsed.offset))) : 0,
23
- peers: parsed?.peers && typeof parsed.peers === "object" ? parsed.peers : {}
23
+ peers: parsed?.peers && typeof parsed.peers === "object" ? parsed.peers : {},
24
+ pendingPairings: parsed?.pendingPairings && typeof parsed.pendingPairings === "object" ? parsed.pendingPairings : {}
24
25
  };
25
26
  } catch (error) {
26
27
  if (error?.code === "ENOENT") {
27
- return { offset: 0, peers: {} };
28
+ return { offset: 0, peers: {}, pendingPairings: {} };
28
29
  }
29
30
  throw error;
30
31
  }
@@ -35,7 +36,8 @@ export async function saveGatewayState(serviceId, state) {
35
36
  const filePath = gatewayStatePath(serviceId);
36
37
  const next = {
37
38
  offset: Number.isFinite(Number(state?.offset)) ? Math.max(0, Math.floor(Number(state.offset))) : 0,
38
- peers: state?.peers && typeof state.peers === "object" ? state.peers : {}
39
+ peers: state?.peers && typeof state.peers === "object" ? state.peers : {},
40
+ pendingPairings: state?.pendingPairings && typeof state.pendingPairings === "object" ? state.pendingPairings : {}
39
41
  };
40
42
  const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
41
43
  await fs.writeFile(tmpPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
package/src/gateway.js CHANGED
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { promisify } from "node:util";
6
- import { createSession, saveSession } from "./session-store.js";
6
+ import { createSession, listSessions, loadSession, saveSession } from "./session-store.js";
7
7
  import { loadGatewayState, saveGatewayState } from "./gateway-state.js";
8
8
  import { getGatewayStatus, getChannelSpec } from "./channels.js";
9
9
 
@@ -37,38 +37,94 @@ function stringifyError(error) {
37
37
  return error.stderr || error.stdout || error.message || String(error);
38
38
  }
39
39
 
40
+ function escapeTelegramHtml(text) {
41
+ return String(text || "")
42
+ .replace(/&/g, "&amp;")
43
+ .replace(/</g, "&lt;")
44
+ .replace(/>/g, "&gt;");
45
+ }
46
+
47
+ function renderTelegramMarkup(text) {
48
+ let value = escapeTelegramHtml(String(text || "").trim());
49
+ value = value.replace(/\*\*([^*\n][^*]*?)\*\*/g, "<b>$1</b>");
50
+ value = value.replace(/`([^`\n]+)`/g, "<code>$1</code>");
51
+ value = value.replace(/^\s*-\s+/gm, "• ");
52
+ return value;
53
+ }
54
+
40
55
  function buildRemoteHelp() {
41
56
  return [
42
- "Waterbrother Telegram control",
57
+ "<b>Waterbrother Telegram control</b>",
43
58
  "",
44
- "Commands:",
45
- "/help - show this help",
46
- "/status - show current remote session",
47
- "/runtime - show active provider/model/runtime state",
48
- "/new - start a fresh remote session",
59
+ "<b>Commands</b>",
60
+ "<code>/help</code> show this help",
61
+ "<code>/status</code> show the current linked remote session",
62
+ "<code>/runtime</code> show active provider/model/runtime state",
63
+ "<code>/sessions</code> list recent linked remote sessions",
64
+ "<code>/resume &lt;session-id&gt;</code> switch the linked remote session",
65
+ "<code>/new</code> start a fresh remote session",
66
+ "<code>/clear</code> clear the current remote conversation history",
49
67
  "",
50
68
  "Any other message is sent to the linked Waterbrother session.",
51
69
  "",
52
- "Current limitation:",
53
- "- remote prompts run with approval=never",
54
- "- read/status flows work best today",
55
- "- mutating actions stay local until remote approvals land"
70
+ "<b>Current limitation</b>",
71
+ " remote prompts run with <code>approval=never</code>",
72
+ " read/status flows work best today",
73
+ " mutating actions stay local until remote approvals land"
56
74
  ].join("\n");
57
75
  }
58
76
 
59
77
  function formatGatewaySessionStatus({ sessionId, userId, username, cwd, runtimeProfile, provider, model }) {
60
78
  const bits = [
61
- "Telegram remote session",
62
- `user=${userId}${username ? ` (${username})` : ""}`,
63
- `session=${sessionId}`,
64
- `provider=${provider}`,
65
- `model=${model}`,
66
- `runtimeProfile=${runtimeProfile || "none"}`,
67
- `cwd=${cwd}`
79
+ "<b>Telegram remote session</b>",
80
+ `user: <code>${escapeTelegramHtml(userId)}</code>${username ? ` (${escapeTelegramHtml(username)})` : ""}`,
81
+ `session: <code>${escapeTelegramHtml(sessionId)}</code>`,
82
+ `provider: <code>${escapeTelegramHtml(provider)}</code>`,
83
+ `model: <code>${escapeTelegramHtml(model)}</code>`,
84
+ `runtime profile: <code>${escapeTelegramHtml(runtimeProfile || "none")}</code>`,
85
+ `cwd: <code>${escapeTelegramHtml(cwd)}</code>`
68
86
  ];
69
87
  return bits.join("\n");
70
88
  }
71
89
 
90
+ function upsertSessionHistory(existing, sessionId) {
91
+ const next = Array.isArray(existing) ? existing.map((value) => String(value || "").trim()).filter(Boolean) : [];
92
+ const normalized = String(sessionId || "").trim();
93
+ if (!normalized) return next;
94
+ return [normalized, ...next.filter((value) => value !== normalized)].slice(0, 12);
95
+ }
96
+
97
+ function formatRuntimeStatus(status = {}) {
98
+ return [
99
+ "<b>Runtime status</b>",
100
+ `provider: <code>${escapeTelegramHtml(status.provider || "unknown")}</code>`,
101
+ `model: <code>${escapeTelegramHtml(status.model || "unknown")}</code>`,
102
+ `design model: <code>${escapeTelegramHtml(status.designModel || "unknown")}</code>`,
103
+ `base URL: <code>${escapeTelegramHtml(status.baseUrl || "")}</code>`,
104
+ `API key configured: <code>${status.apiKeyConfigured ? "yes" : "no"}</code>`
105
+ ].join("\n");
106
+ }
107
+
108
+ function formatSessionListMarkup(currentSessionId, sessions = []) {
109
+ if (!sessions.length) {
110
+ return "<b>Remote sessions</b>\nNo linked remote sessions yet.";
111
+ }
112
+
113
+ const lines = ["<b>Remote sessions</b>"];
114
+ for (const session of sessions) {
115
+ const id = String(session?.id || "").trim();
116
+ if (!id) continue;
117
+ const label = id === currentSessionId ? "current" : "recent";
118
+ const preview = String(session?.lastUserPreview || "").trim();
119
+ const updatedAt = String(session?.updatedAt || "").trim();
120
+ let line = `• <code>${escapeTelegramHtml(id)}</code> <i>(${label})</i>`;
121
+ if (updatedAt) line += `\n updated: <code>${escapeTelegramHtml(updatedAt)}</code>`;
122
+ if (preview) line += `\n last user: ${escapeTelegramHtml(preview).slice(0, 140)}`;
123
+ lines.push(line);
124
+ }
125
+ return lines.join("\n");
126
+ }
127
+
72
128
  class TelegramGateway {
73
129
  constructor({ runtime, cwd, io = console }) {
74
130
  this.runtime = runtime;
@@ -100,13 +156,15 @@ class TelegramGateway {
100
156
  return json.result;
101
157
  }
102
158
 
103
- async sendMessage(chatId, text, replyToMessageId = null) {
159
+ async sendMessage(chatId, text, replyToMessageId = null, { parseMode = "HTML" } = {}) {
104
160
  const chunks = chunkText(text);
105
161
  for (const chunk of chunks) {
106
162
  await this.callApi("sendMessage", {
107
163
  chat_id: chatId,
108
164
  text: chunk,
109
- reply_to_message_id: replyToMessageId || undefined
165
+ reply_to_message_id: replyToMessageId || undefined,
166
+ parse_mode: parseMode || undefined,
167
+ disable_web_page_preview: true
110
168
  });
111
169
  }
112
170
  }
@@ -129,17 +187,37 @@ class TelegramGateway {
129
187
  return allowed.includes(userId);
130
188
  }
131
189
 
190
+ async addPendingPairing(message) {
191
+ const userId = String(message?.from?.id || "").trim();
192
+ const username = [message?.from?.username, message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim();
193
+ this.state.pendingPairings[userId] = {
194
+ userId,
195
+ username,
196
+ chatId: String(message.chat.id),
197
+ firstSeenAt: this.state.pendingPairings[userId]?.firstSeenAt || new Date().toISOString(),
198
+ lastSeenAt: new Date().toISOString(),
199
+ lastMessageId: message.message_id
200
+ };
201
+ await saveGatewayState("telegram", this.state);
202
+ }
203
+
132
204
  async rejectUnpairedMessage(message) {
133
205
  const userId = String(message?.from?.id || "").trim();
206
+ await this.addPendingPairing(message);
134
207
  await this.sendMessage(
135
208
  message.chat.id,
136
209
  [
137
- "This Telegram account is not paired with Waterbrother yet.",
138
- `Your Telegram user id is: ${userId}`,
210
+ "<b>Pairing required</b>",
211
+ `This Telegram account is not paired with Waterbrother yet.`,
212
+ `Your Telegram user id is: <code>${escapeTelegramHtml(userId)}</code>`,
213
+ "",
214
+ "Approve it locally with:",
215
+ `<code>waterbrother gateway pair telegram ${escapeTelegramHtml(userId)}</code>`,
139
216
  "",
140
- "Add it to channels.telegram.allowedUserIds in ~/.waterbrother/config.json, then restart the gateway."
217
+ "Then keep the gateway running and send your message again."
141
218
  ].join("\n"),
142
- message.message_id
219
+ message.message_id,
220
+ { parseMode: "HTML" }
143
221
  );
144
222
  }
145
223
 
@@ -156,6 +234,7 @@ class TelegramGateway {
156
234
  ...existing,
157
235
  chatId: String(message.chat.id),
158
236
  username,
237
+ sessions: upsertSessionHistory(existing.sessions, existing.sessionId),
159
238
  lastSeenAt: new Date().toISOString(),
160
239
  lastMessageId: message.message_id
161
240
  };
@@ -178,10 +257,12 @@ class TelegramGateway {
178
257
  sessionId: session.id,
179
258
  chatId: String(message.chat.id),
180
259
  username,
260
+ sessions: upsertSessionHistory([], session.id),
181
261
  linkedAt: new Date().toISOString(),
182
262
  lastSeenAt: new Date().toISOString(),
183
263
  lastMessageId: message.message_id
184
264
  };
265
+ delete this.state.pendingPairings[userId];
185
266
  await saveGatewayState("telegram", this.state);
186
267
  return session.id;
187
268
  }
@@ -205,14 +286,57 @@ class TelegramGateway {
205
286
  sessionId: session.id,
206
287
  chatId: String(message.chat.id),
207
288
  username: [message?.from?.username, message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim(),
289
+ sessions: upsertSessionHistory(previous.sessions, session.id),
208
290
  linkedAt: new Date().toISOString(),
209
291
  lastSeenAt: new Date().toISOString(),
210
292
  lastMessageId: message.message_id
211
293
  };
294
+ delete this.state.pendingPairings[userId];
212
295
  await saveGatewayState("telegram", this.state);
213
296
  return session.id;
214
297
  }
215
298
 
299
+ async listPeerSessions(userId) {
300
+ const peer = this.getPeerState(userId);
301
+ const ids = Array.isArray(peer?.sessions) ? peer.sessions.map((value) => String(value || "").trim()).filter(Boolean) : [];
302
+ if (!ids.length && peer?.sessionId) ids.push(String(peer.sessionId));
303
+ if (!ids.length) return [];
304
+ const all = await listSessions(Math.max(20, ids.length + 4));
305
+ const byId = new Map(all.map((session) => [session.id, session]));
306
+ return ids.map((id) => byId.get(id) || { id }).filter(Boolean);
307
+ }
308
+
309
+ async resumePeerSession(message, sessionId) {
310
+ const userId = String(message?.from?.id || "").trim();
311
+ const normalizedId = String(sessionId || "").trim();
312
+ if (!normalizedId) throw new Error("Usage: /resume <session-id>");
313
+ const session = await loadSession(normalizedId);
314
+ const previous = this.getPeerState(userId) || {};
315
+ this.state.peers[userId] = {
316
+ ...previous,
317
+ sessionId: session.id,
318
+ chatId: String(message.chat.id),
319
+ username: [message?.from?.username, message?.from?.first_name, message?.from?.last_name].filter(Boolean).join(" ").trim(),
320
+ sessions: upsertSessionHistory(previous.sessions, session.id),
321
+ linkedAt: previous.linkedAt || new Date().toISOString(),
322
+ lastSeenAt: new Date().toISOString(),
323
+ lastMessageId: message.message_id
324
+ };
325
+ await saveGatewayState("telegram", this.state);
326
+ return session.id;
327
+ }
328
+
329
+ async clearRemoteConversation(sessionId) {
330
+ const session = await loadSession(sessionId);
331
+ session.messages = [];
332
+ session.runState = {
333
+ state: "done",
334
+ detail: "",
335
+ updatedAt: new Date().toISOString()
336
+ };
337
+ await saveSession(session);
338
+ }
339
+
216
340
  async runWaterbrother(args = []) {
217
341
  const childEnv = {
218
342
  ...process.env
@@ -280,9 +404,9 @@ class TelegramGateway {
280
404
  }
281
405
 
282
406
  const sessionId = await this.ensurePeerSession(message);
407
+ const peer = this.getPeerState(userId);
283
408
 
284
409
  if (text === "/status") {
285
- const peer = this.getPeerState(userId);
286
410
  await this.sendMessage(
287
411
  message.chat.id,
288
412
  formatGatewaySessionStatus({
@@ -301,16 +425,40 @@ class TelegramGateway {
301
425
 
302
426
  if (text === "/runtime") {
303
427
  const status = await this.runRuntimeStatus();
428
+ await this.sendMessage(message.chat.id, formatRuntimeStatus(status), message.message_id);
429
+ return;
430
+ }
431
+
432
+ if (text === "/sessions") {
433
+ const sessions = await this.listPeerSessions(userId);
434
+ await this.sendMessage(message.chat.id, formatSessionListMarkup(sessionId, sessions), message.message_id);
435
+ return;
436
+ }
437
+
438
+ if (text.startsWith("/resume")) {
439
+ const nextSessionId = text.replace("/resume", "").trim();
440
+ if (!nextSessionId) {
441
+ await this.sendMessage(
442
+ message.chat.id,
443
+ "Usage: <code>/resume &lt;session-id&gt;</code>\nUse <code>/sessions</code> to list recent remote session ids.",
444
+ message.message_id
445
+ );
446
+ return;
447
+ }
448
+ const resumedId = await this.resumePeerSession(message, nextSessionId);
304
449
  await this.sendMessage(
305
450
  message.chat.id,
306
- [
307
- "Runtime status",
308
- `provider=${status.provider}`,
309
- `model=${status.model}`,
310
- `designModel=${status.designModel}`,
311
- `baseUrl=${status.baseUrl}`,
312
- `apiKeyConfigured=${status.apiKeyConfigured ? "yes" : "no"}`
313
- ].join("\n"),
451
+ `Linked remote session: <code>${escapeTelegramHtml(resumedId)}</code>`,
452
+ message.message_id
453
+ );
454
+ return;
455
+ }
456
+
457
+ if (text === "/clear") {
458
+ await this.clearRemoteConversation(sessionId);
459
+ await this.sendMessage(
460
+ message.chat.id,
461
+ `Cleared conversation history for <code>${escapeTelegramHtml(sessionId)}</code>.`,
314
462
  message.message_id
315
463
  );
316
464
  return;
@@ -327,12 +475,13 @@ class TelegramGateway {
327
475
 
328
476
  try {
329
477
  const content = await this.runPrompt(sessionId, text);
330
- await this.sendMessage(message.chat.id, content, message.message_id);
478
+ await this.sendMessage(message.chat.id, renderTelegramMarkup(content), message.message_id);
331
479
  } catch (error) {
332
480
  await this.sendMessage(
333
481
  message.chat.id,
334
- `Remote run failed:\n${stringifyError(error).slice(0, 3000)}`,
335
- message.message_id
482
+ `<b>Remote run failed</b>\n${escapeTelegramHtml(stringifyError(error).slice(0, 3000))}`,
483
+ message.message_id,
484
+ { parseMode: "HTML" }
336
485
  );
337
486
  }
338
487
  }