@grinev/opencode-telegram-bot 0.19.1 → 0.19.3
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/app/start-bot-app.js +4 -3
- package/dist/attach/service.js +34 -6
- package/dist/bot/commands/opencode-start.js +3 -0
- package/dist/bot/index.js +16 -8
- package/dist/bot/utils/external-user-input.js +10 -2
- package/dist/bot/utils/send-with-markdown-fallback.js +14 -18
- package/dist/bot/utils/telegram-text.js +3 -9
- package/dist/model/context-limit.js +13 -2
- package/dist/model/manager.js +67 -6
- package/dist/opencode/auto-restart.js +14 -2
- package/dist/opencode/events.js +13 -2
- package/dist/opencode/ready-lifecycle.js +45 -0
- package/dist/opencode/ready-refresh.js +55 -0
- package/dist/pinned/manager.js +43 -7
- package/dist/telegram/render/inline-renderer.js +10 -13
- package/dist/telegram/render/validator.js +29 -3
- package/dist/utils/opencode-error.js +13 -0
- package/package.json +1 -1
|
@@ -3,9 +3,9 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { cleanupBotRuntime, createBot } from "../bot/index.js";
|
|
4
4
|
import { config } from "../config.js";
|
|
5
5
|
import { opencodeAutoRestartService } from "../opencode/auto-restart.js";
|
|
6
|
+
import { notifyOpencodeReadyIfHealthy, registerOpenCodeReadyRefreshHandler, } from "../opencode/ready-refresh.js";
|
|
6
7
|
import { loadSettings } from "../settings/manager.js";
|
|
7
8
|
import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
|
|
8
|
-
import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
|
|
9
9
|
import { reconcileStoredModelSelection } from "../model/manager.js";
|
|
10
10
|
import { getRuntimeMode } from "../runtime/mode.js";
|
|
11
11
|
import { getRuntimePaths } from "../runtime/paths.js";
|
|
@@ -40,10 +40,11 @@ export async function startBotApp() {
|
|
|
40
40
|
logger.debug(`[Runtime] Application start mode: ${mode}`);
|
|
41
41
|
await loadSettings();
|
|
42
42
|
await reconcileStoredModelSelection();
|
|
43
|
-
|
|
44
|
-
await warmupSessionDirectoryCache();
|
|
43
|
+
registerOpenCodeReadyRefreshHandler();
|
|
45
44
|
const bot = createBot();
|
|
46
45
|
await scheduledTaskRuntime.initialize(bot);
|
|
46
|
+
await opencodeAutoRestartService.start();
|
|
47
|
+
await notifyOpencodeReadyIfHealthy("startup");
|
|
47
48
|
let shutdownStarted = false;
|
|
48
49
|
let serviceStateCleared = false;
|
|
49
50
|
let shutdownTimeout = null;
|
package/dist/attach/service.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { opencodeClient } from "../opencode/client.js";
|
|
2
2
|
import { stopEventListening } from "../opencode/events.js";
|
|
3
|
+
import { isOpencodeServerHealthy } from "../opencode/ready-refresh.js";
|
|
3
4
|
import { summaryAggregator } from "../summary/aggregator.js";
|
|
4
5
|
import { pinnedMessageManager } from "../pinned/manager.js";
|
|
5
6
|
import { keyboardManager } from "../keyboard/manager.js";
|
|
@@ -11,6 +12,7 @@ import { getCurrentSession } from "../session/manager.js";
|
|
|
11
12
|
import { getCurrentProject } from "../settings/manager.js";
|
|
12
13
|
import { attachManager } from "./manager.js";
|
|
13
14
|
import { logger } from "../utils/logger.js";
|
|
15
|
+
import { isExpectedOpencodeUnavailableError } from "../utils/opencode-error.js";
|
|
14
16
|
function getAttachBusyStatus(sessionId, statuses) {
|
|
15
17
|
if (!statuses || typeof statuses !== "object") {
|
|
16
18
|
return false;
|
|
@@ -18,13 +20,16 @@ function getAttachBusyStatus(sessionId, statuses) {
|
|
|
18
20
|
const sessionStatus = statuses[sessionId];
|
|
19
21
|
return sessionStatus?.type === "busy";
|
|
20
22
|
}
|
|
21
|
-
async function ensureAttachPinnedSession({ api, chatId, session, }) {
|
|
23
|
+
async function ensureAttachPinnedSession({ api, chatId, session, forceFullRestore = false, }) {
|
|
22
24
|
if (!pinnedMessageManager.isInitialized()) {
|
|
23
25
|
pinnedMessageManager.initialize(api, chatId);
|
|
24
26
|
}
|
|
25
27
|
keyboardManager.initialize(api, chatId);
|
|
26
28
|
const pinnedState = pinnedMessageManager.getState();
|
|
27
29
|
if (pinnedState.sessionId === session.id && pinnedState.messageId) {
|
|
30
|
+
if (forceFullRestore) {
|
|
31
|
+
await pinnedMessageManager.loadContextFromHistory(session.id, session.directory);
|
|
32
|
+
}
|
|
28
33
|
return;
|
|
29
34
|
}
|
|
30
35
|
if (pinnedState.messageId && pinnedState.sessionId === null) {
|
|
@@ -51,7 +56,12 @@ async function restorePendingQuestion(bot, chatId, sessionId, directory) {
|
|
|
51
56
|
directory,
|
|
52
57
|
});
|
|
53
58
|
if (error || !data) {
|
|
54
|
-
|
|
59
|
+
if (isExpectedOpencodeUnavailableError(error)) {
|
|
60
|
+
logger.warn("[Attach] OpenCode server unavailable; skipping pending question restore");
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
logger.warn("[Attach] Failed to load pending questions during attach:", error);
|
|
64
|
+
}
|
|
55
65
|
return false;
|
|
56
66
|
}
|
|
57
67
|
const pendingQuestion = data.find((request) => request.sessionID === sessionId);
|
|
@@ -67,7 +77,12 @@ async function restorePendingPermissions(bot, chatId, sessionId, directory) {
|
|
|
67
77
|
directory,
|
|
68
78
|
});
|
|
69
79
|
if (error || !data) {
|
|
70
|
-
|
|
80
|
+
if (isExpectedOpencodeUnavailableError(error)) {
|
|
81
|
+
logger.warn("[Attach] OpenCode server unavailable; skipping pending permission restore");
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
logger.warn("[Attach] Failed to load pending permissions during attach:", error);
|
|
85
|
+
}
|
|
71
86
|
return 0;
|
|
72
87
|
}
|
|
73
88
|
const pendingPermissions = data.filter((request) => request.sessionID === sessionId);
|
|
@@ -77,12 +92,13 @@ async function restorePendingPermissions(bot, chatId, sessionId, directory) {
|
|
|
77
92
|
return pendingPermissions.length;
|
|
78
93
|
}
|
|
79
94
|
export async function attachToSession(deps) {
|
|
80
|
-
const { bot, chatId, session, ensureEventSubscription } = deps;
|
|
95
|
+
const { bot, chatId, session, ensureEventSubscription, forceFullRestore = false } = deps;
|
|
81
96
|
const alreadyAttached = attachManager.isAttachedSession(session.id, session.directory);
|
|
82
97
|
await ensureAttachPinnedSession({
|
|
83
98
|
api: bot.api,
|
|
84
99
|
chatId,
|
|
85
100
|
session,
|
|
101
|
+
forceFullRestore,
|
|
86
102
|
});
|
|
87
103
|
if (!alreadyAttached) {
|
|
88
104
|
await ensureEventSubscription(session.directory);
|
|
@@ -98,7 +114,12 @@ export async function attachToSession(deps) {
|
|
|
98
114
|
directory: session.directory,
|
|
99
115
|
});
|
|
100
116
|
if (statusesError) {
|
|
101
|
-
|
|
117
|
+
if (isExpectedOpencodeUnavailableError(statusesError)) {
|
|
118
|
+
logger.warn("[Attach] OpenCode server unavailable; skipping session status restore");
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
logger.warn("[Attach] Failed to load session status during attach:", statusesError);
|
|
122
|
+
}
|
|
102
123
|
}
|
|
103
124
|
const busy = getAttachBusyStatus(session.id, statuses);
|
|
104
125
|
if (busy) {
|
|
@@ -110,7 +131,9 @@ export async function attachToSession(deps) {
|
|
|
110
131
|
await syncPinnedAttachState();
|
|
111
132
|
let restoredQuestion = false;
|
|
112
133
|
let restoredPermissions = 0;
|
|
113
|
-
if (!alreadyAttached
|
|
134
|
+
if ((!alreadyAttached || forceFullRestore) &&
|
|
135
|
+
!questionManager.isActive() &&
|
|
136
|
+
!permissionManager.isActive()) {
|
|
114
137
|
restoredQuestion = await restorePendingQuestion(bot, chatId, session.id, session.directory);
|
|
115
138
|
if (!restoredQuestion) {
|
|
116
139
|
restoredPermissions = await restorePendingPermissions(bot, chatId, session.id, session.directory);
|
|
@@ -134,11 +157,16 @@ export async function restoreAttachedCurrentSession(deps) {
|
|
|
134
157
|
return false;
|
|
135
158
|
}
|
|
136
159
|
try {
|
|
160
|
+
if (!(await isOpencodeServerHealthy())) {
|
|
161
|
+
logger.warn(`[Attach] OpenCode server is unavailable; skipping followed session restore: session=${currentSession.id}, directory=${currentSession.directory}`);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
137
164
|
await attachToSession({
|
|
138
165
|
bot: deps.bot,
|
|
139
166
|
chatId: deps.chatId,
|
|
140
167
|
session: currentSession,
|
|
141
168
|
ensureEventSubscription: deps.ensureEventSubscription,
|
|
169
|
+
forceFullRestore: deps.forceFullRestore,
|
|
142
170
|
});
|
|
143
171
|
logger.info(`[Attach] Restored followed session on startup: session=${currentSession.id}, directory=${currentSession.directory}`);
|
|
144
172
|
return true;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { config } from "../../config.js";
|
|
2
2
|
import { opencodeClient } from "../../opencode/client.js";
|
|
3
3
|
import { resolveLocalOpencodeTarget, startLocalOpencodeServer } from "../../opencode/process.js";
|
|
4
|
+
import { opencodeReadyLifecycle } from "../../opencode/ready-lifecycle.js";
|
|
4
5
|
import { logger } from "../../utils/logger.js";
|
|
5
6
|
import { t } from "../../i18n/index.js";
|
|
6
7
|
import { editBotText } from "../utils/telegram-text.js";
|
|
@@ -42,6 +43,7 @@ export async function opencodeStartCommand(ctx) {
|
|
|
42
43
|
const { data, error } = await opencodeClient.global.health();
|
|
43
44
|
if (!error && data?.healthy) {
|
|
44
45
|
await ctx.reply(t("opencode_start.already_running", { version: data.version || t("common.unknown") }));
|
|
46
|
+
await opencodeReadyLifecycle.notifyReady("opencode_start_already_running");
|
|
45
47
|
return;
|
|
46
48
|
}
|
|
47
49
|
}
|
|
@@ -88,6 +90,7 @@ export async function opencodeStartCommand(ctx) {
|
|
|
88
90
|
}),
|
|
89
91
|
});
|
|
90
92
|
logger.info(`[Bot] OpenCode server started successfully, PID=${pid}, port=${localTarget.port}`);
|
|
93
|
+
await opencodeReadyLifecycle.notifyReady("opencode_start_success");
|
|
91
94
|
}
|
|
92
95
|
catch (err) {
|
|
93
96
|
logger.error("[Bot] Error in /opencode-start command:", err);
|
package/dist/bot/index.js
CHANGED
|
@@ -40,6 +40,7 @@ import { interactionManager } from "../interaction/manager.js";
|
|
|
40
40
|
import { clearAllInteractionState } from "../interaction/cleanup.js";
|
|
41
41
|
import { keyboardManager } from "../keyboard/manager.js";
|
|
42
42
|
import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
|
|
43
|
+
import { opencodeReadyLifecycle } from "../opencode/ready-lifecycle.js";
|
|
43
44
|
import { summaryAggregator } from "../summary/aggregator.js";
|
|
44
45
|
import { formatToolInfo } from "../summary/formatter.js";
|
|
45
46
|
import { renderSubagentCards } from "../summary/subagent-formatter.js";
|
|
@@ -76,6 +77,7 @@ let botInstance = null;
|
|
|
76
77
|
let chatIdInstance = null;
|
|
77
78
|
let commandsInitialized = false;
|
|
78
79
|
let heartbeatTimer = null;
|
|
80
|
+
let unsubscribeReadyRestore = null;
|
|
79
81
|
const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
|
|
80
82
|
const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
|
|
81
83
|
const RESPONSE_STREAM_TEXT_LIMIT = 3800;
|
|
@@ -809,6 +811,18 @@ export function createBot() {
|
|
|
809
811
|
const bot = new Bot(config.telegram.token, botOptions);
|
|
810
812
|
botInstance = bot;
|
|
811
813
|
chatIdInstance = config.telegram.allowedUserId;
|
|
814
|
+
unsubscribeReadyRestore?.();
|
|
815
|
+
unsubscribeReadyRestore = opencodeReadyLifecycle.onReady(async (reason) => {
|
|
816
|
+
const restored = await restoreAttachedCurrentSession({
|
|
817
|
+
bot,
|
|
818
|
+
chatId: config.telegram.allowedUserId,
|
|
819
|
+
ensureEventSubscription,
|
|
820
|
+
forceFullRestore: true,
|
|
821
|
+
});
|
|
822
|
+
if (restored) {
|
|
823
|
+
logger.info(`[Bot] Restored followed session after OpenCode ready: reason=${reason}`);
|
|
824
|
+
}
|
|
825
|
+
});
|
|
812
826
|
// Heartbeat for diagnostics: verify the event loop is not blocked
|
|
813
827
|
let heartbeatCounter = 0;
|
|
814
828
|
heartbeatTimer = setInterval(() => {
|
|
@@ -1024,14 +1038,6 @@ export function createBot() {
|
|
|
1024
1038
|
});
|
|
1025
1039
|
// Voice and audio message handlers (STT transcription -> prompt)
|
|
1026
1040
|
const voicePromptDeps = { bot, ensureEventSubscription };
|
|
1027
|
-
safeBackgroundTask({
|
|
1028
|
-
taskName: "bot.restoreFollowedSession",
|
|
1029
|
-
task: () => restoreAttachedCurrentSession({
|
|
1030
|
-
bot,
|
|
1031
|
-
chatId: config.telegram.allowedUserId,
|
|
1032
|
-
ensureEventSubscription,
|
|
1033
|
-
}),
|
|
1034
|
-
});
|
|
1035
1041
|
bot.on("message:voice", async (ctx) => {
|
|
1036
1042
|
logger.debug(`[Bot] Received voice message, chatId=${ctx.chat.id}`);
|
|
1037
1043
|
botInstance = bot;
|
|
@@ -1146,6 +1152,8 @@ export function createBot() {
|
|
|
1146
1152
|
return bot;
|
|
1147
1153
|
}
|
|
1148
1154
|
export function cleanupBotRuntime(reason) {
|
|
1155
|
+
unsubscribeReadyRestore?.();
|
|
1156
|
+
unsubscribeReadyRestore = null;
|
|
1149
1157
|
stopEventListening();
|
|
1150
1158
|
summaryAggregator.clear();
|
|
1151
1159
|
responseStreamer.clearAll(reason);
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { t } from "../../i18n/index.js";
|
|
2
2
|
import { escapePlainTextForTelegramMarkdownV2 } from "../../summary/formatter.js";
|
|
3
3
|
import { sendBotText } from "./telegram-text.js";
|
|
4
|
+
const EXTERNAL_USER_INPUT_MAX_DISPLAY_LENGTH = 2000;
|
|
4
5
|
function normalizeExternalUserInputText(text) {
|
|
5
6
|
return text.replace(/\r\n/g, "\n").trim();
|
|
6
7
|
}
|
|
8
|
+
function truncateExternalUserInputText(text) {
|
|
9
|
+
if (text.length <= EXTERNAL_USER_INPUT_MAX_DISPLAY_LENGTH) {
|
|
10
|
+
return text;
|
|
11
|
+
}
|
|
12
|
+
return `${text.slice(0, EXTERNAL_USER_INPUT_MAX_DISPLAY_LENGTH - 3)}...`;
|
|
13
|
+
}
|
|
7
14
|
function buildQuotedPlainText(text) {
|
|
8
15
|
return text
|
|
9
16
|
.split("\n")
|
|
@@ -21,10 +28,11 @@ export function buildExternalUserInputNotification(text) {
|
|
|
21
28
|
if (!normalizedText) {
|
|
22
29
|
return null;
|
|
23
30
|
}
|
|
31
|
+
const displayText = truncateExternalUserInputText(normalizedText);
|
|
24
32
|
const title = `👤 ${t("bot.external_user_input")}`;
|
|
25
33
|
return {
|
|
26
|
-
text: `${escapePlainTextForTelegramMarkdownV2(title)}\n\n${buildQuotedMarkdownText(
|
|
27
|
-
rawFallbackText: `${title}\n\n${buildQuotedPlainText(
|
|
34
|
+
text: `${escapePlainTextForTelegramMarkdownV2(title)}\n\n${buildQuotedMarkdownText(displayText)}`,
|
|
35
|
+
rawFallbackText: `${title}\n\n${buildQuotedPlainText(displayText)}`,
|
|
28
36
|
};
|
|
29
37
|
}
|
|
30
38
|
export async function deliverExternalUserInputNotification({ api, chatId, currentSessionId, sessionId, text, consumeSuppressedInput, }) {
|
|
@@ -6,6 +6,7 @@ const MARKDOWN_PARSE_ERROR_MARKERS = [
|
|
|
6
6
|
"entity beginning",
|
|
7
7
|
"bad request: can't parse",
|
|
8
8
|
];
|
|
9
|
+
const TELEGRAM_ENTITY_URL_ERROR_MARKERS = ["entity url", "wrong http url", "url host is empty"];
|
|
9
10
|
const MARKDOWN_V2_RESERVED_CHARS = new Set([
|
|
10
11
|
"_",
|
|
11
12
|
"*",
|
|
@@ -91,6 +92,13 @@ export function isTelegramMarkdownParseError(error) {
|
|
|
91
92
|
}
|
|
92
93
|
return MARKDOWN_PARSE_ERROR_MARKERS.some((marker) => errorText.includes(marker));
|
|
93
94
|
}
|
|
95
|
+
export function isTelegramEntityUrlError(error) {
|
|
96
|
+
const errorText = getErrorText(error);
|
|
97
|
+
if (!errorText) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
return TELEGRAM_ENTITY_URL_ERROR_MARKERS.some((marker) => errorText.includes(marker));
|
|
101
|
+
}
|
|
94
102
|
export async function sendMessageWithMarkdownFallback({ api, chatId, text, rawFallbackText, options, parseMode, }) {
|
|
95
103
|
if (!parseMode) {
|
|
96
104
|
return api.sendMessage(chatId, text, options);
|
|
@@ -104,10 +112,7 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, rawFa
|
|
|
104
112
|
return await api.sendMessage(chatId, text, markdownOptions);
|
|
105
113
|
}
|
|
106
114
|
catch (error) {
|
|
107
|
-
if (
|
|
108
|
-
throw error;
|
|
109
|
-
}
|
|
110
|
-
if (parseMode === "MarkdownV2") {
|
|
115
|
+
if (parseMode === "MarkdownV2" && isTelegramMarkdownParseError(error)) {
|
|
111
116
|
const escapedText = escapeTelegramMarkdownV2(text);
|
|
112
117
|
if (escapedText !== text) {
|
|
113
118
|
logger.warn("[Bot] Markdown parse failed, retrying message with escaped MarkdownV2", error);
|
|
@@ -115,15 +120,12 @@ export async function sendMessageWithMarkdownFallback({ api, chatId, text, rawFa
|
|
|
115
120
|
return await api.sendMessage(chatId, escapedText, markdownOptions);
|
|
116
121
|
}
|
|
117
122
|
catch (escapedError) {
|
|
118
|
-
|
|
119
|
-
throw escapedError;
|
|
120
|
-
}
|
|
121
|
-
logger.warn("[Bot] Escaped Markdown parse failed, retrying message in raw mode", escapedError);
|
|
123
|
+
logger.warn("[Bot] Escaped Markdown send failed, retrying message in raw mode", escapedError);
|
|
122
124
|
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
123
125
|
}
|
|
124
126
|
}
|
|
125
127
|
}
|
|
126
|
-
logger.warn("[Bot]
|
|
128
|
+
logger.warn("[Bot] Formatted message send failed, retrying message in raw mode", error);
|
|
127
129
|
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
128
130
|
}
|
|
129
131
|
}
|
|
@@ -140,10 +142,7 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
|
|
|
140
142
|
return await api.editMessageText(chatId, messageId, text, markdownOptions);
|
|
141
143
|
}
|
|
142
144
|
catch (error) {
|
|
143
|
-
if (
|
|
144
|
-
throw error;
|
|
145
|
-
}
|
|
146
|
-
if (parseMode === "MarkdownV2") {
|
|
145
|
+
if (parseMode === "MarkdownV2" && isTelegramMarkdownParseError(error)) {
|
|
147
146
|
const escapedText = escapeTelegramMarkdownV2(text);
|
|
148
147
|
if (escapedText !== text) {
|
|
149
148
|
logger.warn("[Bot] Markdown parse failed, retrying edited message with escaped MarkdownV2", error);
|
|
@@ -151,15 +150,12 @@ export async function editMessageWithMarkdownFallback({ api, chatId, messageId,
|
|
|
151
150
|
return await api.editMessageText(chatId, messageId, escapedText, markdownOptions);
|
|
152
151
|
}
|
|
153
152
|
catch (escapedError) {
|
|
154
|
-
|
|
155
|
-
throw escapedError;
|
|
156
|
-
}
|
|
157
|
-
logger.warn("[Bot] Escaped Markdown parse failed, retrying edited message in raw mode", escapedError);
|
|
153
|
+
logger.warn("[Bot] Escaped Markdown edit failed, retrying edited message in raw mode", escapedError);
|
|
158
154
|
return api.editMessageText(chatId, messageId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
159
155
|
}
|
|
160
156
|
}
|
|
161
157
|
}
|
|
162
|
-
logger.warn("[Bot]
|
|
158
|
+
logger.warn("[Bot] Formatted message edit failed, retrying edited message in raw mode", error);
|
|
163
159
|
return api.editMessageText(chatId, messageId, fallbackText, stripMarkdownFormattingOptions(options));
|
|
164
160
|
}
|
|
165
161
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { logger } from "../../utils/logger.js";
|
|
2
|
-
import { editMessageWithMarkdownFallback,
|
|
2
|
+
import { editMessageWithMarkdownFallback, sendMessageWithMarkdownFallback, } from "./send-with-markdown-fallback.js";
|
|
3
3
|
function resolveParseMode(format) {
|
|
4
4
|
if (format === "markdown_v2") {
|
|
5
5
|
return "MarkdownV2";
|
|
@@ -56,10 +56,7 @@ export async function sendRenderedBotPart({ api, chatId, part, options, }) {
|
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
58
|
catch (error) {
|
|
59
|
-
|
|
60
|
-
throw error;
|
|
61
|
-
}
|
|
62
|
-
logger.warn("[Bot] Entity payload rejected, retrying assistant message part in raw mode", error);
|
|
59
|
+
logger.warn("[Bot] Entity payload send failed, retrying assistant message part in raw mode", error);
|
|
63
60
|
const sentMessage = await api.sendMessage(chatId, part.fallbackText, rawOptions);
|
|
64
61
|
logger.debug("[Bot] Assistant message part sent in raw fallback mode", {
|
|
65
62
|
fallbackTextLength: part.fallbackText.length,
|
|
@@ -95,10 +92,7 @@ export async function editRenderedBotPart({ api, chatId, messageId, part, option
|
|
|
95
92
|
};
|
|
96
93
|
}
|
|
97
94
|
catch (error) {
|
|
98
|
-
|
|
99
|
-
throw error;
|
|
100
|
-
}
|
|
101
|
-
logger.warn("[Bot] Entity payload rejected, retrying assistant edit part in raw mode", error);
|
|
95
|
+
logger.warn("[Bot] Entity payload edit failed, retrying assistant edit part in raw mode", error);
|
|
102
96
|
await api.editMessageText(chatId, messageId, part.fallbackText, rawOptions);
|
|
103
97
|
logger.debug("[Bot] Assistant edit part applied in raw fallback mode", {
|
|
104
98
|
messageId,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { opencodeClient } from "../opencode/client.js";
|
|
2
2
|
import { logger } from "../utils/logger.js";
|
|
3
3
|
import { DEFAULT_CONTEXT_LIMIT } from "../pinned/format.js";
|
|
4
|
+
import { isExpectedOpencodeUnavailableError } from "../utils/opencode-error.js";
|
|
4
5
|
const PROVIDER_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
5
6
|
const contextLimitCache = new Map();
|
|
6
7
|
let providersCacheExpiresAt = 0;
|
|
@@ -20,7 +21,12 @@ async function refreshContextLimitCache() {
|
|
|
20
21
|
try {
|
|
21
22
|
const { data, error } = await opencodeClient.config.providers();
|
|
22
23
|
if (error || !data) {
|
|
23
|
-
|
|
24
|
+
if (isExpectedOpencodeUnavailableError(error)) {
|
|
25
|
+
logger.warn("[ModelContextLimit] OpenCode server unavailable; using default context limit");
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
logger.warn("[ModelContextLimit] Failed to fetch providers:", error);
|
|
29
|
+
}
|
|
24
30
|
return;
|
|
25
31
|
}
|
|
26
32
|
contextLimitCache.clear();
|
|
@@ -35,7 +41,12 @@ async function refreshContextLimitCache() {
|
|
|
35
41
|
logger.debug(`[ModelContextLimit] Cached limits for ${contextLimitCache.size} provider/model pairs`);
|
|
36
42
|
}
|
|
37
43
|
catch (error) {
|
|
38
|
-
|
|
44
|
+
if (isExpectedOpencodeUnavailableError(error)) {
|
|
45
|
+
logger.warn("[ModelContextLimit] OpenCode server unavailable; using default context limit");
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
logger.warn("[ModelContextLimit] Error refreshing providers cache:", error);
|
|
49
|
+
}
|
|
39
50
|
}
|
|
40
51
|
finally {
|
|
41
52
|
providersFetchInFlight = null;
|
package/dist/model/manager.js
CHANGED
|
@@ -4,6 +4,12 @@ import { opencodeClient } from "../opencode/client.js";
|
|
|
4
4
|
import { logger } from "../utils/logger.js";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
const MODEL_CATALOG_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
7
|
+
const SERVER_UNAVAILABLE_ERROR_MARKERS = [
|
|
8
|
+
"fetch failed",
|
|
9
|
+
"econnrefused",
|
|
10
|
+
"connection refused",
|
|
11
|
+
"connect refused",
|
|
12
|
+
];
|
|
7
13
|
let cachedValidModelKeys = null;
|
|
8
14
|
let modelCatalogCacheExpiresAt = 0;
|
|
9
15
|
let modelCatalogFetchInFlight = null;
|
|
@@ -34,8 +40,63 @@ function filterModelsByCatalog(models, validModelKeys) {
|
|
|
34
40
|
}
|
|
35
41
|
return models.filter((model) => validModelKeys.has(getModelKey(model.providerID, model.modelID)));
|
|
36
42
|
}
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
function hasServerUnavailableMarker(value) {
|
|
44
|
+
const lower = value.toLowerCase();
|
|
45
|
+
return SERVER_UNAVAILABLE_ERROR_MARKERS.some((marker) => lower.includes(marker));
|
|
46
|
+
}
|
|
47
|
+
function isServerUnavailableError(error) {
|
|
48
|
+
const queue = [error];
|
|
49
|
+
const seen = new Set();
|
|
50
|
+
while (queue.length > 0) {
|
|
51
|
+
const current = queue.pop();
|
|
52
|
+
if (!current || seen.has(current)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
seen.add(current);
|
|
56
|
+
if (typeof current === "string") {
|
|
57
|
+
if (hasServerUnavailableMarker(current)) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (current instanceof Error) {
|
|
63
|
+
if (hasServerUnavailableMarker(`${current.name}: ${current.message}`)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
const errorWithCause = current;
|
|
67
|
+
if (errorWithCause.cause) {
|
|
68
|
+
queue.push(errorWithCause.cause);
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (typeof current === "object") {
|
|
73
|
+
const value = current;
|
|
74
|
+
if (typeof value.code === "string" && hasServerUnavailableMarker(value.code)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (typeof value.message === "string" && hasServerUnavailableMarker(value.message)) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (value.cause) {
|
|
81
|
+
queue.push(value.cause);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
function logModelCatalogRefreshFailure(error, type) {
|
|
88
|
+
if (isServerUnavailableError(error)) {
|
|
89
|
+
logger.warn("[ModelManager] OpenCode server is not running; skipping model catalog refresh");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (type === "error") {
|
|
93
|
+
logger.warn("[ModelManager] Failed to refresh model catalog:", error);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
logger.warn("[ModelManager] Error refreshing model catalog:", error);
|
|
97
|
+
}
|
|
98
|
+
async function getValidModelKeys(options) {
|
|
99
|
+
if (!options?.force && cachedValidModelKeys && Date.now() < modelCatalogCacheExpiresAt) {
|
|
39
100
|
logger.debug(`[ModelManager] Model catalog cache hit: models=${cachedValidModelKeys.size}, ttlMs=${modelCatalogCacheExpiresAt - Date.now()}`);
|
|
40
101
|
return cachedValidModelKeys;
|
|
41
102
|
}
|
|
@@ -48,7 +109,7 @@ async function getValidModelKeys() {
|
|
|
48
109
|
logger.debug("[ModelManager] Refreshing model catalog from OpenCode API");
|
|
49
110
|
const response = await opencodeClient.config.providers();
|
|
50
111
|
if (response.error || !response.data) {
|
|
51
|
-
|
|
112
|
+
logModelCatalogRefreshFailure(response.error, "error");
|
|
52
113
|
if (cachedValidModelKeys) {
|
|
53
114
|
logger.warn("[ModelManager] Using stale model catalog cache after refresh failure");
|
|
54
115
|
return cachedValidModelKeys;
|
|
@@ -67,7 +128,7 @@ async function getValidModelKeys() {
|
|
|
67
128
|
return cachedValidModelKeys;
|
|
68
129
|
}
|
|
69
130
|
catch (err) {
|
|
70
|
-
|
|
131
|
+
logModelCatalogRefreshFailure(err, "exception");
|
|
71
132
|
if (cachedValidModelKeys) {
|
|
72
133
|
logger.warn("[ModelManager] Using stale model catalog cache after refresh exception");
|
|
73
134
|
return cachedValidModelKeys;
|
|
@@ -171,12 +232,12 @@ export async function getModelSelectionLists() {
|
|
|
171
232
|
* Validate stored selected model against OpenCode providers catalog.
|
|
172
233
|
* If selected model is unavailable, fallback to env default model.
|
|
173
234
|
*/
|
|
174
|
-
export async function reconcileStoredModelSelection() {
|
|
235
|
+
export async function reconcileStoredModelSelection(options) {
|
|
175
236
|
const currentModel = getCurrentModel();
|
|
176
237
|
if (!currentModel?.providerID || !currentModel.modelID) {
|
|
177
238
|
return;
|
|
178
239
|
}
|
|
179
|
-
const validModelKeys = await getValidModelKeys();
|
|
240
|
+
const validModelKeys = await getValidModelKeys({ force: options?.forceCatalogRefresh });
|
|
180
241
|
if (!validModelKeys) {
|
|
181
242
|
logger.warn("[ModelManager] Skipping stored model validation: model catalog unavailable");
|
|
182
243
|
return;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
2
|
import { logger } from "../utils/logger.js";
|
|
3
3
|
import { opencodeClient } from "./client.js";
|
|
4
|
+
import { opencodeReadyLifecycle } from "./ready-lifecycle.js";
|
|
4
5
|
import { resolveLocalOpencodeTarget, startLocalOpencodeServer, } from "./process.js";
|
|
5
6
|
const SERVER_READY_TIMEOUT_MS = 10000;
|
|
6
7
|
const SERVER_READY_POLL_INTERVAL_MS = 500;
|
|
@@ -31,14 +32,15 @@ export class OpencodeAutoRestartService {
|
|
|
31
32
|
localTarget = null;
|
|
32
33
|
started = false;
|
|
33
34
|
checkInProgress = false;
|
|
35
|
+
serverWasHealthy = false;
|
|
34
36
|
async start() {
|
|
35
37
|
if (this.started || !config.opencode.autoRestartEnabled) {
|
|
36
|
-
return;
|
|
38
|
+
return false;
|
|
37
39
|
}
|
|
38
40
|
const localTarget = resolveLocalOpencodeTarget(config.opencode.apiUrl);
|
|
39
41
|
if (!localTarget) {
|
|
40
42
|
logger.warn(`[OpenCodeAutoRestart] Disabled because OPENCODE_API_URL is not local: ${config.opencode.apiUrl}`);
|
|
41
|
-
return;
|
|
43
|
+
return false;
|
|
42
44
|
}
|
|
43
45
|
this.started = true;
|
|
44
46
|
this.localTarget = localTarget;
|
|
@@ -48,6 +50,7 @@ export class OpencodeAutoRestartService {
|
|
|
48
50
|
void this.checkAndRestart("interval");
|
|
49
51
|
}, config.opencode.monitorIntervalSec * 1000);
|
|
50
52
|
this.timer.unref?.();
|
|
53
|
+
return true;
|
|
51
54
|
}
|
|
52
55
|
stop() {
|
|
53
56
|
if (this.timer) {
|
|
@@ -56,6 +59,7 @@ export class OpencodeAutoRestartService {
|
|
|
56
59
|
}
|
|
57
60
|
this.started = false;
|
|
58
61
|
this.localTarget = null;
|
|
62
|
+
this.serverWasHealthy = false;
|
|
59
63
|
}
|
|
60
64
|
async checkAndRestart(reason) {
|
|
61
65
|
if (this.checkInProgress || !this.localTarget) {
|
|
@@ -65,8 +69,14 @@ export class OpencodeAutoRestartService {
|
|
|
65
69
|
try {
|
|
66
70
|
if (await isOpencodeServerHealthy()) {
|
|
67
71
|
logger.debug(`[OpenCodeAutoRestart] Health-check succeeded: reason=${reason}`);
|
|
72
|
+
if (!this.serverWasHealthy) {
|
|
73
|
+
this.serverWasHealthy = true;
|
|
74
|
+
await opencodeReadyLifecycle.notifyReady(`auto_restart_${reason}`);
|
|
75
|
+
}
|
|
68
76
|
return;
|
|
69
77
|
}
|
|
78
|
+
this.serverWasHealthy = false;
|
|
79
|
+
opencodeReadyLifecycle.notifyUnavailable(`auto_restart_${reason}`);
|
|
70
80
|
logger.warn(`[OpenCodeAutoRestart] OpenCode server is unavailable, starting local server: reason=${reason}, port=${this.localTarget.port}`);
|
|
71
81
|
const childProcess = startLocalOpencodeServer(this.localTarget);
|
|
72
82
|
childProcess.once("error", (error) => {
|
|
@@ -80,6 +90,8 @@ export class OpencodeAutoRestartService {
|
|
|
80
90
|
return;
|
|
81
91
|
}
|
|
82
92
|
logger.info(`[OpenCodeAutoRestart] OpenCode server recovered: pid=${pid ?? "unknown"}, port=${this.localTarget.port}`);
|
|
93
|
+
this.serverWasHealthy = true;
|
|
94
|
+
await opencodeReadyLifecycle.notifyReady(`auto_restart_${reason}`);
|
|
83
95
|
}
|
|
84
96
|
catch (error) {
|
|
85
97
|
logger.error("[OpenCodeAutoRestart] Failed to check or restart OpenCode server", error);
|
package/dist/opencode/events.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { opencodeClient } from "./client.js";
|
|
2
2
|
import { logger } from "../utils/logger.js";
|
|
3
|
+
import { isExpectedOpencodeUnavailableError } from "../utils/opencode-error.js";
|
|
3
4
|
const RECONNECT_BASE_DELAY_MS = 1000;
|
|
4
5
|
const RECONNECT_MAX_DELAY_MS = 15000;
|
|
5
6
|
const FATAL_NO_STREAM_ERROR = "No stream returned from event subscription";
|
|
@@ -108,7 +109,12 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
108
109
|
}
|
|
109
110
|
reconnectAttempt++;
|
|
110
111
|
const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
|
|
111
|
-
|
|
112
|
+
if (isExpectedOpencodeUnavailableError(error)) {
|
|
113
|
+
logger.warn(`Event stream unavailable for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
logger.error(`Event stream error for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`, error);
|
|
117
|
+
}
|
|
112
118
|
const shouldContinue = await waitWithAbort(reconnectDelay, controller.signal);
|
|
113
119
|
if (!shouldContinue) {
|
|
114
120
|
break;
|
|
@@ -121,7 +127,12 @@ export async function subscribeToEvents(directory, callback) {
|
|
|
121
127
|
logger.info("Event listener aborted");
|
|
122
128
|
return;
|
|
123
129
|
}
|
|
124
|
-
|
|
130
|
+
if (isExpectedOpencodeUnavailableError(error)) {
|
|
131
|
+
logger.warn("Event stream unavailable; listener stopped");
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
logger.error("Event stream error:", error);
|
|
135
|
+
}
|
|
125
136
|
isListening = false;
|
|
126
137
|
activeDirectory = null;
|
|
127
138
|
streamAbortController = null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { logger } from "../utils/logger.js";
|
|
2
|
+
class OpencodeReadyLifecycle {
|
|
3
|
+
ready = false;
|
|
4
|
+
handlers = new Set();
|
|
5
|
+
onReady(handler) {
|
|
6
|
+
this.handlers.add(handler);
|
|
7
|
+
return () => {
|
|
8
|
+
this.handlers.delete(handler);
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
isReady() {
|
|
12
|
+
return this.ready;
|
|
13
|
+
}
|
|
14
|
+
async notifyReady(reason) {
|
|
15
|
+
if (this.ready) {
|
|
16
|
+
logger.debug(`[OpenCodeReady] Ready notification ignored: reason=${reason}`);
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
this.ready = true;
|
|
20
|
+
logger.info(`[OpenCodeReady] OpenCode server is ready: reason=${reason}`);
|
|
21
|
+
for (const handler of this.handlers) {
|
|
22
|
+
try {
|
|
23
|
+
await handler(reason);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
logger.warn(`[OpenCodeReady] Ready handler failed: reason=${reason}`, error);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
notifyUnavailable(reason) {
|
|
32
|
+
if (!this.ready) {
|
|
33
|
+
logger.debug(`[OpenCodeReady] Unavailable notification ignored: reason=${reason}`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
this.ready = false;
|
|
37
|
+
logger.warn(`[OpenCodeReady] OpenCode server became unavailable: reason=${reason}`);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
__resetForTests() {
|
|
41
|
+
this.ready = false;
|
|
42
|
+
this.handlers.clear();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export const opencodeReadyLifecycle = new OpencodeReadyLifecycle();
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { reconcileStoredModelSelection } from "../model/manager.js";
|
|
2
|
+
import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
|
|
3
|
+
import { logger } from "../utils/logger.js";
|
|
4
|
+
import { opencodeClient } from "./client.js";
|
|
5
|
+
import { opencodeReadyLifecycle } from "./ready-lifecycle.js";
|
|
6
|
+
let readyRefreshRegistered = false;
|
|
7
|
+
export async function isOpencodeServerHealthy() {
|
|
8
|
+
try {
|
|
9
|
+
const { data, error } = await opencodeClient.global.health();
|
|
10
|
+
return !error && data?.healthy === true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function refreshSessionCacheAfterOpencodeReady(reason) {
|
|
17
|
+
try {
|
|
18
|
+
await warmupSessionDirectoryCache();
|
|
19
|
+
logger.debug(`[OpenCodeReady] Session cache refreshed: reason=${reason}`);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
logger.warn(`[OpenCodeReady] Failed to refresh session cache: reason=${reason}`, error);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
await reconcileStoredModelSelection({ forceCatalogRefresh: true });
|
|
26
|
+
logger.debug(`[OpenCodeReady] Model catalog refreshed: reason=${reason}`);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
logger.warn(`[OpenCodeReady] Failed to refresh model catalog: reason=${reason}`, error);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function refreshSessionCacheIfOpencodeReady(reason) {
|
|
33
|
+
if (!(await isOpencodeServerHealthy())) {
|
|
34
|
+
opencodeReadyLifecycle.notifyUnavailable(reason);
|
|
35
|
+
logger.warn(`[OpenCodeReady] OpenCode server is not running; skipping session cache refresh: reason=${reason}`);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
await refreshSessionCacheAfterOpencodeReady(reason);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
export function registerOpenCodeReadyRefreshHandler() {
|
|
42
|
+
if (readyRefreshRegistered) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
readyRefreshRegistered = true;
|
|
46
|
+
opencodeReadyLifecycle.onReady((reason) => refreshSessionCacheAfterOpencodeReady(reason));
|
|
47
|
+
}
|
|
48
|
+
export async function notifyOpencodeReadyIfHealthy(reason) {
|
|
49
|
+
if (!(await isOpencodeServerHealthy())) {
|
|
50
|
+
opencodeReadyLifecycle.notifyUnavailable(reason);
|
|
51
|
+
logger.warn(`[OpenCodeReady] OpenCode server is not running: reason=${reason}`);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
return opencodeReadyLifecycle.notifyReady(reason);
|
|
55
|
+
}
|
package/dist/pinned/manager.js
CHANGED
|
@@ -5,6 +5,7 @@ import { getCurrentSession } from "../session/manager.js";
|
|
|
5
5
|
import { getCurrentProject, getPinnedMessageId, setPinnedMessageId, clearPinnedMessageId, } from "../settings/manager.js";
|
|
6
6
|
import { getStoredModel } from "../model/manager.js";
|
|
7
7
|
import { getModelContextLimit } from "../model/context-limit.js";
|
|
8
|
+
import { isExpectedOpencodeUnavailableError } from "../utils/opencode-error.js";
|
|
8
9
|
import { t } from "../i18n/index.js";
|
|
9
10
|
import { DEFAULT_CONTEXT_LIMIT, formatContextLine, formatCostLine, formatModelDisplayName, } from "./format.js";
|
|
10
11
|
class PinnedMessageManager {
|
|
@@ -128,7 +129,12 @@ class PinnedMessageManager {
|
|
|
128
129
|
directory,
|
|
129
130
|
});
|
|
130
131
|
if (error || !messagesData) {
|
|
131
|
-
|
|
132
|
+
if (isExpectedOpencodeUnavailableError(error)) {
|
|
133
|
+
logger.warn("[PinnedManager] OpenCode server unavailable; skipping session history load");
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
logger.warn("[PinnedManager] Failed to load session history:", error);
|
|
137
|
+
}
|
|
132
138
|
return;
|
|
133
139
|
}
|
|
134
140
|
// Get the maximum context size and total cost from session history
|
|
@@ -164,7 +170,12 @@ class PinnedMessageManager {
|
|
|
164
170
|
await this.updatePinnedMessage();
|
|
165
171
|
}
|
|
166
172
|
catch (err) {
|
|
167
|
-
|
|
173
|
+
if (isExpectedOpencodeUnavailableError(err)) {
|
|
174
|
+
logger.warn("[PinnedManager] OpenCode server unavailable; skipping session history load");
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
logger.error("[PinnedManager] Error loading context from history:", err);
|
|
178
|
+
}
|
|
168
179
|
}
|
|
169
180
|
}
|
|
170
181
|
/**
|
|
@@ -337,7 +348,12 @@ class PinnedMessageManager {
|
|
|
337
348
|
await this.loadDiffsFromMessages(sessionId, project.worktree);
|
|
338
349
|
}
|
|
339
350
|
catch (err) {
|
|
340
|
-
|
|
351
|
+
if (isExpectedOpencodeUnavailableError(err)) {
|
|
352
|
+
logger.debug("[PinnedManager] OpenCode server unavailable; skipping diff restore");
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
logger.debug("[PinnedManager] Could not load diffs from API:", err);
|
|
356
|
+
}
|
|
341
357
|
}
|
|
342
358
|
}
|
|
343
359
|
/**
|
|
@@ -351,7 +367,12 @@ class PinnedMessageManager {
|
|
|
351
367
|
directory,
|
|
352
368
|
});
|
|
353
369
|
if (error || !messagesData) {
|
|
354
|
-
|
|
370
|
+
if (isExpectedOpencodeUnavailableError(error)) {
|
|
371
|
+
logger.debug("[PinnedManager] OpenCode server unavailable; skipping diff message restore");
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
logger.debug(`[PinnedManager] loadDiffsFromMessages: error or no data`);
|
|
375
|
+
}
|
|
355
376
|
return;
|
|
356
377
|
}
|
|
357
378
|
logger.debug(`[PinnedManager] loadDiffsFromMessages: ${messagesData.length} messages`);
|
|
@@ -422,7 +443,12 @@ class PinnedMessageManager {
|
|
|
422
443
|
}
|
|
423
444
|
}
|
|
424
445
|
catch (err) {
|
|
425
|
-
|
|
446
|
+
if (isExpectedOpencodeUnavailableError(err)) {
|
|
447
|
+
logger.debug("[PinnedManager] OpenCode server unavailable; skipping diff message restore");
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
logger.debug("[PinnedManager] Could not load diffs from messages:", err);
|
|
451
|
+
}
|
|
426
452
|
}
|
|
427
453
|
}
|
|
428
454
|
/**
|
|
@@ -445,7 +471,12 @@ class PinnedMessageManager {
|
|
|
445
471
|
}
|
|
446
472
|
}
|
|
447
473
|
catch (err) {
|
|
448
|
-
|
|
474
|
+
if (isExpectedOpencodeUnavailableError(err)) {
|
|
475
|
+
logger.debug("[PinnedManager] OpenCode server unavailable; skipping session title refresh");
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
logger.debug("[PinnedManager] Could not refresh session title:", err);
|
|
479
|
+
}
|
|
449
480
|
}
|
|
450
481
|
}
|
|
451
482
|
/**
|
|
@@ -523,7 +554,12 @@ class PinnedMessageManager {
|
|
|
523
554
|
logger.debug(`[PinnedManager] Context limit: ${this.contextLimit}`);
|
|
524
555
|
}
|
|
525
556
|
catch (err) {
|
|
526
|
-
|
|
557
|
+
if (isExpectedOpencodeUnavailableError(err)) {
|
|
558
|
+
logger.warn("[PinnedManager] OpenCode server unavailable; using default context limit");
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
logger.error("[PinnedManager] Error fetching context limit:", err);
|
|
562
|
+
}
|
|
527
563
|
this.contextLimit = DEFAULT_CONTEXT_LIMIT;
|
|
528
564
|
this.state.tokensLimit = this.contextLimit;
|
|
529
565
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateTelegramEntities } from "./validator.js";
|
|
1
|
+
import { isLoopbackTelegramHttpUrl, isValidTelegramTextLinkUrl, validateTelegramEntities, } from "./validator.js";
|
|
2
2
|
const ENTITY_TYPE_PRIORITY = {
|
|
3
3
|
bold: 1,
|
|
4
4
|
italic: 2,
|
|
@@ -32,18 +32,15 @@ function pushEntity(state, entity) {
|
|
|
32
32
|
}
|
|
33
33
|
state.entities.push(entity);
|
|
34
34
|
}
|
|
35
|
-
function isTelegramTextLinkUrl(url) {
|
|
36
|
-
try {
|
|
37
|
-
const parsed = new URL(url);
|
|
38
|
-
return ["http:", "https:", "tg:", "mailto:"].includes(parsed.protocol);
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
35
|
function isLocalReferenceUrl(url) {
|
|
45
36
|
return url.startsWith("#") || url.startsWith("/") || url.startsWith("./") || url.startsWith("../");
|
|
46
37
|
}
|
|
38
|
+
function appendPlainLinkTarget(state, offset, url) {
|
|
39
|
+
if (!url || state.text.slice(offset) === url) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
appendText(state, ` (${url})`);
|
|
43
|
+
}
|
|
47
44
|
function renderIntoState(state, nodes) {
|
|
48
45
|
for (const node of nodes) {
|
|
49
46
|
switch (node.type) {
|
|
@@ -93,9 +90,9 @@ function renderIntoState(state, nodes) {
|
|
|
93
90
|
case "link": {
|
|
94
91
|
const offset = state.text.length;
|
|
95
92
|
renderIntoState(state, node.text);
|
|
96
|
-
if (!
|
|
97
|
-
if (isLocalReferenceUrl(node.url)) {
|
|
98
|
-
|
|
93
|
+
if (!isValidTelegramTextLinkUrl(node.url)) {
|
|
94
|
+
if (isLocalReferenceUrl(node.url) || isLoopbackTelegramHttpUrl(node.url)) {
|
|
95
|
+
appendPlainLinkTarget(state, offset, node.url);
|
|
99
96
|
break;
|
|
100
97
|
}
|
|
101
98
|
}
|
|
@@ -43,10 +43,36 @@ function isPartialOverlap(left, right) {
|
|
|
43
43
|
function isStyleEntity(entity) {
|
|
44
44
|
return STYLE_ENTITY_TYPES.has(entity.type);
|
|
45
45
|
}
|
|
46
|
-
function
|
|
46
|
+
function isLoopbackHttpHostname(hostname) {
|
|
47
|
+
const normalized = hostname.toLowerCase().replace(/^\[(.*)]$/, "$1").replace(/\.$/, "");
|
|
48
|
+
return (normalized === "localhost" ||
|
|
49
|
+
normalized.endsWith(".localhost") ||
|
|
50
|
+
normalized === "0.0.0.0" ||
|
|
51
|
+
normalized.startsWith("127.") ||
|
|
52
|
+
normalized === "::1");
|
|
53
|
+
}
|
|
54
|
+
export function isLoopbackTelegramHttpUrl(url) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = new URL(url);
|
|
57
|
+
return ["http:", "https:"].includes(parsed.protocol) && isLoopbackHttpHostname(parsed.hostname);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function isValidTelegramTextLinkUrl(url) {
|
|
64
|
+
if (/\s/.test(url)) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
47
67
|
try {
|
|
48
68
|
const parsed = new URL(url);
|
|
49
|
-
|
|
69
|
+
if (!["http:", "https:", "tg:", "mailto:"].includes(parsed.protocol)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (["http:", "https:"].includes(parsed.protocol)) {
|
|
73
|
+
return Boolean(parsed.hostname) && !isLoopbackTelegramHttpUrl(url);
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
50
76
|
}
|
|
51
77
|
catch {
|
|
52
78
|
return false;
|
|
@@ -82,7 +108,7 @@ function validateEntityShape(textLength, entity, entityIndex) {
|
|
|
82
108
|
entityIndex,
|
|
83
109
|
});
|
|
84
110
|
}
|
|
85
|
-
if (entity.type === "text_link" && !
|
|
111
|
+
if (entity.type === "text_link" && !isValidTelegramTextLinkUrl(entity.url)) {
|
|
86
112
|
issues.push({
|
|
87
113
|
code: "invalid_link_url",
|
|
88
114
|
message: `Invalid Telegram text_link URL: ${entity.url}`,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function isExpectedOpencodeUnavailableError(error) {
|
|
2
|
+
if (!error) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6
|
+
const normalized = message.toLowerCase();
|
|
7
|
+
return (normalized.includes("fetch failed") ||
|
|
8
|
+
normalized.includes("failed to fetch") ||
|
|
9
|
+
normalized.includes("econnrefused") ||
|
|
10
|
+
normalized.includes("econnreset") ||
|
|
11
|
+
normalized.includes("enotfound") ||
|
|
12
|
+
normalized.includes("connectex"));
|
|
13
|
+
}
|