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.
@@ -1,6 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { getCurrentSessionID } from "../opencode/binding.js";
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
- const sessionID = getCurrentSessionID(this.api);
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
- claimed.bound.sessionID = sessionID;
162
- claimed.bound.status = "online";
163
- claimed.bound.model = undefined;
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
- 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})`);
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
- await this.telegram.sendMessage(this.config.channelID, "running", { replyToMessageID: activeJob.telegramMessageID });
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
- 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...`);
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(sessionID, assistantMessageID, parentUserMessageID) {
457
- const state = await this.syncState();
458
- if (!this.lease.isOwner(state))
459
- return;
460
- if (!state.activePrompt)
461
- return;
462
- if (state.activePrompt.userMessageID !== parentUserMessageID)
463
- return;
464
- if (state.bound.sessionID !== sessionID)
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
- const summary = await this.buildSummary(sessionID, assistantMessageID, state.activePrompt.userMessageID);
467
- const completedAt = this.deps.now();
468
- const elapsed = completedAt - (state.activePrompt.startedAt ?? state.activePrompt.createdAt);
469
- const message = formatSummaryForTelegram(summary, this.config.summaryMaxChars);
470
- await this.telegram.sendMessage(this.config.channelID, `completed in ${formatAgeMs(Math.max(0, elapsed))}`, { replyToMessageID: state.activePrompt.telegramMessageID });
471
- await this.telegram.sendMessage(this.config.channelID, message, { replyToMessageID: state.activePrompt.telegramMessageID });
472
- await this.persist(this.appendPromptHistory({
473
- ...state,
474
- activePrompt: undefined,
475
- }, {
476
- jobID: state.activePrompt.userMessageID,
477
- prompt: state.activePrompt.prompt,
478
- summary: summary.text,
479
- changedFiles: summary.changedFiles,
480
- status: "completed",
481
- at: completedAt,
482
- }));
483
- 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
+ }
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("tg-"))
571
+ if (userMessageID.startsWith("msg_tg_"))
494
572
  return;
495
- const activeStartedAt = state.activePrompt?.startedAt ?? state.activePrompt?.createdAt;
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.deleteMessage({ sessionID, messageID: userMessageID });
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({}, { responseStyle: "data", throwOnError: true });
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.diff({
642
- sessionID,
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({}, { responseStyle: "data", throwOnError: true });
803
- const nextSessionID = response?.session?.id ?? response?.id;
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
- await this.persist(this.lease.refresh(state));
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