opencode-plugin-teleprompt 0.1.12 → 0.2.1

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.
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { appendFileSync } from "node:fs";
3
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";
@@ -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;
@@ -136,9 +138,11 @@ export class BridgeController {
136
138
  lastEscAt = 0;
137
139
  sessionCredentials;
138
140
  activePromptCheckInFlight = false;
141
+ completionInFlight = false;
139
142
  constructor(api, config, storePath, deps) {
140
143
  this.api = api;
141
144
  this.config = config;
145
+ this.storePath = storePath;
142
146
  this.deps = { ...DEFAULT_DEPS, ...deps };
143
147
  this.instanceID = this.deps.randomID();
144
148
  this.client = api.client;
@@ -146,6 +150,16 @@ export class BridgeController {
146
150
  this.store = new BridgeStore(storePath, config.channelID ?? "");
147
151
  this.lease = new LeaseManager(this.instanceID, this.deps.now, config.leaseTtlMs);
148
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
+ }
149
163
  async init() {
150
164
  this.data = await this.store.load();
151
165
  }
@@ -156,7 +170,17 @@ export class BridgeController {
156
170
  this.config.botToken = resolvedCredentials.botToken;
157
171
  this.config.channelID = resolvedCredentials.channelID;
158
172
  this.store.setChannelID(resolvedCredentials.channelID);
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
+ }
159
182
  const sessionID = await getCurrentOrCreateSessionID(this.api, this.client);
183
+ this.log(`bindCurrent: getCurrentOrCreateSessionID returned ${sessionID}`);
160
184
  const state = await this.syncState();
161
185
  const claimed = this.lease.claim(state);
162
186
  const next = this.resetBoundSessionState(claimed, sessionID, resolvedCredentials.channelID);
@@ -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
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
  }
@@ -308,9 +334,11 @@ export class BridgeController {
308
334
  return;
309
335
  }
310
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})`);
311
338
  await this.telegram.sendMessage(this.config.channelID, "Bridge is offline. Run /tp:start in OpenCode first.", { replyToMessageID: command.messageID });
312
339
  return;
313
340
  }
341
+ this.log(`handleTelegramCommand: accepting prompt, sending accepted to Telegram`);
314
342
  await this.telegram.sendMessage(this.config.channelID, "accepted", { replyToMessageID: command.messageID });
315
343
  const job = {
316
344
  telegramUpdateID: command.updateID,
@@ -414,19 +442,32 @@ export class BridgeController {
414
442
  });
415
443
  }
416
444
  async processPromptQueue() {
417
- if (this.processingQueue)
445
+ if (this.processingQueue) {
446
+ this.log("processPromptQueue: queue processing already in progress");
418
447
  return;
448
+ }
419
449
  this.processingQueue = true;
450
+ this.log("processPromptQueue: started processing queue");
420
451
  try {
421
452
  while (true) {
422
453
  const state = await this.syncState();
423
- if (!this.lease.isOwner(state))
454
+ if (!this.lease.isOwner(state)) {
455
+ this.log(`processPromptQueue: stopped - not owner of lease`);
424
456
  return;
425
- if (state.activePrompt || state.promptQueue.length === 0)
457
+ }
458
+ if (state.activePrompt) {
459
+ this.log(`processPromptQueue: stopped - another prompt is active (activePromptID=${state.activePrompt.userMessageID})`);
460
+ return;
461
+ }
462
+ if (state.promptQueue.length === 0) {
463
+ this.log(`processPromptQueue: queue is empty`);
426
464
  return;
465
+ }
427
466
  const job = state.promptQueue[0];
428
- if (!state.bound.sessionID)
467
+ if (!state.bound.sessionID) {
468
+ this.log(`processPromptQueue: error - missing sessionID in state`);
429
469
  return;
470
+ }
430
471
  const startedAt = this.deps.now();
431
472
  const activeJob = {
432
473
  ...job,
@@ -438,16 +479,20 @@ export class BridgeController {
438
479
  promptQueue: state.promptQueue.slice(1),
439
480
  };
440
481
  await this.persist(next);
482
+ this.log(`processPromptQueue: activeJob set to userMessageID=${activeJob.userMessageID}`);
441
483
  this.api.ui.toast({
442
484
  variant: "info",
443
485
  message: `Teleprompt: sending prompt to session ${state.bound.sessionID}...`,
444
486
  });
445
- await this.telegram.sendMessage(this.config.channelID, "running", { replyToMessageID: activeJob.telegramMessageID });
446
487
  try {
447
- const submitted = await submitPrompt(this.client, state.bound.sessionID, job.prompt, job.telegramUpdateID, state.bound.model);
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...`);
448
491
  await this.waitForPromptCompletion(state.bound.sessionID, submitted.userMessageID, submitted.assistantMessageID);
492
+ this.log(`processPromptQueue: completed successfully`);
449
493
  }
450
494
  catch (error) {
495
+ this.log(`processPromptQueue: execution failed: ${String(error)}`);
451
496
  const latest = await this.requireState();
452
497
  if (latest.activePrompt?.userMessageID === activeJob.userMessageID) {
453
498
  await this.persist(this.appendPromptHistory({
@@ -468,37 +513,52 @@ export class BridgeController {
468
513
  }
469
514
  finally {
470
515
  this.processingQueue = false;
516
+ this.log("processPromptQueue: finished processing queue");
471
517
  }
472
518
  }
473
- async onAssistantCompleted(sessionID, assistantMessageID, parentUserMessageID) {
474
- await this.completeActivePrompt(sessionID, assistantMessageID, parentUserMessageID);
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.
475
524
  }
476
525
  async completeActivePrompt(sessionID, assistantMessageID, diffMessageID, directParts) {
477
- const state = await this.syncState();
478
- if (!this.lease.isOwner(state))
479
- return;
480
- if (!state.activePrompt)
526
+ // Guard against duplicate completion from event stream + polling race
527
+ if (this.completionInFlight)
481
528
  return;
482
- if (state.bound.sessionID !== sessionID)
483
- return;
484
- const summary = await this.buildSummary(sessionID, assistantMessageID, diffMessageID, directParts);
485
- const completedAt = this.deps.now();
486
- const elapsed = completedAt - (state.activePrompt.startedAt ?? state.activePrompt.createdAt);
487
- const message = formatSummaryForTelegram(summary, this.config.summaryMaxChars);
488
- await this.telegram.sendMessage(this.config.channelID, `completed in ${formatAgeMs(Math.max(0, elapsed))}`, { replyToMessageID: state.activePrompt.telegramMessageID });
489
- await this.telegram.sendMessage(this.config.channelID, message, { replyToMessageID: state.activePrompt.telegramMessageID });
490
- await this.persist(this.appendPromptHistory({
491
- ...state,
492
- activePrompt: undefined,
493
- }, {
494
- jobID: state.activePrompt.userMessageID,
495
- prompt: state.activePrompt.prompt,
496
- summary: summary.text,
497
- changedFiles: summary.changedFiles,
498
- status: "completed",
499
- at: completedAt,
500
- }));
501
- await this.processPromptQueue();
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
+ }
502
562
  }
503
563
  async onUserMessage(sessionID, userMessageID) {
504
564
  const state = await this.syncState();
@@ -508,12 +568,12 @@ export class BridgeController {
508
568
  return;
509
569
  if (state.bound.sessionID !== sessionID)
510
570
  return;
511
- if (userMessageID.startsWith("tg-"))
571
+ if (userMessageID.startsWith("msg_tg_"))
512
572
  return;
513
573
  if (state.activePrompt)
514
574
  return;
515
575
  try {
516
- await this.client.session.abort({ path: { id: sessionID } });
576
+ await this.client.session.abort({ sessionID, directory: this.api.state.path.directory });
517
577
  }
518
578
  catch { }
519
579
  this.api.ui.toast({
@@ -653,14 +713,7 @@ export class BridgeController {
653
713
  .trim();
654
714
  let changedFiles = [];
655
715
  try {
656
- const diffResponse = await this.client.session.message({
657
- path: {
658
- id: sessionID,
659
- messageID: userMessageID,
660
- },
661
- responseStyle: "data",
662
- throwOnError: true,
663
- });
716
+ const diffResponse = await this.client.session.message({ sessionID, messageID: userMessageID, directory: this.api.state.path.directory }, { responseStyle: "data", throwOnError: true });
664
717
  const diff = diffResponse?.diff;
665
718
  changedFiles = (diff || []).map((item) => item.file);
666
719
  }
@@ -804,11 +857,7 @@ export class BridgeController {
804
857
  return;
805
858
  }
806
859
  try {
807
- await this.client.session.summarize({
808
- path: { id: state.bound.sessionID },
809
- responseStyle: "data",
810
- throwOnError: true,
811
- });
860
+ await this.client.session.summarize({ sessionID: state.bound.sessionID, directory: this.api.state.path.directory }, { responseStyle: "data", throwOnError: true });
812
861
  await this.telegram.sendMessage(this.config.channelID, `Compaction requested for session ${state.bound.sessionID}.`, { replyToMessageID });
813
862
  }
814
863
  catch (error) {
@@ -932,7 +981,7 @@ export class BridgeController {
932
981
  return;
933
982
  }
934
983
  try {
935
- await this.client.session.abort({ path: { id: state.bound.sessionID } });
984
+ await this.client.session.abort({ sessionID: state.bound.sessionID, directory: this.api.state.path.directory });
936
985
  const latest = await this.requireState();
937
986
  if (latest.activePrompt?.userMessageID === state.activePrompt.userMessageID) {
938
987
  await this.persist(this.appendPromptHistory({
@@ -1006,11 +1055,7 @@ export class BridgeController {
1006
1055
  if (!sessionID)
1007
1056
  return undefined;
1008
1057
  try {
1009
- const response = await this.client.session.get({
1010
- path: { id: sessionID },
1011
- responseStyle: "data",
1012
- throwOnError: true,
1013
- });
1058
+ const response = await this.client.session.get({ sessionID, directory: this.api.state.path.directory }, { responseStyle: "data", throwOnError: true });
1014
1059
  const title = response?.title ?? response?.session?.title;
1015
1060
  if (typeof title === "string" && title.trim().length > 0)
1016
1061
  return title.trim();
@@ -1271,13 +1316,7 @@ export class BridgeController {
1271
1316
  return;
1272
1317
  this.activePromptCheckInFlight = true;
1273
1318
  try {
1274
- const response = await this.client.session.messages({
1275
- path: {
1276
- id: state.bound.sessionID,
1277
- },
1278
- responseStyle: "data",
1279
- throwOnError: true,
1280
- });
1319
+ const response = await this.client.session.messages({ sessionID: state.bound.sessionID, directory: this.api.state.path.directory }, { responseStyle: "data", throwOnError: true });
1281
1320
  const messages = Array.isArray(response) ? response : (response?.messages || response?.items || response?.data || []);
1282
1321
  const threshold = (state.activePrompt.startedAt ?? state.activePrompt.createdAt) - 1_000;
1283
1322
  const latestCompletedAssistant = [...messages]
@@ -1300,17 +1339,17 @@ export class BridgeController {
1300
1339
  }
1301
1340
  async clearTuiPrompt() {
1302
1341
  try {
1303
- await this.client.tui.clearPrompt({
1304
- responseStyle: "data",
1305
- throwOnError: true,
1306
- });
1342
+ await this.client.tui.clearPrompt({}, { responseStyle: "data", throwOnError: true });
1307
1343
  }
1308
1344
  catch { }
1309
1345
  }
1310
- async waitForPromptCompletion(sessionID, userMessageID, assistantMessageID) {
1346
+ async waitForPromptCompletion(sessionID, userMessageID, _assistantMessageID) {
1311
1347
  const startedAt = this.deps.now();
1312
1348
  const timeoutMs = 10 * 60 * 1000;
1313
1349
  const pollEveryMs = 2000;
1350
+ const stableDelayMs = 3000; // Wait 3s of no new messages before declaring done
1351
+ let lastSeenAssistantID;
1352
+ let stableSince;
1314
1353
  while (this.deps.now() - startedAt < timeoutMs) {
1315
1354
  const state = await this.syncState();
1316
1355
  if (!this.lease.isOwner(state))
@@ -1320,41 +1359,68 @@ export class BridgeController {
1320
1359
  if (!state.activePrompt || state.activePrompt.userMessageID !== userMessageID)
1321
1360
  return;
1322
1361
  try {
1323
- const response = await this.client.session.messages({
1324
- path: {
1325
- id: sessionID,
1326
- },
1327
- responseStyle: "data",
1328
- throwOnError: true,
1329
- });
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 });
1330
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
1331
1388
  const completedAssistant = [...messages]
1332
1389
  .reverse()
1333
1390
  .find((entry) => {
1334
1391
  const info = entry?.info;
1335
1392
  if (info?.role !== "assistant")
1336
1393
  return false;
1337
- if (assistantMessageID && info.id !== assistantMessageID)
1394
+ if (!info?.time?.completed)
1338
1395
  return false;
1339
- if (info?.error)
1340
- return true;
1341
- const isCompleted = typeof info?.time?.completed === "number";
1342
- if (!isCompleted)
1396
+ const created = info?.time?.created || 0;
1397
+ if (created > 0 && created < startedAt - 5000)
1343
1398
  return false;
1344
- // If we don't have a specific ID, ensure this message wasn't created before we started
1345
- if (!assistantMessageID) {
1346
- const created = info?.time?.created || 0;
1347
- if (created > 0 && created < startedAt - 5000)
1348
- return false; // 5s buffer
1349
- }
1350
1399
  return true;
1351
1400
  });
1352
1401
  if (completedAssistant?.info?.id) {
1353
1402
  if (completedAssistant.info.error) {
1354
1403
  throw new Error(`Assistant error: ${JSON.stringify(completedAssistant.info.error)}`);
1355
1404
  }
1356
- await this.completeActivePrompt(sessionID, completedAssistant.info.id, userMessageID, completedAssistant.parts);
1357
- return;
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
+ }
1358
1424
  }
1359
1425
  }
1360
1426
  catch (err) {