@grinev/opencode-telegram-bot 0.20.0 → 0.20.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.
@@ -292,7 +292,7 @@ async function executeCommand(ctx, deps, params) {
292
292
  const model = storedModel.providerID && storedModel.modelID
293
293
  ? `${storedModel.providerID}/${storedModel.modelID}`
294
294
  : undefined;
295
- foregroundSessionState.markBusy(session.id);
295
+ foregroundSessionState.markBusy(session.id, session.directory);
296
296
  await markAttachedSessionBusy(session.id);
297
297
  assistantRunState.startRun(session.id, {
298
298
  startedAt: Date.now(),
@@ -198,7 +198,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
198
198
  fileCount: fileParts.length,
199
199
  };
200
200
  logger.info(`[Bot] Calling session.promptAsync (start-only) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
201
- foregroundSessionState.markBusy(currentSession.id);
201
+ foregroundSessionState.markBusy(currentSession.id, currentSession.directory);
202
202
  await markAttachedSessionBusy(currentSession.id);
203
203
  assistantRunState.startRun(currentSession.id, {
204
204
  startedAt: Date.now(),
package/dist/bot/index.js CHANGED
@@ -59,6 +59,7 @@ import { clearPromptResponseMode, processUserPrompt } from "./handlers/prompt.js
59
59
  import { handleVoiceMessage } from "./handlers/voice.js";
60
60
  import { handleDocumentMessage } from "./handlers/document.js";
61
61
  import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
62
+ import { reconcileBusyState } from "./utils/busy-reconciliation.js";
62
63
  import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
63
64
  import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
64
65
  import { deliverThinkingMessage } from "./utils/thinking-message.js";
@@ -800,6 +801,9 @@ async function ensureEventSubscription(directory) {
800
801
  });
801
802
  logger.info(`[Bot] Subscribing to OpenCode events for project: ${directory}`);
802
803
  subscribeToEvents(directory, (event) => {
804
+ if (event.type === "server.heartbeat") {
805
+ void reconcileBusyState(directory);
806
+ }
803
807
  const attached = attachManager.getSnapshot();
804
808
  const eventSessionId = getEventSessionId(event);
805
809
  if (attached &&
@@ -0,0 +1,113 @@
1
+ import { opencodeClient } from "../../opencode/client.js";
2
+ import { foregroundSessionState, } from "../../scheduled-task/foreground-state.js";
3
+ import { scheduledTaskRuntime } from "../../scheduled-task/runtime.js";
4
+ import { attachManager } from "../../attach/manager.js";
5
+ import { markAttachedSessionBusy, markAttachedSessionIdle } from "../../attach/service.js";
6
+ import { assistantRunState } from "../assistant-run-state.js";
7
+ import { clearPromptResponseMode } from "../handlers/prompt.js";
8
+ import { logger } from "../../utils/logger.js";
9
+ const RECONCILE_MIN_INTERVAL_MS = 10_000;
10
+ const FOREGROUND_BUSY_RECONCILE_GRACE_MS = 2_000;
11
+ const inFlightDirectories = new Set();
12
+ const lastReconcileAtByDirectory = new Map();
13
+ function getReconciliationTargets(directory) {
14
+ const foregroundBusySessions = foregroundSessionState
15
+ .getBusySessions()
16
+ .filter((session) => session.directory === directory);
17
+ const attachedSession = attachManager.getSnapshot();
18
+ const attachedSessionForDirectory = attachedSession?.directory === directory ? attachedSession : null;
19
+ return { foregroundBusySessions, attachedSessionForDirectory };
20
+ }
21
+ function getSessionStatus(statuses, sessionId) {
22
+ if (!statuses || typeof statuses !== "object") {
23
+ return null;
24
+ }
25
+ const status = statuses[sessionId];
26
+ return status ?? null;
27
+ }
28
+ function isTerminalStatus(status) {
29
+ return !status || status.type === "idle" || status.type === "error";
30
+ }
31
+ function isWithinForegroundBusyGracePeriod(session, now) {
32
+ return now - session.markedAt < FOREGROUND_BUSY_RECONCILE_GRACE_MS;
33
+ }
34
+ async function clearForegroundBusySession(sessionId, reason) {
35
+ foregroundSessionState.markIdle(sessionId);
36
+ assistantRunState.clearRun(sessionId, reason);
37
+ clearPromptResponseMode(sessionId);
38
+ }
39
+ export async function reconcileBusyStateNow(directory, now = Date.now()) {
40
+ if (!directory) {
41
+ return;
42
+ }
43
+ const { foregroundBusySessions, attachedSessionForDirectory } = getReconciliationTargets(directory);
44
+ if (foregroundBusySessions.length === 0 && !attachedSessionForDirectory) {
45
+ return;
46
+ }
47
+ const { data: statuses, error } = await opencodeClient.session.status({ directory });
48
+ if (error || !statuses) {
49
+ logger.warn("[BusyReconciliation] Failed to load session status", error);
50
+ return;
51
+ }
52
+ const freshForegroundSessionIds = new Set(foregroundBusySessions
53
+ .filter((session) => isWithinForegroundBusyGracePeriod(session, now))
54
+ .map((session) => session.sessionId));
55
+ if (attachedSessionForDirectory) {
56
+ const attachedStatus = getSessionStatus(statuses, attachedSessionForDirectory.sessionId);
57
+ if (attachedStatus?.type === "busy") {
58
+ await markAttachedSessionBusy(attachedSessionForDirectory.sessionId);
59
+ }
60
+ else if (isTerminalStatus(attachedStatus) &&
61
+ !freshForegroundSessionIds.has(attachedSessionForDirectory.sessionId)) {
62
+ await markAttachedSessionIdle(attachedSessionForDirectory.sessionId);
63
+ }
64
+ }
65
+ let clearedForegroundSession = false;
66
+ for (const session of foregroundBusySessions) {
67
+ const status = getSessionStatus(statuses, session.sessionId);
68
+ if (!isTerminalStatus(status)) {
69
+ continue;
70
+ }
71
+ if (freshForegroundSessionIds.has(session.sessionId)) {
72
+ logger.debug(`[BusyReconciliation] Skipping fresh foreground busy state: session=${session.sessionId}, directory=${session.directory}, status=${status?.type ?? "not-found"}`);
73
+ continue;
74
+ }
75
+ logger.info(`[BusyReconciliation] Clearing stale foreground busy state: session=${session.sessionId}, directory=${session.directory}, status=${status?.type ?? "not-found"}`);
76
+ if (attachedSessionForDirectory?.sessionId !== session.sessionId) {
77
+ await markAttachedSessionIdle(session.sessionId);
78
+ }
79
+ await clearForegroundBusySession(session.sessionId, "status_reconcile_idle");
80
+ clearedForegroundSession = true;
81
+ }
82
+ if (clearedForegroundSession) {
83
+ await scheduledTaskRuntime.flushDeferredDeliveries();
84
+ }
85
+ }
86
+ export async function reconcileBusyState(directory, now = Date.now()) {
87
+ if (!directory || inFlightDirectories.has(directory)) {
88
+ return;
89
+ }
90
+ const { foregroundBusySessions, attachedSessionForDirectory } = getReconciliationTargets(directory);
91
+ if (foregroundBusySessions.length === 0 && !attachedSessionForDirectory) {
92
+ return;
93
+ }
94
+ const lastReconcileAt = lastReconcileAtByDirectory.get(directory);
95
+ if (lastReconcileAt !== undefined && now - lastReconcileAt < RECONCILE_MIN_INTERVAL_MS) {
96
+ return;
97
+ }
98
+ lastReconcileAtByDirectory.set(directory, now);
99
+ inFlightDirectories.add(directory);
100
+ try {
101
+ await reconcileBusyStateNow(directory, now);
102
+ }
103
+ catch (error) {
104
+ logger.warn("[BusyReconciliation] Failed to reconcile busy state", error);
105
+ }
106
+ finally {
107
+ inFlightDirectories.delete(directory);
108
+ }
109
+ }
110
+ export function __resetBusyReconciliationForTests() {
111
+ inFlightDirectories.clear();
112
+ lastReconcileAtByDirectory.clear();
113
+ }
@@ -1,32 +1,42 @@
1
1
  import { logger } from "../utils/logger.js";
2
2
  class ForegroundSessionState {
3
- activeSessionIds = new Set();
4
- markBusy(sessionId) {
5
- if (!sessionId) {
3
+ activeSessions = new Map();
4
+ markBusy(sessionId, directory) {
5
+ if (!sessionId || !directory) {
6
6
  return;
7
7
  }
8
- this.activeSessionIds.add(sessionId);
9
- logger.debug(`[ScheduledTaskForeground] Marked session busy: session=${sessionId}, count=${this.activeSessionIds.size}`);
8
+ this.activeSessions.set(sessionId, { sessionId, directory, markedAt: Date.now() });
9
+ logger.debug(`[ScheduledTaskForeground] Marked session busy: session=${sessionId}, directory=${directory}, count=${this.activeSessions.size}`);
10
10
  }
11
11
  markIdle(sessionId) {
12
12
  if (!sessionId) {
13
13
  return;
14
14
  }
15
- this.activeSessionIds.delete(sessionId);
16
- logger.debug(`[ScheduledTaskForeground] Marked session idle: session=${sessionId}, count=${this.activeSessionIds.size}`);
15
+ this.activeSessions.delete(sessionId);
16
+ logger.debug(`[ScheduledTaskForeground] Marked session idle: session=${sessionId}, count=${this.activeSessions.size}`);
17
+ }
18
+ getBusySessions() {
19
+ return Array.from(this.activeSessions.values(), (session) => ({ ...session }));
17
20
  }
18
21
  isBusy() {
19
- return this.activeSessionIds.size > 0;
22
+ return this.activeSessions.size > 0;
20
23
  }
21
24
  clearAll(reason) {
22
- if (this.activeSessionIds.size === 0) {
25
+ if (this.activeSessions.size === 0) {
23
26
  return;
24
27
  }
25
- logger.info(`[ScheduledTaskForeground] Cleared foreground busy state: reason=${reason}, count=${this.activeSessionIds.size}`);
26
- this.activeSessionIds.clear();
28
+ logger.info(`[ScheduledTaskForeground] Cleared foreground busy state: reason=${reason}, count=${this.activeSessions.size}`);
29
+ this.activeSessions.clear();
27
30
  }
28
31
  __resetForTests() {
29
- this.activeSessionIds.clear();
32
+ this.activeSessions.clear();
33
+ }
34
+ __setMarkedAtForTests(sessionId, markedAt) {
35
+ const session = this.activeSessions.get(sessionId);
36
+ if (!session) {
37
+ return;
38
+ }
39
+ this.activeSessions.set(sessionId, { ...session, markedAt });
30
40
  }
31
41
  }
32
42
  export const foregroundSessionState = new ForegroundSessionState();
@@ -173,6 +173,10 @@ class SummaryAggregator {
173
173
  this.handleMessagePartDelta(event);
174
174
  return;
175
175
  }
176
+ if (eventType === "server.heartbeat") {
177
+ logger.debug("[Aggregator] Heartbeat received");
178
+ return;
179
+ }
176
180
  // Log all question-related events for debugging
177
181
  if (event.type.startsWith("question.")) {
178
182
  logger.info(`[Aggregator] Question event: ${event.type}`, JSON.stringify(event.properties, null, 2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",