opencode-plugin-teleprompt 0.1.0

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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -0
  3. package/dist/config.d.ts +2 -0
  4. package/dist/config.js +40 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/index.d.ts +7 -0
  7. package/dist/index.js +8 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/opencode/binding.d.ts +2 -0
  10. package/dist/opencode/binding.js +12 -0
  11. package/dist/opencode/binding.js.map +1 -0
  12. package/dist/opencode/events.d.ts +31 -0
  13. package/dist/opencode/events.js +74 -0
  14. package/dist/opencode/events.js.map +1 -0
  15. package/dist/opencode/permissions.d.ts +4 -0
  16. package/dist/opencode/permissions.js +66 -0
  17. package/dist/opencode/permissions.js.map +1 -0
  18. package/dist/opencode/submit.d.ts +7 -0
  19. package/dist/opencode/submit.js +14 -0
  20. package/dist/opencode/submit.js.map +1 -0
  21. package/dist/runtime/controller.d.ts +78 -0
  22. package/dist/runtime/controller.js +1180 -0
  23. package/dist/runtime/controller.js.map +1 -0
  24. package/dist/runtime/shutdown.d.ts +1 -0
  25. package/dist/runtime/shutdown.js +10 -0
  26. package/dist/runtime/shutdown.js.map +1 -0
  27. package/dist/state/lease.d.ts +12 -0
  28. package/dist/state/lease.js +56 -0
  29. package/dist/state/lease.js.map +1 -0
  30. package/dist/state/store.d.ts +9 -0
  31. package/dist/state/store.js +59 -0
  32. package/dist/state/store.js.map +1 -0
  33. package/dist/summary/format.d.ts +2 -0
  34. package/dist/summary/format.js +23 -0
  35. package/dist/summary/format.js.map +1 -0
  36. package/dist/telegram/api.d.ts +16 -0
  37. package/dist/telegram/api.js +78 -0
  38. package/dist/telegram/api.js.map +1 -0
  39. package/dist/telegram/parser.d.ts +3 -0
  40. package/dist/telegram/parser.js +267 -0
  41. package/dist/telegram/parser.js.map +1 -0
  42. package/dist/telegram/poller.d.ts +18 -0
  43. package/dist/telegram/poller.js +50 -0
  44. package/dist/telegram/poller.js.map +1 -0
  45. package/dist/tui-types.d.ts +55 -0
  46. package/dist/tui-types.js +2 -0
  47. package/dist/tui-types.js.map +1 -0
  48. package/dist/tui.d.ts +2 -0
  49. package/dist/tui.js +98 -0
  50. package/dist/tui.js.map +1 -0
  51. package/dist/types.d.ts +167 -0
  52. package/dist/types.js +2 -0
  53. package/dist/types.js.map +1 -0
  54. package/package.json +55 -0
@@ -0,0 +1,1180 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { getCurrentSessionID } from "../opencode/binding.js";
4
+ import { replyPermission, toPendingPermission, formatPermissionRequestMessage } from "../opencode/permissions.js";
5
+ import { createTelegramUserMessageID, submitPrompt } from "../opencode/submit.js";
6
+ import { formatSummaryForTelegram } from "../summary/format.js";
7
+ import { LeaseManager } from "../state/lease.js";
8
+ import { BridgeStore } from "../state/store.js";
9
+ import { TelegramApi } from "../telegram/api.js";
10
+ import { TelegramPoller } from "../telegram/poller.js";
11
+ import { SessionEventStream } from "../opencode/events.js";
12
+ import { createShutdownGuard } from "./shutdown.js";
13
+ const execFileAsync = promisify(execFile);
14
+ const DEFAULT_DEPS = {
15
+ now: () => Date.now(),
16
+ randomID: () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
17
+ };
18
+ function formatAgeMs(ms) {
19
+ if (ms < 1000)
20
+ return `${ms}ms`;
21
+ const seconds = Math.floor(ms / 1000);
22
+ if (seconds < 60)
23
+ return `${seconds}s`;
24
+ const minutes = Math.floor(seconds / 60);
25
+ const remSeconds = seconds % 60;
26
+ if (minutes < 60)
27
+ return `${minutes}m${remSeconds}s`;
28
+ const hours = Math.floor(minutes / 60);
29
+ const remMinutes = minutes % 60;
30
+ return `${hours}h${remMinutes}m`;
31
+ }
32
+ function formatGroupedModels(models, current) {
33
+ const grouped = new Map();
34
+ for (const model of models) {
35
+ const list = grouped.get(model.providerID) ?? [];
36
+ list.push(model);
37
+ grouped.set(model.providerID, list);
38
+ }
39
+ const providerIDs = [...grouped.keys()].sort((a, b) => a.localeCompare(b));
40
+ const lines = [];
41
+ for (const providerID of providerIDs) {
42
+ lines.push(`${providerID}:`);
43
+ const providerModels = grouped.get(providerID) ?? [];
44
+ providerModels.sort((a, b) => a.modelID.localeCompare(b.modelID));
45
+ for (const model of providerModels) {
46
+ const label = `${providerID}/${model.modelID}`;
47
+ const isCurrent = current &&
48
+ current.providerID === model.providerID &&
49
+ current.modelID === model.modelID;
50
+ const suffix = model.name && model.name !== model.modelID ? ` (${model.name})` : "";
51
+ lines.push(`- ${label}${suffix}${isCurrent ? " [current]" : ""}`);
52
+ }
53
+ lines.push("");
54
+ }
55
+ while (lines.length > 0 && lines[lines.length - 1] === "") {
56
+ lines.pop();
57
+ }
58
+ return lines.join("\n");
59
+ }
60
+ function preview(text, max = 90) {
61
+ const normalized = text.replace(/\s+/g, " ").trim();
62
+ if (normalized.length <= max)
63
+ return normalized;
64
+ return `${normalized.slice(0, max - 1)}…`;
65
+ }
66
+ function resolvePresetModel(preset, models) {
67
+ const scored = models
68
+ .map((model) => {
69
+ const key = `${model.providerID}/${model.modelID} ${model.name ?? ""}`.toLowerCase();
70
+ let score = 0;
71
+ if (preset === "fast") {
72
+ if (key.includes("mini"))
73
+ score += 60;
74
+ if (key.includes("haiku"))
75
+ score += 50;
76
+ if (key.includes("flash"))
77
+ score += 50;
78
+ if (key.includes("nano"))
79
+ score += 40;
80
+ if (key.includes("fast"))
81
+ score += 40;
82
+ }
83
+ else if (preset === "smart") {
84
+ if (key.includes("sonnet"))
85
+ score += 60;
86
+ if (key.includes("gpt-5"))
87
+ score += 55;
88
+ if (key.includes("claude-3.7"))
89
+ score += 50;
90
+ if (key.includes("medium"))
91
+ score += 35;
92
+ }
93
+ else {
94
+ if (key.includes("opus"))
95
+ score += 70;
96
+ if (key.includes("max"))
97
+ score += 60;
98
+ if (key.includes("gpt-5.4"))
99
+ score += 55;
100
+ if (key.includes("pro"))
101
+ score += 40;
102
+ if (key.includes("reason"))
103
+ score += 35;
104
+ }
105
+ // Prefer stable families over unknown names when preset tie happens.
106
+ if (key.includes("gpt") || key.includes("claude") || key.includes("gemini")) {
107
+ score += 3;
108
+ }
109
+ return { model, score };
110
+ })
111
+ .sort((a, b) => b.score - a.score);
112
+ if (scored.length === 0)
113
+ return undefined;
114
+ if (scored[0].score <= 0)
115
+ return undefined;
116
+ return scored[0].model;
117
+ }
118
+ export class BridgeController {
119
+ api;
120
+ config;
121
+ deps;
122
+ instanceID;
123
+ client;
124
+ telegram;
125
+ store;
126
+ lease;
127
+ data;
128
+ heartbeatTimer;
129
+ pollAbort;
130
+ pollTask;
131
+ eventStream;
132
+ eventRestartTimer;
133
+ shutdownOnce = createShutdownGuard();
134
+ processingQueue = false;
135
+ lastEscAt = 0;
136
+ sessionCredentials;
137
+ constructor(api, config, storePath, deps) {
138
+ this.api = api;
139
+ this.config = config;
140
+ this.deps = { ...DEFAULT_DEPS, ...deps };
141
+ this.instanceID = this.deps.randomID();
142
+ this.client = api.client;
143
+ this.telegram = new TelegramApi(config.botToken || "__missing_token__");
144
+ this.store = new BridgeStore(storePath, config.channelID ?? "");
145
+ this.lease = new LeaseManager(this.instanceID, this.deps.now, config.leaseTtlMs);
146
+ }
147
+ async init() {
148
+ this.data = await this.store.load();
149
+ }
150
+ async bindCurrent(credentials) {
151
+ const resolvedCredentials = this.resolveCredentials(credentials);
152
+ this.sessionCredentials = resolvedCredentials;
153
+ this.telegram = new TelegramApi(resolvedCredentials.botToken);
154
+ this.config.botToken = resolvedCredentials.botToken;
155
+ this.config.channelID = resolvedCredentials.channelID;
156
+ this.store.setChannelID(resolvedCredentials.channelID);
157
+ const sessionID = getCurrentSessionID(this.api);
158
+ const state = await this.syncState();
159
+ const claimed = this.lease.claim(state);
160
+ claimed.bound.sessionID = sessionID;
161
+ claimed.bound.status = "online";
162
+ claimed.bound.model = undefined;
163
+ claimed.bound.channelID = resolvedCredentials.channelID;
164
+ await this.persist(claimed);
165
+ this.startHeartbeat();
166
+ await this.startEventStream(sessionID);
167
+ this.startPolling();
168
+ if (this.config.onlineNotice) {
169
+ await this.getTelegramApi().sendMessage(this.requireChannelID(), `OpenCode Telegram bridge online.\nsession_id: ${sessionID}`);
170
+ }
171
+ return sessionID;
172
+ }
173
+ async unbind() {
174
+ const state = await this.syncState();
175
+ if (state.lease && !this.lease.isOwner(state)) {
176
+ throw new Error(`Cannot unbind: bridge is owned by ${state.lease.ownerInstanceID}.`);
177
+ }
178
+ await this.stopRuntime(true);
179
+ const latest = await this.syncState();
180
+ const released = this.lease.release({
181
+ ...latest,
182
+ bound: {
183
+ ...latest.bound,
184
+ status: "offline",
185
+ sessionID: undefined,
186
+ },
187
+ activePrompt: undefined,
188
+ promptQueue: [],
189
+ pendingPermissions: {},
190
+ });
191
+ await this.persist(released);
192
+ }
193
+ async statusLine() {
194
+ const state = await this.syncState();
195
+ const owner = state.lease?.ownerInstanceID ?? "none";
196
+ const session = state.bound.sessionID ?? "none";
197
+ const model = state.bound.model
198
+ ? `${state.bound.model.providerID}/${state.bound.model.modelID}`
199
+ : "default";
200
+ const now = this.deps.now();
201
+ const heartbeatFreshness = state.lease
202
+ ? formatAgeMs(Math.max(0, now - state.lease.ownerHeartbeatAt))
203
+ : "n/a";
204
+ const activeElapsed = state.activePrompt
205
+ ? formatAgeMs(Math.max(0, now - (state.activePrompt.startedAt ?? state.activePrompt.createdAt)))
206
+ : "none";
207
+ const cwd = process.cwd();
208
+ const branch = await this.getGitBranch();
209
+ const lines = [
210
+ `status=${state.bound.status}`,
211
+ `session=${session}`,
212
+ `owner=${owner}`,
213
+ `cwd=${cwd}`,
214
+ `branch=${branch ?? "n/a"}`,
215
+ `model=${model}`,
216
+ `active_elapsed=${activeElapsed}`,
217
+ `queue=${state.promptQueue.length}`,
218
+ `pending_permissions=${Object.keys(state.pendingPermissions).length}`,
219
+ `heartbeat_age=${heartbeatFreshness}`,
220
+ ];
221
+ return lines.join("\n");
222
+ }
223
+ async handleTelegramCommand(command) {
224
+ const state = await this.syncState();
225
+ const isOwner = this.lease.isOwner(state);
226
+ const alwaysAllowed = new Set(["status", "who", "health", "reclaim"]);
227
+ if (!isOwner && !alwaysAllowed.has(command.command.kind)) {
228
+ await this.telegram.sendMessage(this.config.channelID, "Bridge is currently owned by another OpenCode instance. Use /tp:reclaim first.", { replyToMessageID: command.messageID });
229
+ return;
230
+ }
231
+ if (command.command.kind === "status") {
232
+ await this.telegram.sendMessage(this.config.channelID, await this.statusLine(), { replyToMessageID: command.messageID });
233
+ return;
234
+ }
235
+ if (command.command.kind === "disconnect") {
236
+ const state = await this.requireState();
237
+ if (state.bound.status !== "online") {
238
+ await this.telegram.sendMessage(this.config.channelID, "Bridge already offline.", { replyToMessageID: command.messageID });
239
+ return;
240
+ }
241
+ await this.unbind();
242
+ await this.telegram.sendMessage(this.config.channelID, "Teleprompt disconnected from Telegram (/tp:dc).", { replyToMessageID: command.messageID });
243
+ return;
244
+ }
245
+ if (command.command.kind === "interrupt") {
246
+ await this.handleInterrupt(command.messageID);
247
+ return;
248
+ }
249
+ if (command.command.kind === "queue") {
250
+ await this.handleQueue(command.messageID);
251
+ return;
252
+ }
253
+ if (command.command.kind === "cancel") {
254
+ await this.handleCancel(command.command.target, command.messageID);
255
+ return;
256
+ }
257
+ if (command.command.kind === "retry") {
258
+ await this.handleRetry(command.updateID, command.messageID, command.channelID);
259
+ return;
260
+ }
261
+ if (command.command.kind === "context") {
262
+ await this.handleContext(command.messageID);
263
+ return;
264
+ }
265
+ if (command.command.kind === "compact") {
266
+ await this.handleCompact(command.messageID);
267
+ return;
268
+ }
269
+ if (command.command.kind === "newsession") {
270
+ await this.handleNewSession(command.messageID);
271
+ return;
272
+ }
273
+ if (command.command.kind === "reset-context") {
274
+ await this.handleResetContext(command.messageID);
275
+ return;
276
+ }
277
+ if (command.command.kind === "who") {
278
+ await this.handleWho(command.messageID);
279
+ return;
280
+ }
281
+ if (command.command.kind === "health") {
282
+ await this.handleHealth(command.messageID);
283
+ return;
284
+ }
285
+ if (command.command.kind === "reclaim") {
286
+ await this.handleReclaim(command.messageID);
287
+ return;
288
+ }
289
+ if (command.command.kind === "history") {
290
+ await this.handleHistory(command.messageID);
291
+ return;
292
+ }
293
+ if (command.command.kind === "last-error") {
294
+ await this.handleLastError(command.messageID);
295
+ return;
296
+ }
297
+ if (command.command.kind === "model") {
298
+ await this.handleModelCommand(command.command.target, command.command.preset, command.messageID);
299
+ return;
300
+ }
301
+ if (command.command.kind === "permission") {
302
+ await this.handlePermissionReply(command.command.requestID, command.command.action, command.messageID);
303
+ return;
304
+ }
305
+ if (state.bound.status !== "online" || !state.bound.sessionID) {
306
+ await this.telegram.sendMessage(this.config.channelID, "Bridge is offline. Run /tp:start in OpenCode first.", { replyToMessageID: command.messageID });
307
+ return;
308
+ }
309
+ await this.telegram.sendMessage(this.config.channelID, "accepted", { replyToMessageID: command.messageID });
310
+ const job = {
311
+ telegramUpdateID: command.updateID,
312
+ telegramMessageID: command.messageID,
313
+ telegramChannelID: command.channelID,
314
+ prompt: command.command.prompt,
315
+ userMessageID: createTelegramUserMessageID(command.updateID),
316
+ createdAt: this.deps.now(),
317
+ };
318
+ const next = {
319
+ ...state,
320
+ promptQueue: [...state.promptQueue, job],
321
+ recentPrompts: [
322
+ ...state.recentPrompts,
323
+ {
324
+ jobID: job.userMessageID,
325
+ prompt: job.prompt,
326
+ at: job.createdAt,
327
+ },
328
+ ].slice(-20),
329
+ };
330
+ await this.persist(next);
331
+ const queuePosition = next.promptQueue.length + (next.activePrompt ? 1 : 0);
332
+ if (queuePosition > 1) {
333
+ await this.telegram.sendMessage(this.config.channelID, `queued (${queuePosition})`, { replyToMessageID: command.messageID });
334
+ }
335
+ await this.processPromptQueue();
336
+ }
337
+ async shutdown() {
338
+ await this.shutdownOnce(async () => {
339
+ await this.stopRuntime(false);
340
+ const state = await this.syncState();
341
+ if (!this.lease.isOwner(state))
342
+ return;
343
+ const next = this.lease.release({
344
+ ...state,
345
+ bound: {
346
+ ...state.bound,
347
+ status: "offline",
348
+ },
349
+ });
350
+ await this.persist(next);
351
+ if (this.config.offlineNotice && next.bound.sessionID && this.config.channelID) {
352
+ await this.telegram.sendMessage(this.config.channelID, `OpenCode Telegram bridge offline.\nsession_id: ${next.bound.sessionID}`);
353
+ }
354
+ this.sessionCredentials = undefined;
355
+ });
356
+ }
357
+ async handleLocalTuiCommand(command) {
358
+ const startWithCredentials = this.parseStartWithCredentials(command);
359
+ if (startWithCredentials) {
360
+ const sessionID = await this.bindCurrent(startWithCredentials);
361
+ this.api.ui.toast({
362
+ variant: "success",
363
+ message: `Telegram bridge bound to session ${sessionID}`,
364
+ });
365
+ return;
366
+ }
367
+ const credentialOnly = this.parseCredentialCommand(command);
368
+ if (credentialOnly) {
369
+ this.sessionCredentials = credentialOnly;
370
+ this.api.ui.toast({
371
+ variant: "success",
372
+ message: "Teleprompt credentials set for this runtime. Run /tp:start.",
373
+ });
374
+ return;
375
+ }
376
+ const state = await this.syncState();
377
+ if (state.bound.status !== "online" || !state.bound.sessionID)
378
+ return;
379
+ if (command !== "session.interrupt" && command !== "prompt.clear")
380
+ return;
381
+ if (!this.lease.isOwner(state)) {
382
+ this.api.ui.toast({
383
+ variant: "warning",
384
+ message: "Teleprompt is owned by another OpenCode instance.",
385
+ });
386
+ return;
387
+ }
388
+ const now = this.deps.now();
389
+ if (now - this.lastEscAt <= 1400) {
390
+ await this.unbind();
391
+ this.api.ui.toast({
392
+ variant: "success",
393
+ message: "Teleprompt disconnected (double ESC). Local input unlocked.",
394
+ });
395
+ return;
396
+ }
397
+ this.lastEscAt = now;
398
+ this.api.ui.toast({
399
+ variant: "info",
400
+ message: "Teleprompt active. Press ESC again to disconnect.",
401
+ });
402
+ }
403
+ async processPromptQueue() {
404
+ if (this.processingQueue)
405
+ return;
406
+ this.processingQueue = true;
407
+ try {
408
+ while (true) {
409
+ const state = await this.syncState();
410
+ if (!this.lease.isOwner(state))
411
+ return;
412
+ if (state.activePrompt || state.promptQueue.length === 0)
413
+ return;
414
+ const job = state.promptQueue[0];
415
+ if (!state.bound.sessionID)
416
+ return;
417
+ const startedAt = this.deps.now();
418
+ const activeJob = {
419
+ ...job,
420
+ startedAt,
421
+ };
422
+ const next = {
423
+ ...state,
424
+ activePrompt: activeJob,
425
+ promptQueue: state.promptQueue.slice(1),
426
+ };
427
+ await this.persist(next);
428
+ await this.telegram.sendMessage(this.config.channelID, "running", { replyToMessageID: activeJob.telegramMessageID });
429
+ try {
430
+ await submitPrompt(this.client, state.bound.sessionID, job.prompt, job.telegramUpdateID, state.bound.model);
431
+ }
432
+ catch (error) {
433
+ const latest = await this.requireState();
434
+ if (latest.activePrompt?.userMessageID === activeJob.userMessageID) {
435
+ await this.persist(this.appendPromptHistory({
436
+ ...latest,
437
+ activePrompt: undefined,
438
+ }, {
439
+ jobID: activeJob.userMessageID,
440
+ prompt: activeJob.prompt,
441
+ summary: String(error),
442
+ changedFiles: [],
443
+ status: "failed",
444
+ at: this.deps.now(),
445
+ }));
446
+ }
447
+ await this.telegram.sendMessage(this.config.channelID, `failed: ${String(error)}`, { replyToMessageID: activeJob.telegramMessageID });
448
+ }
449
+ }
450
+ }
451
+ finally {
452
+ this.processingQueue = false;
453
+ }
454
+ }
455
+ async onAssistantCompleted(sessionID, assistantMessageID, parentUserMessageID) {
456
+ const state = await this.syncState();
457
+ if (!this.lease.isOwner(state))
458
+ return;
459
+ if (!state.activePrompt)
460
+ return;
461
+ if (state.activePrompt.userMessageID !== parentUserMessageID)
462
+ return;
463
+ if (state.bound.sessionID !== sessionID)
464
+ return;
465
+ const summary = await this.buildSummary(sessionID, assistantMessageID, state.activePrompt.userMessageID);
466
+ const completedAt = this.deps.now();
467
+ const elapsed = completedAt - (state.activePrompt.startedAt ?? state.activePrompt.createdAt);
468
+ const message = formatSummaryForTelegram(summary, this.config.summaryMaxChars);
469
+ await this.telegram.sendMessage(this.config.channelID, `completed in ${formatAgeMs(Math.max(0, elapsed))}`, { replyToMessageID: state.activePrompt.telegramMessageID });
470
+ await this.telegram.sendMessage(this.config.channelID, message, { replyToMessageID: state.activePrompt.telegramMessageID });
471
+ await this.persist(this.appendPromptHistory({
472
+ ...state,
473
+ activePrompt: undefined,
474
+ }, {
475
+ jobID: state.activePrompt.userMessageID,
476
+ prompt: state.activePrompt.prompt,
477
+ summary: summary.text,
478
+ changedFiles: summary.changedFiles,
479
+ status: "completed",
480
+ at: completedAt,
481
+ }));
482
+ await this.processPromptQueue();
483
+ }
484
+ async onUserMessage(sessionID, userMessageID) {
485
+ const state = await this.syncState();
486
+ if (!this.lease.isOwner(state))
487
+ return;
488
+ if (state.bound.status !== "online")
489
+ return;
490
+ if (state.bound.sessionID !== sessionID)
491
+ return;
492
+ if (userMessageID.startsWith("tg-"))
493
+ return;
494
+ try {
495
+ await this.client.session.abort({ sessionID });
496
+ }
497
+ catch { }
498
+ try {
499
+ await this.client.session.deleteMessage({ sessionID, messageID: userMessageID });
500
+ }
501
+ catch { }
502
+ this.api.ui.toast({
503
+ variant: "warning",
504
+ message: "Teleprompt is active. Local prompt input is locked. Press ESC twice to disconnect.",
505
+ });
506
+ }
507
+ async onPermissionAsked(input) {
508
+ const state = await this.syncState();
509
+ if (!this.lease.isOwner(state))
510
+ return;
511
+ if (state.bound.sessionID !== input.sessionID)
512
+ return;
513
+ const pending = toPendingPermission(input);
514
+ const next = {
515
+ ...state,
516
+ pendingPermissions: {
517
+ ...state.pendingPermissions,
518
+ [pending.requestID]: pending,
519
+ },
520
+ };
521
+ await this.persist(next);
522
+ const activeReplyTo = state.activePrompt?.telegramMessageID;
523
+ await this.telegram.sendMessage(this.config.channelID, "waiting-permission", activeReplyTo ? { replyToMessageID: activeReplyTo } : undefined);
524
+ await this.telegram.sendMessage(this.config.channelID, formatPermissionRequestMessage(this.config.prefix, pending), activeReplyTo ? { replyToMessageID: activeReplyTo } : undefined);
525
+ }
526
+ async handlePermissionReply(requestID, action, replyToMessageID) {
527
+ const state = await this.syncState();
528
+ if (!this.lease.isOwner(state)) {
529
+ await this.telegram.sendMessage(this.config.channelID, "Cannot apply permission reply: this instance is not the current bridge owner.", replyToMessageID ? { replyToMessageID } : undefined);
530
+ return;
531
+ }
532
+ const pending = state.pendingPermissions[requestID];
533
+ if (!pending) {
534
+ await this.telegram.sendMessage(this.config.channelID, `Permission request not found: ${requestID}`, replyToMessageID ? { replyToMessageID } : undefined);
535
+ return;
536
+ }
537
+ await replyPermission(this.client, requestID, action);
538
+ const { [requestID]: _removed, ...rest } = state.pendingPermissions;
539
+ await this.persist({
540
+ ...state,
541
+ pendingPermissions: rest,
542
+ });
543
+ await this.telegram.sendMessage(this.config.channelID, `Permission ${requestID} -> ${action}`, replyToMessageID ? { replyToMessageID } : undefined);
544
+ }
545
+ async handleModelCommand(target, preset, replyToMessageID) {
546
+ const state = await this.requireState();
547
+ if (state.bound.status !== "online" || !state.bound.sessionID) {
548
+ await this.telegram.sendMessage(this.config.channelID, "Bridge is offline. Run /tp:start in OpenCode first.", replyToMessageID ? { replyToMessageID } : undefined);
549
+ return;
550
+ }
551
+ const available = await this.fetchAvailableModels();
552
+ if (available.length === 0) {
553
+ await this.telegram.sendMessage(this.config.channelID, "No available models found from OpenCode providers.", replyToMessageID ? { replyToMessageID } : undefined);
554
+ return;
555
+ }
556
+ if (!target && !preset) {
557
+ const current = state.bound.model
558
+ ? `${state.bound.model.providerID}/${state.bound.model.modelID}`
559
+ : "default (OpenCode session default)";
560
+ const grouped = formatGroupedModels(available, state.bound.model);
561
+ await this.telegram.sendMessage(this.config.channelID, `Current model: ${current}\n\nValid models:\n${grouped}\n\nSet with: /tp:model <provider>/<model>\nPresets: /tp:model fast | /tp:model smart | /tp:model max`, replyToMessageID ? { replyToMessageID } : undefined);
562
+ return;
563
+ }
564
+ let found;
565
+ if (preset) {
566
+ found = resolvePresetModel(preset, available);
567
+ if (!found) {
568
+ await this.telegram.sendMessage(this.config.channelID, `Could not resolve preset '${preset}'. Use /tp:model to list explicit options.`, replyToMessageID ? { replyToMessageID } : undefined);
569
+ return;
570
+ }
571
+ }
572
+ else {
573
+ found = available.find((item) => item.providerID.toLowerCase() === target.providerID.toLowerCase() &&
574
+ item.modelID.toLowerCase() === target.modelID.toLowerCase());
575
+ }
576
+ if (!found) {
577
+ await this.telegram.sendMessage(this.config.channelID, `Invalid model: ${target?.providerID}/${target?.modelID}\nUse /tp:model to list valid options.`, replyToMessageID ? { replyToMessageID } : undefined);
578
+ return;
579
+ }
580
+ await this.persist({
581
+ ...state,
582
+ bound: {
583
+ ...state.bound,
584
+ model: {
585
+ providerID: found.providerID,
586
+ modelID: found.modelID,
587
+ },
588
+ },
589
+ });
590
+ await this.telegram.sendMessage(this.config.channelID, preset
591
+ ? `Model preset '${preset}' selected for session ${state.bound.sessionID}: ${found.providerID}/${found.modelID}`
592
+ : `Model updated for session ${state.bound.sessionID}: ${found.providerID}/${found.modelID}`, replyToMessageID ? { replyToMessageID } : undefined);
593
+ }
594
+ async fetchAvailableModels() {
595
+ try {
596
+ const response = await this.client.config.providers({}, { responseStyle: "data", throwOnError: true });
597
+ const providers = response?.providers;
598
+ if (!Array.isArray(providers))
599
+ return [];
600
+ const output = [];
601
+ for (const provider of providers) {
602
+ const providerID = provider.id;
603
+ if (!providerID || !provider.models)
604
+ continue;
605
+ for (const [key, value] of Object.entries(provider.models)) {
606
+ const modelID = value?.id || key;
607
+ output.push({
608
+ providerID,
609
+ modelID,
610
+ name: value?.name,
611
+ });
612
+ }
613
+ }
614
+ output.sort((a, b) => {
615
+ const left = `${a.providerID}/${a.modelID}`.toLowerCase();
616
+ const right = `${b.providerID}/${b.modelID}`.toLowerCase();
617
+ return left.localeCompare(right);
618
+ });
619
+ return output;
620
+ }
621
+ catch {
622
+ return [];
623
+ }
624
+ }
625
+ async buildSummary(sessionID, assistantMessageID, userMessageID) {
626
+ const parts = this.api.state.part(assistantMessageID);
627
+ const text = parts
628
+ .filter((part) => {
629
+ return part.type === "text" && typeof part.text === "string";
630
+ })
631
+ .map((part) => part.text)
632
+ .join("")
633
+ .trim();
634
+ let changedFiles = [];
635
+ try {
636
+ const diffResponse = await this.client.session.diff({
637
+ sessionID,
638
+ messageID: userMessageID,
639
+ }, { responseStyle: "data", throwOnError: true });
640
+ const diff = diffResponse.diff;
641
+ changedFiles = (diff || []).map((item) => item.file);
642
+ }
643
+ catch {
644
+ changedFiles = [];
645
+ }
646
+ return {
647
+ text: text || "(assistant completed with no text output)",
648
+ changedFiles,
649
+ };
650
+ }
651
+ async handleQueue(replyToMessageID) {
652
+ const state = await this.requireState();
653
+ const now = this.deps.now();
654
+ const lines = [];
655
+ lines.push("Queue status:");
656
+ if (state.activePrompt) {
657
+ lines.push(`active: ${state.activePrompt.userMessageID} (${formatAgeMs(now - (state.activePrompt.startedAt ?? state.activePrompt.createdAt))})`);
658
+ lines.push(`active_prompt: ${preview(state.activePrompt.prompt)}`);
659
+ }
660
+ else {
661
+ lines.push("active: none");
662
+ }
663
+ lines.push(`queued: ${state.promptQueue.length}`);
664
+ for (const [idx, job] of state.promptQueue.entries()) {
665
+ lines.push(`${idx + 1}. ${job.userMessageID} (${formatAgeMs(now - job.createdAt)}) ${preview(job.prompt, 70)}`);
666
+ }
667
+ await this.telegram.sendMessage(this.config.channelID, lines.join("\n"), { replyToMessageID });
668
+ }
669
+ async handleCancel(target, replyToMessageID) {
670
+ const state = await this.requireState();
671
+ if (state.promptQueue.length === 0) {
672
+ await this.telegram.sendMessage(this.config.channelID, "Queue is empty. Nothing to cancel.", { replyToMessageID });
673
+ return;
674
+ }
675
+ let index = -1;
676
+ if (target.toLowerCase() === "last") {
677
+ index = state.promptQueue.length - 1;
678
+ }
679
+ else {
680
+ index = state.promptQueue.findIndex((item) => item.userMessageID === target);
681
+ }
682
+ if (index < 0) {
683
+ if (state.activePrompt && state.activePrompt.userMessageID === target) {
684
+ await this.telegram.sendMessage(this.config.channelID, `Job ${target} is active. Use /tp:interrupt to stop it.`, { replyToMessageID });
685
+ return;
686
+ }
687
+ await this.telegram.sendMessage(this.config.channelID, `Queued job not found: ${target}`, { replyToMessageID });
688
+ return;
689
+ }
690
+ const removed = state.promptQueue[index];
691
+ const nextQueue = state.promptQueue.filter((_, i) => i !== index);
692
+ await this.persist({
693
+ ...state,
694
+ promptQueue: nextQueue,
695
+ });
696
+ await this.telegram.sendMessage(this.config.channelID, `Canceled queued job ${removed.userMessageID}.`, { replyToMessageID });
697
+ }
698
+ async handleRetry(updateID, messageID, channelID) {
699
+ const state = await this.requireState();
700
+ if (state.bound.status !== "online" || !state.bound.sessionID) {
701
+ await this.telegram.sendMessage(this.config.channelID, "Bridge is offline. Run /tp:start in OpenCode first.", { replyToMessageID: messageID });
702
+ return;
703
+ }
704
+ const last = [...state.promptHistory].reverse().find((item) => item.prompt.trim().length > 0);
705
+ if (!last) {
706
+ await this.telegram.sendMessage(this.config.channelID, "No previous prompt found to retry.", { replyToMessageID: messageID });
707
+ return;
708
+ }
709
+ const retryJob = {
710
+ telegramUpdateID: updateID,
711
+ telegramMessageID: messageID,
712
+ telegramChannelID: channelID,
713
+ prompt: last.prompt,
714
+ userMessageID: createTelegramUserMessageID(updateID),
715
+ createdAt: this.deps.now(),
716
+ };
717
+ const next = {
718
+ ...state,
719
+ promptQueue: [...state.promptQueue, retryJob],
720
+ recentPrompts: [
721
+ ...state.recentPrompts,
722
+ {
723
+ jobID: retryJob.userMessageID,
724
+ prompt: retryJob.prompt,
725
+ at: retryJob.createdAt,
726
+ },
727
+ ].slice(-20),
728
+ };
729
+ await this.persist(next);
730
+ await this.telegram.sendMessage(this.config.channelID, `Retry queued from ${last.jobID} -> ${retryJob.userMessageID}`, { replyToMessageID: messageID });
731
+ await this.processPromptQueue();
732
+ }
733
+ async handleContext(replyToMessageID) {
734
+ const state = await this.requireState();
735
+ const currentModel = state.bound.model
736
+ ? `${state.bound.model.providerID}/${state.bound.model.modelID}`
737
+ : "default";
738
+ const sessionTitle = await this.getSessionTitle(state.bound.sessionID);
739
+ const recentPrompts = [...state.recentPrompts].slice(-3).reverse();
740
+ const recentHistory = [...state.promptHistory].slice(-3).reverse();
741
+ const lines = [
742
+ `session=${state.bound.sessionID ?? "none"}`,
743
+ `title=${sessionTitle ?? "n/a"}`,
744
+ `model=${currentModel}`,
745
+ "",
746
+ "Recent user requests:",
747
+ ];
748
+ if (recentPrompts.length === 0) {
749
+ lines.push("- (none)");
750
+ }
751
+ else {
752
+ for (const item of recentPrompts) {
753
+ lines.push(`- ${item.jobID}: ${preview(item.prompt, 80)}`);
754
+ }
755
+ }
756
+ lines.push("");
757
+ lines.push("Last assistant summaries:");
758
+ if (recentHistory.length === 0) {
759
+ lines.push("- (none)");
760
+ }
761
+ else {
762
+ for (const item of recentHistory) {
763
+ lines.push(`- ${item.jobID} [${item.status}]: ${preview(item.summary, 90)}`);
764
+ }
765
+ const changed = recentHistory[0]?.changedFiles ?? [];
766
+ lines.push("");
767
+ lines.push("Recent changed files:");
768
+ if (changed.length === 0)
769
+ lines.push("- (none)");
770
+ for (const file of changed.slice(0, 8)) {
771
+ lines.push(`- ${file}`);
772
+ }
773
+ }
774
+ await this.telegram.sendMessage(this.config.channelID, lines.join("\n"), { replyToMessageID });
775
+ }
776
+ async handleCompact(replyToMessageID) {
777
+ const state = await this.requireState();
778
+ if (!state.bound.sessionID || state.bound.status !== "online") {
779
+ await this.telegram.sendMessage(this.config.channelID, "Bridge is offline. Run /tp:start in OpenCode first.", { replyToMessageID });
780
+ return;
781
+ }
782
+ try {
783
+ await this.client.session.summarize({ sessionID: state.bound.sessionID }, { responseStyle: "data", throwOnError: true });
784
+ await this.telegram.sendMessage(this.config.channelID, `Compaction requested for session ${state.bound.sessionID}.`, { replyToMessageID });
785
+ }
786
+ catch (error) {
787
+ await this.telegram.sendMessage(this.config.channelID, `Compaction failed: ${String(error)}`, { replyToMessageID });
788
+ }
789
+ }
790
+ async handleNewSession(replyToMessageID) {
791
+ const state = await this.requireState();
792
+ if (state.bound.status !== "online") {
793
+ await this.telegram.sendMessage(this.config.channelID, "Bridge is offline. Run /tp:start in OpenCode first.", { replyToMessageID });
794
+ return;
795
+ }
796
+ try {
797
+ const response = await this.client.session.create({}, { responseStyle: "data", throwOnError: true });
798
+ const nextSessionID = response?.session?.id ?? response?.id;
799
+ if (!nextSessionID || typeof nextSessionID !== "string") {
800
+ throw new Error("Could not resolve new session id.");
801
+ }
802
+ await this.switchBoundSession(nextSessionID);
803
+ await this.telegram.sendMessage(this.config.channelID, `Created and switched to new session: ${nextSessionID}`, { replyToMessageID });
804
+ }
805
+ catch (error) {
806
+ await this.telegram.sendMessage(this.config.channelID, `New session failed: ${String(error)}`, { replyToMessageID });
807
+ }
808
+ }
809
+ async handleResetContext(replyToMessageID) {
810
+ await this.handleNewSession(replyToMessageID);
811
+ }
812
+ async handleWho(replyToMessageID) {
813
+ const state = await this.requireState();
814
+ const lease = state.lease;
815
+ const lines = [
816
+ `instance_id=${this.instanceID}`,
817
+ `lease_owner=${lease?.ownerInstanceID ?? "none"}`,
818
+ `is_owner=${this.lease.isOwner(state) ? "true" : "false"}`,
819
+ `session=${state.bound.sessionID ?? "none"}`,
820
+ `status=${state.bound.status}`,
821
+ ];
822
+ await this.telegram.sendMessage(this.config.channelID, lines.join("\n"), { replyToMessageID });
823
+ }
824
+ async handleHealth(replyToMessageID) {
825
+ const state = await this.requireState();
826
+ const now = this.deps.now();
827
+ const leaseAge = state.lease
828
+ ? formatAgeMs(Math.max(0, now - state.lease.ownerHeartbeatAt))
829
+ : "n/a";
830
+ const stale = state.lease && now - state.lease.ownerHeartbeatAt > this.config.leaseTtlMs
831
+ ? "true"
832
+ : "false";
833
+ const lines = [
834
+ `status=${state.bound.status}`,
835
+ `session=${state.bound.sessionID ?? "none"}`,
836
+ `lease_owner=${state.lease?.ownerInstanceID ?? "none"}`,
837
+ `lease_age=${leaseAge}`,
838
+ `lease_stale=${stale}`,
839
+ `is_owner=${this.lease.isOwner(state) ? "true" : "false"}`,
840
+ `polling=${this.pollTask ? "running" : "stopped"}`,
841
+ `event_stream=${this.eventStream ? "running" : "stopped"}`,
842
+ `queue=${state.promptQueue.length}`,
843
+ `pending_permissions=${Object.keys(state.pendingPermissions).length}`,
844
+ ];
845
+ await this.telegram.sendMessage(this.config.channelID, lines.join("\n"), { replyToMessageID });
846
+ }
847
+ async handleReclaim(replyToMessageID) {
848
+ const state = await this.syncState();
849
+ try {
850
+ const claimed = this.lease.claim(state);
851
+ await this.persist(claimed);
852
+ this.startHeartbeat();
853
+ this.startPolling();
854
+ if (claimed.bound.sessionID) {
855
+ await this.startEventStream(claimed.bound.sessionID);
856
+ }
857
+ await this.telegram.sendMessage(this.config.channelID, `Reclaimed bridge ownership as ${this.instanceID}.`, { replyToMessageID });
858
+ }
859
+ catch (error) {
860
+ await this.telegram.sendMessage(this.config.channelID, `Reclaim failed: ${String(error)}`, { replyToMessageID });
861
+ }
862
+ }
863
+ async handleHistory(replyToMessageID) {
864
+ const state = await this.requireState();
865
+ const items = [...state.promptHistory].slice(-10).reverse();
866
+ if (items.length === 0) {
867
+ await this.telegram.sendMessage(this.config.channelID, "No history yet.", { replyToMessageID });
868
+ return;
869
+ }
870
+ const lines = ["Recent history:"];
871
+ for (const item of items) {
872
+ lines.push(`- ${item.jobID} [${item.status}] ${new Date(item.at).toISOString()} ${preview(item.summary, 90)}`);
873
+ }
874
+ await this.telegram.sendMessage(this.config.channelID, lines.join("\n"), { replyToMessageID });
875
+ }
876
+ async handleLastError(replyToMessageID) {
877
+ const state = await this.requireState();
878
+ const item = [...state.promptHistory]
879
+ .reverse()
880
+ .find((entry) => entry.status === "failed" || entry.status === "interrupted");
881
+ if (!item) {
882
+ await this.telegram.sendMessage(this.config.channelID, "No failed/interrupted runs found.", { replyToMessageID });
883
+ return;
884
+ }
885
+ const lines = [
886
+ `job=${item.jobID}`,
887
+ `status=${item.status}`,
888
+ `at=${new Date(item.at).toISOString()}`,
889
+ `summary=${preview(item.summary, 300)}`,
890
+ ];
891
+ await this.telegram.sendMessage(this.config.channelID, lines.join("\n"), { replyToMessageID });
892
+ }
893
+ async handleInterrupt(replyToMessageID) {
894
+ const state = await this.requireState();
895
+ if (state.bound.status !== "online" || !state.bound.sessionID) {
896
+ await this.telegram.sendMessage(this.config.channelID, "Bridge is offline. Run /tp:start in OpenCode first.", { replyToMessageID });
897
+ return;
898
+ }
899
+ if (!state.activePrompt) {
900
+ await this.telegram.sendMessage(this.config.channelID, "No active run to interrupt.", { replyToMessageID });
901
+ return;
902
+ }
903
+ try {
904
+ await this.client.session.abort({ sessionID: state.bound.sessionID });
905
+ const latest = await this.requireState();
906
+ if (latest.activePrompt?.userMessageID === state.activePrompt.userMessageID) {
907
+ await this.persist(this.appendPromptHistory({
908
+ ...latest,
909
+ activePrompt: undefined,
910
+ }, {
911
+ jobID: state.activePrompt.userMessageID,
912
+ prompt: state.activePrompt.prompt,
913
+ summary: "Interrupted from Telegram.",
914
+ changedFiles: [],
915
+ status: "interrupted",
916
+ at: this.deps.now(),
917
+ }));
918
+ }
919
+ await this.telegram.sendMessage(this.config.channelID, "Interrupted active run.", { replyToMessageID });
920
+ await this.processPromptQueue();
921
+ }
922
+ catch (error) {
923
+ await this.telegram.sendMessage(this.config.channelID, `Interrupt failed: ${String(error)}`, { replyToMessageID });
924
+ }
925
+ }
926
+ async onSessionError(event) {
927
+ const state = await this.syncState();
928
+ if (!this.lease.isOwner(state))
929
+ return;
930
+ if (event.sessionID && state.bound.sessionID !== event.sessionID)
931
+ return;
932
+ const errorName = event.error?.name ?? "UnknownError";
933
+ if (!state.activePrompt) {
934
+ await this.telegram.sendMessage(this.config.channelID, `failed: ${errorName}`);
935
+ return;
936
+ }
937
+ const next = {
938
+ ...state,
939
+ activePrompt: undefined,
940
+ };
941
+ await this.persist(this.appendPromptHistory(next, {
942
+ jobID: state.activePrompt.userMessageID,
943
+ prompt: state.activePrompt.prompt,
944
+ summary: errorName,
945
+ changedFiles: [],
946
+ status: "failed",
947
+ at: this.deps.now(),
948
+ }));
949
+ await this.telegram.sendMessage(this.config.channelID, `failed: ${errorName}`, { replyToMessageID: state.activePrompt.telegramMessageID });
950
+ await this.processPromptQueue();
951
+ }
952
+ async getGitBranch() {
953
+ try {
954
+ const { stdout } = await execFileAsync("git", [
955
+ "rev-parse",
956
+ "--abbrev-ref",
957
+ "HEAD",
958
+ ]);
959
+ const branch = stdout.trim();
960
+ if (!branch)
961
+ return undefined;
962
+ return branch;
963
+ }
964
+ catch {
965
+ return undefined;
966
+ }
967
+ }
968
+ appendPromptHistory(state, item) {
969
+ return {
970
+ ...state,
971
+ promptHistory: [...state.promptHistory, item].slice(-30),
972
+ };
973
+ }
974
+ async getSessionTitle(sessionID) {
975
+ if (!sessionID)
976
+ return undefined;
977
+ try {
978
+ const response = await this.client.session.get({ sessionID }, { responseStyle: "data", throwOnError: true });
979
+ const title = response?.session?.title;
980
+ if (typeof title === "string" && title.trim().length > 0)
981
+ return title.trim();
982
+ return undefined;
983
+ }
984
+ catch {
985
+ return undefined;
986
+ }
987
+ }
988
+ async switchBoundSession(sessionID) {
989
+ const state = await this.syncState();
990
+ const next = {
991
+ ...state,
992
+ bound: {
993
+ ...state.bound,
994
+ sessionID,
995
+ status: "online",
996
+ },
997
+ activePrompt: undefined,
998
+ promptQueue: [],
999
+ pendingPermissions: {},
1000
+ };
1001
+ await this.persist(next);
1002
+ await this.startEventStream(sessionID);
1003
+ }
1004
+ startHeartbeat() {
1005
+ this.stopHeartbeat();
1006
+ this.heartbeatTimer = setInterval(async () => {
1007
+ try {
1008
+ const state = await this.syncState();
1009
+ if (!this.lease.isOwner(state))
1010
+ return;
1011
+ await this.persist(this.lease.refresh(state));
1012
+ }
1013
+ catch (error) {
1014
+ this.api.ui.toast({
1015
+ variant: "error",
1016
+ message: `Telegram bridge heartbeat failed: ${String(error)}`,
1017
+ });
1018
+ }
1019
+ }, this.config.heartbeatMs);
1020
+ }
1021
+ stopHeartbeat() {
1022
+ if (!this.heartbeatTimer)
1023
+ return;
1024
+ clearInterval(this.heartbeatTimer);
1025
+ this.heartbeatTimer = undefined;
1026
+ }
1027
+ startPolling() {
1028
+ if (this.pollTask)
1029
+ return;
1030
+ const telegram = this.getTelegramApi();
1031
+ this.pollAbort = new AbortController();
1032
+ const poller = new TelegramPoller(telegram, this.requireChannelID(), this.config.prefix, this.config.pollTimeoutSec, {
1033
+ onCommand: (command) => this.handleTelegramCommand(command),
1034
+ onOffset: async (offset) => {
1035
+ const state = await this.syncState();
1036
+ if (!this.lease.isOwner(state))
1037
+ return;
1038
+ await this.persist({ ...state, pollingOffset: offset });
1039
+ },
1040
+ onError: (error) => {
1041
+ this.api.ui.toast({
1042
+ variant: "warning",
1043
+ message: `Telegram polling error: ${String(error)}`,
1044
+ });
1045
+ },
1046
+ });
1047
+ this.pollTask = poller.run(this.data?.pollingOffset ?? 0, this.pollAbort.signal);
1048
+ }
1049
+ async startEventStream(sessionID) {
1050
+ if (this.eventRestartTimer) {
1051
+ clearTimeout(this.eventRestartTimer);
1052
+ this.eventRestartTimer = undefined;
1053
+ }
1054
+ await this.eventStream?.stop();
1055
+ this.eventStream = new SessionEventStream(this.client, sessionID, {
1056
+ onAssistantCompleted: (sid, assistantID, parentID) => this.onAssistantCompleted(sid, assistantID, parentID),
1057
+ onPermissionAsked: (event) => this.onPermissionAsked(event),
1058
+ onSessionError: (event) => this.onSessionError(event),
1059
+ onUserMessage: (sid, msgID) => this.onUserMessage(sid, msgID),
1060
+ onStreamError: (error) => this.onEventStreamError(sessionID, error),
1061
+ });
1062
+ this.eventStream.start();
1063
+ }
1064
+ async onEventStreamError(sessionID, error) {
1065
+ this.eventStream = undefined;
1066
+ this.api.ui.toast({
1067
+ variant: "warning",
1068
+ message: `Event stream error: ${String(error)}. Restarting...`,
1069
+ });
1070
+ if (this.eventRestartTimer)
1071
+ return;
1072
+ this.eventRestartTimer = setTimeout(async () => {
1073
+ this.eventRestartTimer = undefined;
1074
+ try {
1075
+ const state = await this.syncState();
1076
+ if (!this.lease.isOwner(state))
1077
+ return;
1078
+ if (state.bound.status !== "online" || state.bound.sessionID !== sessionID)
1079
+ return;
1080
+ await this.startEventStream(sessionID);
1081
+ }
1082
+ catch (restartError) {
1083
+ this.api.ui.toast({
1084
+ variant: "warning",
1085
+ message: `Event stream restart failed: ${String(restartError)}`,
1086
+ });
1087
+ }
1088
+ }, 1500);
1089
+ }
1090
+ normalizeLocalCommand(command) {
1091
+ const trimmed = command.trim();
1092
+ if (!trimmed.startsWith("/"))
1093
+ return trimmed;
1094
+ return trimmed.slice(1);
1095
+ }
1096
+ splitCommandArgs(command) {
1097
+ return this.normalizeLocalCommand(command).split(/\s+/).filter(Boolean);
1098
+ }
1099
+ parseCredentialArgs(parts) {
1100
+ if (parts.length < 3)
1101
+ return undefined;
1102
+ const botToken = parts[1]?.trim();
1103
+ const channelID = parts[2]?.trim();
1104
+ if (!botToken || !channelID)
1105
+ return undefined;
1106
+ return { botToken, channelID };
1107
+ }
1108
+ parseStartWithCredentials(command) {
1109
+ const parts = this.splitCommandArgs(command);
1110
+ if (parts[0] !== "tp:start")
1111
+ return undefined;
1112
+ return this.parseCredentialArgs(parts);
1113
+ }
1114
+ parseCredentialCommand(command) {
1115
+ const parts = this.splitCommandArgs(command);
1116
+ if (parts[0] !== "tp:credentials")
1117
+ return undefined;
1118
+ return this.parseCredentialArgs(parts);
1119
+ }
1120
+ resolveCredentials(credentials) {
1121
+ const botToken = credentials?.botToken?.trim()
1122
+ || this.sessionCredentials?.botToken?.trim()
1123
+ || this.config.botToken?.trim();
1124
+ const channelID = credentials?.channelID?.trim()
1125
+ || this.sessionCredentials?.channelID?.trim()
1126
+ || this.config.channelID?.trim();
1127
+ if (!botToken || !channelID) {
1128
+ throw new Error("Missing Telegram credentials. Set env vars or run /tp:start <bot_token> <channel_id> (or /tp:credentials <bot_token> <channel_id>) for this session.");
1129
+ }
1130
+ return { botToken, channelID };
1131
+ }
1132
+ getTelegramApi() {
1133
+ return this.telegram;
1134
+ }
1135
+ requireChannelID() {
1136
+ const channelID = this.config.channelID?.trim();
1137
+ if (!channelID) {
1138
+ throw new Error("Telegram channel id is not configured.");
1139
+ }
1140
+ return channelID;
1141
+ }
1142
+ async stopRuntime(clearJobs) {
1143
+ this.stopHeartbeat();
1144
+ if (this.eventRestartTimer) {
1145
+ clearTimeout(this.eventRestartTimer);
1146
+ this.eventRestartTimer = undefined;
1147
+ }
1148
+ this.pollAbort?.abort();
1149
+ this.pollAbort = undefined;
1150
+ await this.pollTask;
1151
+ this.pollTask = undefined;
1152
+ await this.eventStream?.stop();
1153
+ this.eventStream = undefined;
1154
+ if (!clearJobs)
1155
+ return;
1156
+ const state = await this.requireState();
1157
+ await this.persist({
1158
+ ...state,
1159
+ activePrompt: undefined,
1160
+ promptQueue: [],
1161
+ pendingPermissions: {},
1162
+ });
1163
+ }
1164
+ async requireState() {
1165
+ if (this.data)
1166
+ return this.data;
1167
+ this.data = await this.store.load();
1168
+ return this.data;
1169
+ }
1170
+ async persist(next) {
1171
+ this.data = next;
1172
+ await this.store.save(next);
1173
+ }
1174
+ async syncState() {
1175
+ const latest = await this.store.load();
1176
+ this.data = latest;
1177
+ return latest;
1178
+ }
1179
+ }
1180
+ //# sourceMappingURL=controller.js.map