opencode-plugin-teleprompt 0.1.4 → 0.2.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.
- package/dist/index.js +0 -2
- package/dist/index.js.map +1 -1
- package/dist/opencode/binding.d.ts +1 -0
- package/dist/opencode/binding.js +40 -0
- package/dist/opencode/binding.js.map +1 -1
- package/dist/opencode/events.d.ts +1 -0
- package/dist/opencode/events.js +13 -3
- package/dist/opencode/events.js.map +1 -1
- package/dist/opencode/permissions.d.ts +1 -1
- package/dist/opencode/permissions.js +13 -8
- package/dist/opencode/permissions.js.map +1 -1
- package/dist/opencode/submit.d.ts +6 -1
- package/dist/opencode/submit.js +40 -8
- package/dist/opencode/submit.js.map +1 -1
- package/dist/runtime/controller.d.ts +14 -2
- package/dist/runtime/controller.js +330 -78
- package/dist/runtime/controller.js.map +1 -1
- package/dist/telegram/api.d.ts +1 -0
- package/dist/telegram/api.js +20 -6
- package/dist/telegram/api.js.map +1 -1
- package/dist/telegram/delay.d.ts +1 -0
- package/dist/telegram/delay.js +18 -0
- package/dist/telegram/delay.js.map +1 -0
- package/dist/telegram/parser.js +76 -177
- package/dist/telegram/parser.js.map +1 -1
- package/dist/telegram/poller.d.ts +1 -0
- package/dist/telegram/poller.js +14 -1
- package/dist/telegram/poller.js.map +1 -1
- package/dist/tui-types.d.ts +13 -0
- package/dist/tui.js +97 -1
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +5 -1
- package/dist/version.d.ts +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/package.json +3 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import {
|
|
3
|
+
import { appendFileSync } from "node:fs";
|
|
4
|
+
import { getCurrentOrCreateSessionID } from "../opencode/binding.js";
|
|
4
5
|
import { replyPermission, toPendingPermission, formatPermissionRequestMessage } from "../opencode/permissions.js";
|
|
5
6
|
import { createTelegramUserMessageID, submitPrompt } from "../opencode/submit.js";
|
|
6
7
|
import { formatSummaryForTelegram } from "../summary/format.js";
|
|
@@ -10,12 +11,12 @@ import { TelegramApi } from "../telegram/api.js";
|
|
|
10
11
|
import { TelegramPoller } from "../telegram/poller.js";
|
|
11
12
|
import { SessionEventStream } from "../opencode/events.js";
|
|
12
13
|
import { createShutdownGuard } from "./shutdown.js";
|
|
14
|
+
import { PLUGIN_VERSION } from "../version.js";
|
|
13
15
|
const execFileAsync = promisify(execFile);
|
|
14
16
|
const DEFAULT_DEPS = {
|
|
15
17
|
now: () => Date.now(),
|
|
16
18
|
randomID: () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
|
17
19
|
};
|
|
18
|
-
const BRIDGE_USER_MESSAGE_GRACE_MS = 10_000;
|
|
19
20
|
function formatAgeMs(ms) {
|
|
20
21
|
if (ms < 1000)
|
|
21
22
|
return `${ms}ms`;
|
|
@@ -119,6 +120,7 @@ function resolvePresetModel(preset, models) {
|
|
|
119
120
|
export class BridgeController {
|
|
120
121
|
api;
|
|
121
122
|
config;
|
|
123
|
+
storePath;
|
|
122
124
|
deps;
|
|
123
125
|
instanceID;
|
|
124
126
|
client;
|
|
@@ -135,9 +137,12 @@ export class BridgeController {
|
|
|
135
137
|
processingQueue = false;
|
|
136
138
|
lastEscAt = 0;
|
|
137
139
|
sessionCredentials;
|
|
140
|
+
activePromptCheckInFlight = false;
|
|
141
|
+
completionInFlight = false;
|
|
138
142
|
constructor(api, config, storePath, deps) {
|
|
139
143
|
this.api = api;
|
|
140
144
|
this.config = config;
|
|
145
|
+
this.storePath = storePath;
|
|
141
146
|
this.deps = { ...DEFAULT_DEPS, ...deps };
|
|
142
147
|
this.instanceID = this.deps.randomID();
|
|
143
148
|
this.client = api.client;
|
|
@@ -145,6 +150,16 @@ export class BridgeController {
|
|
|
145
150
|
this.store = new BridgeStore(storePath, config.channelID ?? "");
|
|
146
151
|
this.lease = new LeaseManager(this.instanceID, this.deps.now, config.leaseTtlMs);
|
|
147
152
|
}
|
|
153
|
+
log(message) {
|
|
154
|
+
try {
|
|
155
|
+
const logPath = this.storePath.replace(/\.json$/, ".log");
|
|
156
|
+
const timestamp = new Date().toISOString();
|
|
157
|
+
appendFileSync(logPath, `[${timestamp}] ${message}\n`, "utf8");
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// ignore
|
|
161
|
+
}
|
|
162
|
+
}
|
|
148
163
|
async init() {
|
|
149
164
|
this.data = await this.store.load();
|
|
150
165
|
}
|
|
@@ -155,17 +170,26 @@ export class BridgeController {
|
|
|
155
170
|
this.config.botToken = resolvedCredentials.botToken;
|
|
156
171
|
this.config.channelID = resolvedCredentials.channelID;
|
|
157
172
|
this.store.setChannelID(resolvedCredentials.channelID);
|
|
158
|
-
|
|
173
|
+
this.log(`bindCurrent: api.route.current = ${JSON.stringify(this.api.route.current)}`);
|
|
174
|
+
try {
|
|
175
|
+
const listResponse = await this.client.session.list({ directory: this.api.state.path.directory }, { responseStyle: "data", throwOnError: true });
|
|
176
|
+
const sessions = Array.isArray(listResponse) ? listResponse : (listResponse?.sessions || listResponse?.items || listResponse?.data || []);
|
|
177
|
+
this.log(`bindCurrent: available sessions: ${JSON.stringify(sessions.map((s) => ({ id: s.id, title: s.title })))}`);
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
this.log(`bindCurrent: failed to list sessions: ${String(e)}`);
|
|
181
|
+
}
|
|
182
|
+
const sessionID = await getCurrentOrCreateSessionID(this.api, this.client);
|
|
183
|
+
this.log(`bindCurrent: getCurrentOrCreateSessionID returned ${sessionID}`);
|
|
159
184
|
const state = await this.syncState();
|
|
160
185
|
const claimed = this.lease.claim(state);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
claimed.bound.channelID = resolvedCredentials.channelID;
|
|
165
|
-
await this.persist(claimed);
|
|
186
|
+
const next = this.resetBoundSessionState(claimed, sessionID, resolvedCredentials.channelID);
|
|
187
|
+
await this.persist(next);
|
|
188
|
+
await this.skipTelegramBacklog();
|
|
166
189
|
this.startHeartbeat();
|
|
167
190
|
await this.startEventStream(sessionID);
|
|
168
191
|
this.startPolling();
|
|
192
|
+
await this.clearTuiPrompt();
|
|
169
193
|
if (this.config.onlineNotice) {
|
|
170
194
|
await this.getTelegramApi().sendMessage(this.requireChannelID(), `OpenCode Telegram bridge online.\nsession_id: ${sessionID}`);
|
|
171
195
|
}
|
|
@@ -222,10 +246,12 @@ export class BridgeController {
|
|
|
222
246
|
return lines.join("\n");
|
|
223
247
|
}
|
|
224
248
|
async handleTelegramCommand(command) {
|
|
249
|
+
this.log(`handleTelegramCommand: kind=${command.command.kind}, updateID=${command.updateID}, messageID=${command.messageID}`);
|
|
225
250
|
const state = await this.syncState();
|
|
226
251
|
const isOwner = this.lease.isOwner(state);
|
|
227
|
-
const alwaysAllowed = new Set(["status", "who", "health", "reclaim"]);
|
|
252
|
+
const alwaysAllowed = new Set(["status", "who", "health", "reclaim", "version"]);
|
|
228
253
|
if (!isOwner && !alwaysAllowed.has(command.command.kind)) {
|
|
254
|
+
this.log(`handleTelegramCommand: rejected because not owner (isOwner=${isOwner}, kind=${command.command.kind})`);
|
|
229
255
|
await this.telegram.sendMessage(this.config.channelID, "Bridge is currently owned by another OpenCode instance. Use /tp:reclaim first.", { replyToMessageID: command.messageID });
|
|
230
256
|
return;
|
|
231
257
|
}
|
|
@@ -233,6 +259,10 @@ export class BridgeController {
|
|
|
233
259
|
await this.telegram.sendMessage(this.config.channelID, await this.statusLine(), { replyToMessageID: command.messageID });
|
|
234
260
|
return;
|
|
235
261
|
}
|
|
262
|
+
if (command.command.kind === "version") {
|
|
263
|
+
await this.telegram.sendMessage(this.config.channelID, this.versionLine(), { replyToMessageID: command.messageID });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
236
266
|
if (command.command.kind === "disconnect") {
|
|
237
267
|
const state = await this.requireState();
|
|
238
268
|
if (state.bound.status !== "online") {
|
|
@@ -304,9 +334,11 @@ export class BridgeController {
|
|
|
304
334
|
return;
|
|
305
335
|
}
|
|
306
336
|
if (state.bound.status !== "online" || !state.bound.sessionID) {
|
|
337
|
+
this.log(`handleTelegramCommand: rejected because offline or missing session ID (status=${state.bound.status}, sessionID=${state.bound.sessionID})`);
|
|
307
338
|
await this.telegram.sendMessage(this.config.channelID, "Bridge is offline. Run /tp:start in OpenCode first.", { replyToMessageID: command.messageID });
|
|
308
339
|
return;
|
|
309
340
|
}
|
|
341
|
+
this.log(`handleTelegramCommand: accepting prompt, sending accepted to Telegram`);
|
|
310
342
|
await this.telegram.sendMessage(this.config.channelID, "accepted", { replyToMessageID: command.messageID });
|
|
311
343
|
const job = {
|
|
312
344
|
telegramUpdateID: command.updateID,
|
|
@@ -359,12 +391,20 @@ export class BridgeController {
|
|
|
359
391
|
const startWithCredentials = this.parseStartWithCredentials(command);
|
|
360
392
|
if (startWithCredentials) {
|
|
361
393
|
const sessionID = await this.bindCurrent(startWithCredentials);
|
|
394
|
+
await this.clearTuiPrompt();
|
|
362
395
|
this.api.ui.toast({
|
|
363
396
|
variant: "success",
|
|
364
397
|
message: `Telegram bridge bound to session ${sessionID}`,
|
|
365
398
|
});
|
|
366
399
|
return;
|
|
367
400
|
}
|
|
401
|
+
if (this.isVersionCommand(command)) {
|
|
402
|
+
this.api.ui.toast({
|
|
403
|
+
variant: "info",
|
|
404
|
+
message: this.versionLine(),
|
|
405
|
+
});
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
368
408
|
const credentialOnly = this.parseCredentialCommand(command);
|
|
369
409
|
if (credentialOnly) {
|
|
370
410
|
this.sessionCredentials = credentialOnly;
|
|
@@ -402,19 +442,32 @@ export class BridgeController {
|
|
|
402
442
|
});
|
|
403
443
|
}
|
|
404
444
|
async processPromptQueue() {
|
|
405
|
-
if (this.processingQueue)
|
|
445
|
+
if (this.processingQueue) {
|
|
446
|
+
this.log("processPromptQueue: queue processing already in progress");
|
|
406
447
|
return;
|
|
448
|
+
}
|
|
407
449
|
this.processingQueue = true;
|
|
450
|
+
this.log("processPromptQueue: started processing queue");
|
|
408
451
|
try {
|
|
409
452
|
while (true) {
|
|
410
453
|
const state = await this.syncState();
|
|
411
|
-
if (!this.lease.isOwner(state))
|
|
454
|
+
if (!this.lease.isOwner(state)) {
|
|
455
|
+
this.log(`processPromptQueue: stopped - not owner of lease`);
|
|
412
456
|
return;
|
|
413
|
-
|
|
457
|
+
}
|
|
458
|
+
if (state.activePrompt) {
|
|
459
|
+
this.log(`processPromptQueue: stopped - another prompt is active (activePromptID=${state.activePrompt.userMessageID})`);
|
|
414
460
|
return;
|
|
461
|
+
}
|
|
462
|
+
if (state.promptQueue.length === 0) {
|
|
463
|
+
this.log(`processPromptQueue: queue is empty`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
415
466
|
const job = state.promptQueue[0];
|
|
416
|
-
if (!state.bound.sessionID)
|
|
467
|
+
if (!state.bound.sessionID) {
|
|
468
|
+
this.log(`processPromptQueue: error - missing sessionID in state`);
|
|
417
469
|
return;
|
|
470
|
+
}
|
|
418
471
|
const startedAt = this.deps.now();
|
|
419
472
|
const activeJob = {
|
|
420
473
|
...job,
|
|
@@ -426,11 +479,20 @@ export class BridgeController {
|
|
|
426
479
|
promptQueue: state.promptQueue.slice(1),
|
|
427
480
|
};
|
|
428
481
|
await this.persist(next);
|
|
429
|
-
|
|
482
|
+
this.log(`processPromptQueue: activeJob set to userMessageID=${activeJob.userMessageID}`);
|
|
483
|
+
this.api.ui.toast({
|
|
484
|
+
variant: "info",
|
|
485
|
+
message: `Teleprompt: sending prompt to session ${state.bound.sessionID}...`,
|
|
486
|
+
});
|
|
430
487
|
try {
|
|
431
|
-
|
|
488
|
+
this.log(`processPromptQueue: submitting to client: prompt="${job.prompt}"`);
|
|
489
|
+
const submitted = await submitPrompt(this.client, state.bound.sessionID, job.prompt, job.telegramUpdateID, this.api.state.path.directory, state.bound.model);
|
|
490
|
+
this.log(`processPromptQueue: submitted successfully. userMessageID=${submitted.userMessageID}, assistantMessageID=${submitted.assistantMessageID}. Waiting for completion...`);
|
|
491
|
+
await this.waitForPromptCompletion(state.bound.sessionID, submitted.userMessageID, submitted.assistantMessageID);
|
|
492
|
+
this.log(`processPromptQueue: completed successfully`);
|
|
432
493
|
}
|
|
433
494
|
catch (error) {
|
|
495
|
+
this.log(`processPromptQueue: execution failed: ${String(error)}`);
|
|
434
496
|
const latest = await this.requireState();
|
|
435
497
|
if (latest.activePrompt?.userMessageID === activeJob.userMessageID) {
|
|
436
498
|
await this.persist(this.appendPromptHistory({
|
|
@@ -451,36 +513,52 @@ export class BridgeController {
|
|
|
451
513
|
}
|
|
452
514
|
finally {
|
|
453
515
|
this.processingQueue = false;
|
|
516
|
+
this.log("processPromptQueue: finished processing queue");
|
|
454
517
|
}
|
|
455
518
|
}
|
|
456
|
-
async onAssistantCompleted(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (
|
|
519
|
+
async onAssistantCompleted(_sessionID, _assistantMessageID, _parentUserMessageID) {
|
|
520
|
+
// Intentionally a no-op. Completion is handled by waitForPromptCompletion
|
|
521
|
+
// polling. The event stream fires for EVERY completed assistant message,
|
|
522
|
+
// including intermediate ones during the agent tool-call loop, so reacting
|
|
523
|
+
// here would prematurely abort the agent before the final answer arrives.
|
|
524
|
+
}
|
|
525
|
+
async completeActivePrompt(sessionID, assistantMessageID, diffMessageID, directParts) {
|
|
526
|
+
// Guard against duplicate completion from event stream + polling race
|
|
527
|
+
if (this.completionInFlight)
|
|
465
528
|
return;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
529
|
+
this.completionInFlight = true;
|
|
530
|
+
try {
|
|
531
|
+
const state = await this.syncState();
|
|
532
|
+
if (!this.lease.isOwner(state))
|
|
533
|
+
return;
|
|
534
|
+
if (!state.activePrompt)
|
|
535
|
+
return;
|
|
536
|
+
if (state.bound.sessionID !== sessionID)
|
|
537
|
+
return;
|
|
538
|
+
// Removed session.abort() because we want the agent to finish its turn naturally,
|
|
539
|
+
// avoiding "interrupted" UI states.
|
|
540
|
+
const summary = await this.buildSummary(sessionID, assistantMessageID, diffMessageID, directParts);
|
|
541
|
+
const completedAt = this.deps.now();
|
|
542
|
+
const elapsed = completedAt - (state.activePrompt.startedAt ?? state.activePrompt.createdAt);
|
|
543
|
+
const resultText = formatSummaryForTelegram(summary, this.config.summaryMaxChars);
|
|
544
|
+
const combinedMessage = `✅ completed in ${formatAgeMs(Math.max(0, elapsed))}\n\n${resultText}`;
|
|
545
|
+
await this.telegram.sendMessage(this.config.channelID, combinedMessage, { replyToMessageID: state.activePrompt.telegramMessageID });
|
|
546
|
+
await this.persist(this.appendPromptHistory({
|
|
547
|
+
...state,
|
|
548
|
+
activePrompt: undefined,
|
|
549
|
+
}, {
|
|
550
|
+
jobID: state.activePrompt.userMessageID,
|
|
551
|
+
prompt: state.activePrompt.prompt,
|
|
552
|
+
summary: summary.text,
|
|
553
|
+
changedFiles: summary.changedFiles,
|
|
554
|
+
status: "completed",
|
|
555
|
+
at: completedAt,
|
|
556
|
+
}));
|
|
557
|
+
await this.processPromptQueue();
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
this.completionInFlight = false;
|
|
561
|
+
}
|
|
484
562
|
}
|
|
485
563
|
async onUserMessage(sessionID, userMessageID) {
|
|
486
564
|
const state = await this.syncState();
|
|
@@ -490,18 +568,12 @@ export class BridgeController {
|
|
|
490
568
|
return;
|
|
491
569
|
if (state.bound.sessionID !== sessionID)
|
|
492
570
|
return;
|
|
493
|
-
if (userMessageID.startsWith("
|
|
571
|
+
if (userMessageID.startsWith("msg_tg_"))
|
|
494
572
|
return;
|
|
495
|
-
|
|
496
|
-
if (activeStartedAt && this.deps.now() - activeStartedAt < BRIDGE_USER_MESSAGE_GRACE_MS) {
|
|
573
|
+
if (state.activePrompt)
|
|
497
574
|
return;
|
|
498
|
-
}
|
|
499
|
-
try {
|
|
500
|
-
await this.client.session.abort({ sessionID });
|
|
501
|
-
}
|
|
502
|
-
catch { }
|
|
503
575
|
try {
|
|
504
|
-
await this.client.session.
|
|
576
|
+
await this.client.session.abort({ sessionID, directory: this.api.state.path.directory });
|
|
505
577
|
}
|
|
506
578
|
catch { }
|
|
507
579
|
this.api.ui.toast({
|
|
@@ -539,7 +611,7 @@ export class BridgeController {
|
|
|
539
611
|
await this.telegram.sendMessage(this.config.channelID, `Permission request not found: ${requestID}`, replyToMessageID ? { replyToMessageID } : undefined);
|
|
540
612
|
return;
|
|
541
613
|
}
|
|
542
|
-
await replyPermission(this.client, requestID, action);
|
|
614
|
+
await replyPermission(this.client, pending.sessionID, requestID, action);
|
|
543
615
|
const { [requestID]: _removed, ...rest } = state.pendingPermissions;
|
|
544
616
|
await this.persist({
|
|
545
617
|
...state,
|
|
@@ -598,7 +670,10 @@ export class BridgeController {
|
|
|
598
670
|
}
|
|
599
671
|
async fetchAvailableModels() {
|
|
600
672
|
try {
|
|
601
|
-
const response = await this.client.config.providers({
|
|
673
|
+
const response = await this.client.config.providers({
|
|
674
|
+
responseStyle: "data",
|
|
675
|
+
throwOnError: true,
|
|
676
|
+
});
|
|
602
677
|
const providers = response?.providers;
|
|
603
678
|
if (!Array.isArray(providers))
|
|
604
679
|
return [];
|
|
@@ -627,8 +702,8 @@ export class BridgeController {
|
|
|
627
702
|
return [];
|
|
628
703
|
}
|
|
629
704
|
}
|
|
630
|
-
async buildSummary(sessionID, assistantMessageID, userMessageID) {
|
|
631
|
-
const parts = this.api.state.part(assistantMessageID);
|
|
705
|
+
async buildSummary(sessionID, assistantMessageID, userMessageID, directParts) {
|
|
706
|
+
const parts = directParts ?? this.api.state.part(assistantMessageID);
|
|
632
707
|
const text = parts
|
|
633
708
|
.filter((part) => {
|
|
634
709
|
return part.type === "text" && typeof part.text === "string";
|
|
@@ -638,11 +713,8 @@ export class BridgeController {
|
|
|
638
713
|
.trim();
|
|
639
714
|
let changedFiles = [];
|
|
640
715
|
try {
|
|
641
|
-
const diffResponse = await this.client.session.
|
|
642
|
-
|
|
643
|
-
messageID: userMessageID,
|
|
644
|
-
}, { responseStyle: "data", throwOnError: true });
|
|
645
|
-
const diff = diffResponse.diff;
|
|
716
|
+
const diffResponse = await this.client.session.message({ sessionID, messageID: userMessageID, directory: this.api.state.path.directory }, { responseStyle: "data", throwOnError: true });
|
|
717
|
+
const diff = diffResponse?.diff;
|
|
646
718
|
changedFiles = (diff || []).map((item) => item.file);
|
|
647
719
|
}
|
|
648
720
|
catch {
|
|
@@ -785,7 +857,7 @@ export class BridgeController {
|
|
|
785
857
|
return;
|
|
786
858
|
}
|
|
787
859
|
try {
|
|
788
|
-
await this.client.session.summarize({ sessionID: state.bound.sessionID }, { responseStyle: "data", throwOnError: true });
|
|
860
|
+
await this.client.session.summarize({ sessionID: state.bound.sessionID, directory: this.api.state.path.directory }, { responseStyle: "data", throwOnError: true });
|
|
789
861
|
await this.telegram.sendMessage(this.config.channelID, `Compaction requested for session ${state.bound.sessionID}.`, { replyToMessageID });
|
|
790
862
|
}
|
|
791
863
|
catch (error) {
|
|
@@ -799,8 +871,11 @@ export class BridgeController {
|
|
|
799
871
|
return;
|
|
800
872
|
}
|
|
801
873
|
try {
|
|
802
|
-
const response = await this.client.session.create({
|
|
803
|
-
|
|
874
|
+
const response = await this.client.session.create({
|
|
875
|
+
responseStyle: "data",
|
|
876
|
+
throwOnError: true,
|
|
877
|
+
});
|
|
878
|
+
const nextSessionID = response?.id ?? response?.session?.id;
|
|
804
879
|
if (!nextSessionID || typeof nextSessionID !== "string") {
|
|
805
880
|
throw new Error("Could not resolve new session id.");
|
|
806
881
|
}
|
|
@@ -906,7 +981,7 @@ export class BridgeController {
|
|
|
906
981
|
return;
|
|
907
982
|
}
|
|
908
983
|
try {
|
|
909
|
-
await this.client.session.abort({ sessionID: state.bound.sessionID });
|
|
984
|
+
await this.client.session.abort({ sessionID: state.bound.sessionID, directory: this.api.state.path.directory });
|
|
910
985
|
const latest = await this.requireState();
|
|
911
986
|
if (latest.activePrompt?.userMessageID === state.activePrompt.userMessageID) {
|
|
912
987
|
await this.persist(this.appendPromptHistory({
|
|
@@ -980,8 +1055,8 @@ export class BridgeController {
|
|
|
980
1055
|
if (!sessionID)
|
|
981
1056
|
return undefined;
|
|
982
1057
|
try {
|
|
983
|
-
const response = await this.client.session.get({ sessionID }, { responseStyle: "data", throwOnError: true });
|
|
984
|
-
const title = response?.session?.title;
|
|
1058
|
+
const response = await this.client.session.get({ sessionID, directory: this.api.state.path.directory }, { responseStyle: "data", throwOnError: true });
|
|
1059
|
+
const title = response?.title ?? response?.session?.title;
|
|
985
1060
|
if (typeof title === "string" && title.trim().length > 0)
|
|
986
1061
|
return title.trim();
|
|
987
1062
|
return undefined;
|
|
@@ -992,18 +1067,9 @@ export class BridgeController {
|
|
|
992
1067
|
}
|
|
993
1068
|
async switchBoundSession(sessionID) {
|
|
994
1069
|
const state = await this.syncState();
|
|
995
|
-
const next =
|
|
996
|
-
...state,
|
|
997
|
-
bound: {
|
|
998
|
-
...state.bound,
|
|
999
|
-
sessionID,
|
|
1000
|
-
status: "online",
|
|
1001
|
-
},
|
|
1002
|
-
activePrompt: undefined,
|
|
1003
|
-
promptQueue: [],
|
|
1004
|
-
pendingPermissions: {},
|
|
1005
|
-
};
|
|
1070
|
+
const next = this.resetBoundSessionState(state, sessionID, state.bound.channelID || this.requireChannelID());
|
|
1006
1071
|
await this.persist(next);
|
|
1072
|
+
await this.skipTelegramBacklog();
|
|
1007
1073
|
await this.startEventStream(sessionID);
|
|
1008
1074
|
}
|
|
1009
1075
|
startHeartbeat() {
|
|
@@ -1013,7 +1079,9 @@ export class BridgeController {
|
|
|
1013
1079
|
const state = await this.syncState();
|
|
1014
1080
|
if (!this.lease.isOwner(state))
|
|
1015
1081
|
return;
|
|
1016
|
-
|
|
1082
|
+
const refreshed = this.lease.refresh(state);
|
|
1083
|
+
await this.persist(refreshed);
|
|
1084
|
+
await this.reconcileActivePrompt(refreshed);
|
|
1017
1085
|
}
|
|
1018
1086
|
catch (error) {
|
|
1019
1087
|
this.api.ui.toast({
|
|
@@ -1042,6 +1110,12 @@ export class BridgeController {
|
|
|
1042
1110
|
return;
|
|
1043
1111
|
await this.persist({ ...state, pollingOffset: offset });
|
|
1044
1112
|
},
|
|
1113
|
+
onUpdates: (count) => {
|
|
1114
|
+
this.api.ui.toast({
|
|
1115
|
+
variant: "info",
|
|
1116
|
+
message: `Teleprompt: received ${count} Telegram updates`,
|
|
1117
|
+
});
|
|
1118
|
+
},
|
|
1045
1119
|
onError: (error) => {
|
|
1046
1120
|
this.api.ui.toast({
|
|
1047
1121
|
variant: "warning",
|
|
@@ -1139,6 +1213,13 @@ export class BridgeController {
|
|
|
1139
1213
|
}
|
|
1140
1214
|
return this.parseCredentialArgs(parts);
|
|
1141
1215
|
}
|
|
1216
|
+
isVersionCommand(command) {
|
|
1217
|
+
const parts = this.splitCommandArgs(command);
|
|
1218
|
+
return this.isLocalCommand(parts, "version");
|
|
1219
|
+
}
|
|
1220
|
+
versionLine() {
|
|
1221
|
+
return `opencode-plugin-teleprompt ${PLUGIN_VERSION}`;
|
|
1222
|
+
}
|
|
1142
1223
|
resolveCredentials(credentials) {
|
|
1143
1224
|
const botToken = credentials?.botToken?.trim()
|
|
1144
1225
|
|| this.sessionCredentials?.botToken?.trim()
|
|
@@ -1198,5 +1279,176 @@ export class BridgeController {
|
|
|
1198
1279
|
this.data = latest;
|
|
1199
1280
|
return latest;
|
|
1200
1281
|
}
|
|
1282
|
+
resetBoundSessionState(state, sessionID, channelID) {
|
|
1283
|
+
return {
|
|
1284
|
+
...state,
|
|
1285
|
+
bound: {
|
|
1286
|
+
sessionID,
|
|
1287
|
+
channelID,
|
|
1288
|
+
status: "online",
|
|
1289
|
+
model: undefined,
|
|
1290
|
+
},
|
|
1291
|
+
activePrompt: undefined,
|
|
1292
|
+
promptQueue: [],
|
|
1293
|
+
pendingPermissions: {},
|
|
1294
|
+
promptHistory: [],
|
|
1295
|
+
recentPrompts: [],
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
async skipTelegramBacklog() {
|
|
1299
|
+
const latestOffset = await this.getTelegramApi().getLatestUpdateOffset();
|
|
1300
|
+
if (latestOffset === undefined)
|
|
1301
|
+
return;
|
|
1302
|
+
const state = await this.requireState();
|
|
1303
|
+
if (latestOffset <= state.pollingOffset)
|
|
1304
|
+
return;
|
|
1305
|
+
await this.persist({
|
|
1306
|
+
...state,
|
|
1307
|
+
pollingOffset: latestOffset,
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
async reconcileActivePrompt(state) {
|
|
1311
|
+
if (this.activePromptCheckInFlight)
|
|
1312
|
+
return;
|
|
1313
|
+
if (!state.activePrompt)
|
|
1314
|
+
return;
|
|
1315
|
+
if (state.bound.status !== "online" || !state.bound.sessionID)
|
|
1316
|
+
return;
|
|
1317
|
+
this.activePromptCheckInFlight = true;
|
|
1318
|
+
try {
|
|
1319
|
+
const response = await this.client.session.messages({ sessionID: state.bound.sessionID, directory: this.api.state.path.directory }, { responseStyle: "data", throwOnError: true });
|
|
1320
|
+
const messages = Array.isArray(response) ? response : (response?.messages || response?.items || response?.data || []);
|
|
1321
|
+
const threshold = (state.activePrompt.startedAt ?? state.activePrompt.createdAt) - 1_000;
|
|
1322
|
+
const latestCompletedAssistant = [...messages]
|
|
1323
|
+
.reverse()
|
|
1324
|
+
.find((entry) => {
|
|
1325
|
+
const info = entry?.info;
|
|
1326
|
+
return (info?.role === "assistant"
|
|
1327
|
+
&& typeof info?.id === "string"
|
|
1328
|
+
&& typeof info?.time?.completed === "number"
|
|
1329
|
+
&& info.time.completed >= threshold);
|
|
1330
|
+
});
|
|
1331
|
+
if (!latestCompletedAssistant)
|
|
1332
|
+
return;
|
|
1333
|
+
await this.completeActivePrompt(state.bound.sessionID, latestCompletedAssistant.info.id, state.activePrompt.userMessageID, latestCompletedAssistant.parts);
|
|
1334
|
+
}
|
|
1335
|
+
catch { }
|
|
1336
|
+
finally {
|
|
1337
|
+
this.activePromptCheckInFlight = false;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
async clearTuiPrompt() {
|
|
1341
|
+
try {
|
|
1342
|
+
await this.client.tui.clearPrompt({}, { responseStyle: "data", throwOnError: true });
|
|
1343
|
+
}
|
|
1344
|
+
catch { }
|
|
1345
|
+
}
|
|
1346
|
+
async waitForPromptCompletion(sessionID, userMessageID, _assistantMessageID) {
|
|
1347
|
+
const startedAt = this.deps.now();
|
|
1348
|
+
const timeoutMs = 10 * 60 * 1000;
|
|
1349
|
+
const pollEveryMs = 2000;
|
|
1350
|
+
const stableDelayMs = 3000; // Wait 3s of no new messages before declaring done
|
|
1351
|
+
let lastSeenAssistantID;
|
|
1352
|
+
let stableSince;
|
|
1353
|
+
while (this.deps.now() - startedAt < timeoutMs) {
|
|
1354
|
+
const state = await this.syncState();
|
|
1355
|
+
if (!this.lease.isOwner(state))
|
|
1356
|
+
return;
|
|
1357
|
+
if (state.bound.sessionID !== sessionID)
|
|
1358
|
+
return;
|
|
1359
|
+
if (!state.activePrompt || state.activePrompt.userMessageID !== userMessageID)
|
|
1360
|
+
return;
|
|
1361
|
+
try {
|
|
1362
|
+
// 1. Poll the session status
|
|
1363
|
+
let isIdle = false;
|
|
1364
|
+
try {
|
|
1365
|
+
const statusResponse = await this.client.session.status({ query: { directory: this.api.state.path.directory } }, { responseStyle: "data", throwOnError: true });
|
|
1366
|
+
const statusType = statusResponse?.type || statusResponse?.data?.type;
|
|
1367
|
+
isIdle = statusType === "idle";
|
|
1368
|
+
}
|
|
1369
|
+
catch (statusErr) {
|
|
1370
|
+
this.log(`waitForPromptCompletion: failed to get session status: ${String(statusErr)}`);
|
|
1371
|
+
}
|
|
1372
|
+
const response = await this.client.session.messages({ sessionID, directory: this.api.state.path.directory }, { responseStyle: "data", throwOnError: true });
|
|
1373
|
+
const messages = Array.isArray(response) ? response : (response?.messages || response?.items || response?.data || []);
|
|
1374
|
+
// 2. Find the user message created after we started
|
|
1375
|
+
const userMessage = [...messages]
|
|
1376
|
+
.reverse()
|
|
1377
|
+
.find((entry) => {
|
|
1378
|
+
const info = entry?.info;
|
|
1379
|
+
if (info?.role !== "user")
|
|
1380
|
+
return false;
|
|
1381
|
+
const created = info?.time?.created || 0;
|
|
1382
|
+
if (created > 0 && created < startedAt - 5000)
|
|
1383
|
+
return false;
|
|
1384
|
+
return true;
|
|
1385
|
+
});
|
|
1386
|
+
const actualUserMessageID = userMessage?.info?.id || userMessageID;
|
|
1387
|
+
// Find the LATEST completed assistant message created after we started
|
|
1388
|
+
const completedAssistant = [...messages]
|
|
1389
|
+
.reverse()
|
|
1390
|
+
.find((entry) => {
|
|
1391
|
+
const info = entry?.info;
|
|
1392
|
+
if (info?.role !== "assistant")
|
|
1393
|
+
return false;
|
|
1394
|
+
if (!info?.time?.completed)
|
|
1395
|
+
return false;
|
|
1396
|
+
const created = info?.time?.created || 0;
|
|
1397
|
+
if (created > 0 && created < startedAt - 5000)
|
|
1398
|
+
return false;
|
|
1399
|
+
return true;
|
|
1400
|
+
});
|
|
1401
|
+
if (completedAssistant?.info?.id) {
|
|
1402
|
+
if (completedAssistant.info.error) {
|
|
1403
|
+
throw new Error(`Assistant error: ${JSON.stringify(completedAssistant.info.error)}`);
|
|
1404
|
+
}
|
|
1405
|
+
// If the session status is idle, we can fast-path immediately
|
|
1406
|
+
if (isIdle) {
|
|
1407
|
+
this.log(`waitForPromptCompletion: session is idle, fast-path completing`);
|
|
1408
|
+
await this.completeActivePrompt(sessionID, completedAssistant.info.id, actualUserMessageID, completedAssistant.parts);
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
// Stable-state detection: wait until no NEW assistant messages appear
|
|
1412
|
+
if (completedAssistant.info.id !== lastSeenAssistantID) {
|
|
1413
|
+
// New message appeared — reset the stability timer
|
|
1414
|
+
lastSeenAssistantID = completedAssistant.info.id;
|
|
1415
|
+
stableSince = this.deps.now();
|
|
1416
|
+
this.log(`waitForPromptCompletion: new assistant message ${completedAssistant.info.id}, resetting stability timer`);
|
|
1417
|
+
}
|
|
1418
|
+
else if (stableSince && (this.deps.now() - stableSince >= stableDelayMs)) {
|
|
1419
|
+
// Same message for stableDelayMs — model has stopped, we're done
|
|
1420
|
+
this.log(`waitForPromptCompletion: stable for ${stableDelayMs}ms, completing`);
|
|
1421
|
+
await this.completeActivePrompt(sessionID, completedAssistant.info.id, actualUserMessageID, completedAssistant.parts);
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
catch (err) {
|
|
1427
|
+
if (err instanceof Error && err.message.startsWith("Assistant error:")) {
|
|
1428
|
+
throw err;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
await new Promise((resolve) => setTimeout(resolve, pollEveryMs));
|
|
1432
|
+
}
|
|
1433
|
+
const state = await this.syncState();
|
|
1434
|
+
if (!this.lease.isOwner(state))
|
|
1435
|
+
return;
|
|
1436
|
+
if (state.bound.sessionID !== sessionID)
|
|
1437
|
+
return;
|
|
1438
|
+
if (state.activePrompt?.userMessageID !== userMessageID)
|
|
1439
|
+
return;
|
|
1440
|
+
await this.telegram.sendMessage(this.config.channelID, "failed: prompt timed out waiting for completion", { replyToMessageID: state.activePrompt.telegramMessageID });
|
|
1441
|
+
await this.persist(this.appendPromptHistory({
|
|
1442
|
+
...state,
|
|
1443
|
+
activePrompt: undefined,
|
|
1444
|
+
}, {
|
|
1445
|
+
jobID: userMessageID,
|
|
1446
|
+
prompt: state.activePrompt.prompt,
|
|
1447
|
+
summary: "Timed out waiting for assistant completion.",
|
|
1448
|
+
changedFiles: [],
|
|
1449
|
+
status: "failed",
|
|
1450
|
+
at: this.deps.now(),
|
|
1451
|
+
}));
|
|
1452
|
+
}
|
|
1201
1453
|
}
|
|
1202
1454
|
//# sourceMappingURL=controller.js.map
|