@coinseeker/opencode-telegram-plugin 1.0.13 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/telegram-remote.js +694 -135
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,11 +15,11 @@ Configure the npm package in `~/.config/opencode/opencode.json`:
|
|
|
15
15
|
|
|
16
16
|
```json
|
|
17
17
|
{
|
|
18
|
-
"plugin": ["@coinseeker/opencode-telegram-plugin@1.0
|
|
18
|
+
"plugin": ["@coinseeker/opencode-telegram-plugin@1.1.0"]
|
|
19
19
|
}
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Current stable version: `@coinseeker/opencode-telegram-plugin@1.0
|
|
22
|
+
Current stable version: `@coinseeker/opencode-telegram-plugin@1.1.0`.
|
|
23
23
|
|
|
24
24
|
Restart OpenCode after editing the config. OpenCode resolves npm package plugins on startup.
|
|
25
25
|
|
|
@@ -59,7 +59,7 @@ Keep this file private. Never commit or share your Telegram bot token.
|
|
|
59
59
|
- Permission approve/reject buttons from Telegram.
|
|
60
60
|
- Multi-session-safe Telegram polling through a file-lock leader model.
|
|
61
61
|
- Log file output instead of stdout terminal spam.
|
|
62
|
-
-
|
|
62
|
+
- Cross-process remote session listing via `/sessions`, `/status N`, `/start_work N`, `/help` slash commands.
|
|
63
63
|
- Safety-gated remote `/start-work` execution: verifies agent=plan, idle status, incomplete plan, and no active boulder before dispatching.
|
|
64
64
|
|
|
65
65
|
## Logs
|
package/dist/telegram-remote.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// src/telegram-remote.ts
|
|
7
7
|
import { createHash as createHash5 } from "crypto";
|
|
8
8
|
import { homedir as homedir3, tmpdir as tmpdir5 } from "os";
|
|
9
|
-
import { dirname as dirname6, join as
|
|
9
|
+
import { dirname as dirname6, join as join10 } from "path";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
11
|
|
|
12
12
|
// src/bot.ts
|
|
@@ -923,6 +923,7 @@ function createQuestionDispatcher(ctx) {
|
|
|
923
923
|
await expirePending2(ctx, shortHash, pending, messageId);
|
|
924
924
|
return;
|
|
925
925
|
}
|
|
926
|
+
pending.expiresAt = Date.now() + QUESTION_EXPIRY_MS;
|
|
926
927
|
const question = pending.questions[questionIndex];
|
|
927
928
|
if (!question) return;
|
|
928
929
|
if (selection === "c") {
|
|
@@ -980,6 +981,7 @@ function createQuestionDispatcher(ctx) {
|
|
|
980
981
|
await expirePending2(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
|
|
981
982
|
return;
|
|
982
983
|
}
|
|
984
|
+
match.data.expiresAt = Date.now() + QUESTION_EXPIRY_MS;
|
|
983
985
|
const question = match.data.questions[awaiting.questionIndex];
|
|
984
986
|
if (question?.multiple === true) {
|
|
985
987
|
const current = selectedAnswers(match.data, awaiting.questionIndex);
|
|
@@ -1017,9 +1019,313 @@ async function handleQuestionReplied(event, ctx) {
|
|
|
1017
1019
|
}
|
|
1018
1020
|
}
|
|
1019
1021
|
|
|
1022
|
+
// src/lib/session-registry.ts
|
|
1023
|
+
import { chmod, mkdir as mkdir4, readFile as readFile3, readdir as readdir4, rename as rename3, unlink as unlink4, writeFile as writeFile3 } from "fs/promises";
|
|
1024
|
+
import { join as join4 } from "path";
|
|
1025
|
+
|
|
1026
|
+
// src/lib/opencode-http.ts
|
|
1027
|
+
var ALLOWED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
|
|
1028
|
+
function asRecord(value) {
|
|
1029
|
+
if (!value || typeof value !== "object") return void 0;
|
|
1030
|
+
return value;
|
|
1031
|
+
}
|
|
1032
|
+
function isStatusType(value) {
|
|
1033
|
+
return value === "idle" || value === "busy" || value === "retry";
|
|
1034
|
+
}
|
|
1035
|
+
function normalizeOpenCodeServerUrl(value) {
|
|
1036
|
+
if (!value) return void 0;
|
|
1037
|
+
try {
|
|
1038
|
+
const url = new URL(value);
|
|
1039
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
|
|
1040
|
+
if (url.username || url.password) return void 0;
|
|
1041
|
+
if (url.search || url.hash) return void 0;
|
|
1042
|
+
if (url.pathname !== "/" && url.pathname !== "") return void 0;
|
|
1043
|
+
if (!ALLOWED_HOSTNAMES.has(url.hostname)) return void 0;
|
|
1044
|
+
return url.href;
|
|
1045
|
+
} catch {
|
|
1046
|
+
return void 0;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
function requireOpenCodeServerUrl(serverUrl) {
|
|
1050
|
+
const normalized = normalizeOpenCodeServerUrl(serverUrl);
|
|
1051
|
+
if (!normalized) throw new Error("Invalid OpenCode server URL");
|
|
1052
|
+
return normalized;
|
|
1053
|
+
}
|
|
1054
|
+
function endpoint(serverUrl, path) {
|
|
1055
|
+
return new URL(path, requireOpenCodeServerUrl(serverUrl));
|
|
1056
|
+
}
|
|
1057
|
+
function isDifferentServerUrl(sourceServerUrl, currentServerUrl) {
|
|
1058
|
+
const source = normalizeOpenCodeServerUrl(sourceServerUrl);
|
|
1059
|
+
const current = normalizeOpenCodeServerUrl(currentServerUrl);
|
|
1060
|
+
if (!source || !current) return false;
|
|
1061
|
+
return source !== current;
|
|
1062
|
+
}
|
|
1063
|
+
function normalizeSession(value) {
|
|
1064
|
+
const record = asRecord(value);
|
|
1065
|
+
if (!record || typeof record.directory !== "string") return void 0;
|
|
1066
|
+
const session = { directory: record.directory };
|
|
1067
|
+
if (typeof record.id === "string") session.id = record.id;
|
|
1068
|
+
if (typeof record.title === "string") session.title = record.title;
|
|
1069
|
+
if (typeof record.parentID === "string" || record.parentID === null) {
|
|
1070
|
+
session.parentID = record.parentID;
|
|
1071
|
+
}
|
|
1072
|
+
if (typeof record.agent === "string") session.agent = record.agent;
|
|
1073
|
+
return session;
|
|
1074
|
+
}
|
|
1075
|
+
function normalizeSessionList(value) {
|
|
1076
|
+
if (!Array.isArray(value)) return [];
|
|
1077
|
+
const sessions = [];
|
|
1078
|
+
for (const raw of value) {
|
|
1079
|
+
const record = asRecord(raw);
|
|
1080
|
+
const time = asRecord(record?.time);
|
|
1081
|
+
if (!record) continue;
|
|
1082
|
+
if (typeof record.id !== "string") continue;
|
|
1083
|
+
if (typeof record.title !== "string") continue;
|
|
1084
|
+
if (!time || typeof time.updated !== "number") continue;
|
|
1085
|
+
const session = {
|
|
1086
|
+
id: record.id,
|
|
1087
|
+
title: record.title,
|
|
1088
|
+
time: { updated: time.updated }
|
|
1089
|
+
};
|
|
1090
|
+
if (typeof record.parentID === "string" || record.parentID === null) {
|
|
1091
|
+
session.parentID = record.parentID;
|
|
1092
|
+
}
|
|
1093
|
+
if (typeof record.agent === "string") session.agent = record.agent;
|
|
1094
|
+
sessions.push(session);
|
|
1095
|
+
}
|
|
1096
|
+
return sessions;
|
|
1097
|
+
}
|
|
1098
|
+
function normalizeStatusMap(value) {
|
|
1099
|
+
const record = asRecord(value);
|
|
1100
|
+
if (!record) return {};
|
|
1101
|
+
const out = {};
|
|
1102
|
+
for (const [sessionId, rawStatus] of Object.entries(record)) {
|
|
1103
|
+
const status = asRecord(rawStatus);
|
|
1104
|
+
if (status && isStatusType(status.type)) out[sessionId] = { type: status.type };
|
|
1105
|
+
}
|
|
1106
|
+
return out;
|
|
1107
|
+
}
|
|
1108
|
+
function normalizeMessages(value) {
|
|
1109
|
+
if (!Array.isArray(value)) return [];
|
|
1110
|
+
const messages = [];
|
|
1111
|
+
for (const rawMessage of value) {
|
|
1112
|
+
const message = asRecord(rawMessage);
|
|
1113
|
+
const info = asRecord(message?.info);
|
|
1114
|
+
if (!message || !info || typeof info.role !== "string" || !Array.isArray(message.parts)) continue;
|
|
1115
|
+
const parts = [];
|
|
1116
|
+
for (const rawPart of message.parts) {
|
|
1117
|
+
const part = asRecord(rawPart);
|
|
1118
|
+
if (!part || typeof part.type !== "string") continue;
|
|
1119
|
+
const normalized = { type: part.type };
|
|
1120
|
+
if (typeof part.text === "string") normalized.text = part.text;
|
|
1121
|
+
parts.push(normalized);
|
|
1122
|
+
}
|
|
1123
|
+
messages.push({ info: { role: info.role }, parts });
|
|
1124
|
+
}
|
|
1125
|
+
return messages;
|
|
1126
|
+
}
|
|
1127
|
+
async function fetchJson(serverUrl, path, fetcher) {
|
|
1128
|
+
const response = await fetcher(endpoint(serverUrl, path), { redirect: "error" });
|
|
1129
|
+
if (response.status === 404) return { data: void 0, response: { status: response.status } };
|
|
1130
|
+
if (!response.ok) {
|
|
1131
|
+
throw new Error(`OpenCode request failed: ${response.status} ${response.statusText}`);
|
|
1132
|
+
}
|
|
1133
|
+
return { data: await response.json(), response: { status: response.status } };
|
|
1134
|
+
}
|
|
1135
|
+
async function getRemoteSession(serverUrl, sessionId, fetcher = fetch) {
|
|
1136
|
+
const result = await fetchJson(serverUrl, `/session/${encodeURIComponent(sessionId)}`, fetcher);
|
|
1137
|
+
return { data: normalizeSession(result.data), response: result.response };
|
|
1138
|
+
}
|
|
1139
|
+
async function getRemoteStatusMap(serverUrl, fetcher = fetch) {
|
|
1140
|
+
const result = await fetchJson(serverUrl, "/session/status", fetcher);
|
|
1141
|
+
return normalizeStatusMap(result.data);
|
|
1142
|
+
}
|
|
1143
|
+
async function getRemoteSessions(serverUrl, fetcher = fetch) {
|
|
1144
|
+
const result = await fetchJson(serverUrl, "/session", fetcher);
|
|
1145
|
+
return normalizeSessionList(result.data);
|
|
1146
|
+
}
|
|
1147
|
+
async function getRemoteMessages(serverUrl, sessionId, limit, fetcher = fetch) {
|
|
1148
|
+
const url = endpoint(serverUrl, `/session/${encodeURIComponent(sessionId)}/message`);
|
|
1149
|
+
url.searchParams.set("limit", String(limit));
|
|
1150
|
+
const response = await fetcher(url, { redirect: "error" });
|
|
1151
|
+
if (response.status === 404) return [];
|
|
1152
|
+
if (!response.ok) {
|
|
1153
|
+
throw new Error(`OpenCode request failed: ${response.status} ${response.statusText}`);
|
|
1154
|
+
}
|
|
1155
|
+
return normalizeMessages(await response.json());
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// src/lib/session-registry.ts
|
|
1159
|
+
function filenameForSession(sessionId) {
|
|
1160
|
+
return Buffer.from(sessionId).toString("base64url") + ".json";
|
|
1161
|
+
}
|
|
1162
|
+
function hasCode4(err, code) {
|
|
1163
|
+
return err instanceof Error && "code" in err && err.code === code;
|
|
1164
|
+
}
|
|
1165
|
+
function normalizeEntry(entry) {
|
|
1166
|
+
const serverUrl = normalizeOpenCodeServerUrl(entry.serverUrl);
|
|
1167
|
+
if (!serverUrl) throw new Error("invalid registry serverUrl");
|
|
1168
|
+
const out = {
|
|
1169
|
+
sessionId: entry.sessionId,
|
|
1170
|
+
title: entry.title,
|
|
1171
|
+
parentID: entry.parentID,
|
|
1172
|
+
serverUrl,
|
|
1173
|
+
updatedAt: entry.updatedAt
|
|
1174
|
+
};
|
|
1175
|
+
if (entry.agent !== void 0) out.agent = entry.agent;
|
|
1176
|
+
if (entry.status !== void 0) out.status = entry.status;
|
|
1177
|
+
return out;
|
|
1178
|
+
}
|
|
1179
|
+
function isRegistryFile(value) {
|
|
1180
|
+
if (!value || typeof value !== "object") return false;
|
|
1181
|
+
const file = value;
|
|
1182
|
+
if (file.version !== 1) return false;
|
|
1183
|
+
const entry = file.entry;
|
|
1184
|
+
if (!entry || typeof entry !== "object") return false;
|
|
1185
|
+
const e = entry;
|
|
1186
|
+
if (typeof e.sessionId !== "string") return false;
|
|
1187
|
+
if (typeof e.title !== "string") return false;
|
|
1188
|
+
if (e.parentID !== null && typeof e.parentID !== "string") return false;
|
|
1189
|
+
if (e.agent !== void 0 && typeof e.agent !== "string") return false;
|
|
1190
|
+
if (e.status !== void 0 && e.status !== "idle" && e.status !== "busy" && e.status !== "retry") {
|
|
1191
|
+
return false;
|
|
1192
|
+
}
|
|
1193
|
+
if (typeof e.serverUrl !== "string") return false;
|
|
1194
|
+
if (!normalizeOpenCodeServerUrl(e.serverUrl)) return false;
|
|
1195
|
+
if (typeof e.updatedAt !== "number") return false;
|
|
1196
|
+
return true;
|
|
1197
|
+
}
|
|
1198
|
+
function agentFromSession(session) {
|
|
1199
|
+
const candidate = session;
|
|
1200
|
+
return typeof candidate.agent === "string" ? candidate.agent : void 0;
|
|
1201
|
+
}
|
|
1202
|
+
function registryEntryFromSession(session, serverUrl, status) {
|
|
1203
|
+
const entry = {
|
|
1204
|
+
sessionId: session.id,
|
|
1205
|
+
title: session.title,
|
|
1206
|
+
parentID: session.parentID ?? null,
|
|
1207
|
+
serverUrl,
|
|
1208
|
+
updatedAt: session.time.updated
|
|
1209
|
+
};
|
|
1210
|
+
const agent = agentFromSession(session);
|
|
1211
|
+
if (agent !== void 0) entry.agent = agent;
|
|
1212
|
+
if (status !== void 0) entry.status = status;
|
|
1213
|
+
return entry;
|
|
1214
|
+
}
|
|
1215
|
+
function createSessionRegistryStore(opts) {
|
|
1216
|
+
const registryDir = join4(opts.configDir, "session-registry", opts.tokenHash);
|
|
1217
|
+
function filePath(sessionId) {
|
|
1218
|
+
return join4(registryDir, filenameForSession(sessionId));
|
|
1219
|
+
}
|
|
1220
|
+
async function readEntry(sessionId) {
|
|
1221
|
+
let text;
|
|
1222
|
+
try {
|
|
1223
|
+
text = await readFile3(filePath(sessionId), "utf8");
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
if (hasCode4(err, "ENOENT")) return null;
|
|
1226
|
+
opts.logger.error("session-registry: failed to read file", {
|
|
1227
|
+
sessionId,
|
|
1228
|
+
error: String(err)
|
|
1229
|
+
});
|
|
1230
|
+
return null;
|
|
1231
|
+
}
|
|
1232
|
+
try {
|
|
1233
|
+
const parsed = JSON.parse(text);
|
|
1234
|
+
if (!isRegistryFile(parsed)) return null;
|
|
1235
|
+
return normalizeEntry(parsed.entry);
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
opts.logger.error("session-registry: corrupted JSON", { sessionId, error: String(err) });
|
|
1238
|
+
return null;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
async function writeEntry(entry) {
|
|
1242
|
+
await mkdir4(registryDir, { recursive: true });
|
|
1243
|
+
try {
|
|
1244
|
+
await chmod(registryDir, 448);
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
opts.logger.error("session-registry: failed to chmod dir", { error: String(err) });
|
|
1247
|
+
}
|
|
1248
|
+
const payload = { version: 1, entry: normalizeEntry(entry) };
|
|
1249
|
+
const target = filePath(entry.sessionId);
|
|
1250
|
+
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
|
|
1251
|
+
await writeFile3(tmp, JSON.stringify(payload, null, 2), "utf8");
|
|
1252
|
+
try {
|
|
1253
|
+
await rename3(tmp, target);
|
|
1254
|
+
} catch (err) {
|
|
1255
|
+
try {
|
|
1256
|
+
await unlink4(tmp);
|
|
1257
|
+
} catch {
|
|
1258
|
+
}
|
|
1259
|
+
throw err;
|
|
1260
|
+
}
|
|
1261
|
+
await chmod(target, 384);
|
|
1262
|
+
}
|
|
1263
|
+
async function upsertSession(entry) {
|
|
1264
|
+
const existing = await readEntry(entry.sessionId);
|
|
1265
|
+
await writeEntry({
|
|
1266
|
+
...existing,
|
|
1267
|
+
...entry,
|
|
1268
|
+
agent: entry.agent ?? existing?.agent,
|
|
1269
|
+
status: entry.status ?? existing?.status
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
async function updateSession(sessionId, patch) {
|
|
1273
|
+
const existing = await readEntry(sessionId);
|
|
1274
|
+
if (!existing) return;
|
|
1275
|
+
await writeEntry({
|
|
1276
|
+
...existing,
|
|
1277
|
+
...patch,
|
|
1278
|
+
sessionId,
|
|
1279
|
+
title: patch.title ?? existing.title,
|
|
1280
|
+
parentID: patch.parentID ?? existing.parentID,
|
|
1281
|
+
serverUrl: patch.serverUrl ?? existing.serverUrl,
|
|
1282
|
+
updatedAt: patch.updatedAt ?? Date.now()
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
async function listSessions() {
|
|
1286
|
+
let names;
|
|
1287
|
+
try {
|
|
1288
|
+
names = await readdir4(registryDir);
|
|
1289
|
+
} catch (err) {
|
|
1290
|
+
if (hasCode4(err, "ENOENT")) return [];
|
|
1291
|
+
opts.logger.error("session-registry: failed to list dir", { error: String(err) });
|
|
1292
|
+
return [];
|
|
1293
|
+
}
|
|
1294
|
+
const entries = [];
|
|
1295
|
+
for (const name of names) {
|
|
1296
|
+
if (!name.endsWith(".json")) continue;
|
|
1297
|
+
let text;
|
|
1298
|
+
try {
|
|
1299
|
+
text = await readFile3(join4(registryDir, name), "utf8");
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
opts.logger.error("session-registry: failed to read listed file", {
|
|
1302
|
+
file: name,
|
|
1303
|
+
error: String(err)
|
|
1304
|
+
});
|
|
1305
|
+
continue;
|
|
1306
|
+
}
|
|
1307
|
+
try {
|
|
1308
|
+
const parsed = JSON.parse(text);
|
|
1309
|
+
if (isRegistryFile(parsed)) entries.push(normalizeEntry(parsed.entry));
|
|
1310
|
+
} catch (err) {
|
|
1311
|
+
opts.logger.error("session-registry: corrupted listed file", {
|
|
1312
|
+
file: name,
|
|
1313
|
+
error: String(err)
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return entries;
|
|
1318
|
+
}
|
|
1319
|
+
return { upsertSession, updateSession, listSessions };
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1020
1322
|
// src/events/session-created.ts
|
|
1021
1323
|
async function handleSessionCreated(event, ctx) {
|
|
1022
|
-
|
|
1324
|
+
const info = event.properties.info;
|
|
1325
|
+
ctx.sessionTitleService.setSessionInfo(info);
|
|
1326
|
+
await ctx.sessionRegistry.upsertSession(
|
|
1327
|
+
registryEntryFromSession(info, ctx.serverUrl.href, ctx.sessionTitleService.getSessionStatus(info.id))
|
|
1328
|
+
);
|
|
1023
1329
|
}
|
|
1024
1330
|
|
|
1025
1331
|
// src/lib/abort-tracker.ts
|
|
@@ -1061,14 +1367,14 @@ async function handleSessionError(event, ctx) {
|
|
|
1061
1367
|
|
|
1062
1368
|
// src/lib/pending-start-work.ts
|
|
1063
1369
|
import { createHash as createHash4 } from "crypto";
|
|
1064
|
-
import { mkdir as
|
|
1370
|
+
import { mkdir as mkdir5, readdir as readdir5, readFile as readFile4, rename as rename4, unlink as unlink5, writeFile as writeFile4 } from "fs/promises";
|
|
1065
1371
|
import { tmpdir as tmpdir3 } from "os";
|
|
1066
|
-
import { dirname as dirname3, join as
|
|
1067
|
-
function
|
|
1372
|
+
import { dirname as dirname3, join as join5 } from "path";
|
|
1373
|
+
function hasCode5(err, code) {
|
|
1068
1374
|
return "code" in err && err.code === code;
|
|
1069
1375
|
}
|
|
1070
1376
|
function pendingFilePath3(dir, shortHash) {
|
|
1071
|
-
return
|
|
1377
|
+
return join5(dir, `${shortHash}.json`);
|
|
1072
1378
|
}
|
|
1073
1379
|
function parsePending3(text) {
|
|
1074
1380
|
const parsed = JSON.parse(text);
|
|
@@ -1087,10 +1393,10 @@ function parsePending3(text) {
|
|
|
1087
1393
|
}
|
|
1088
1394
|
async function listPendingFiles3(dir) {
|
|
1089
1395
|
try {
|
|
1090
|
-
const entries = await
|
|
1396
|
+
const entries = await readdir5(dir, { withFileTypes: true });
|
|
1091
1397
|
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name);
|
|
1092
1398
|
} catch (err) {
|
|
1093
|
-
if (err instanceof Error &&
|
|
1399
|
+
if (err instanceof Error && hasCode5(err, "ENOENT")) return [];
|
|
1094
1400
|
throw err;
|
|
1095
1401
|
}
|
|
1096
1402
|
}
|
|
@@ -1098,29 +1404,29 @@ function shortHashFromFileName3(fileName) {
|
|
|
1098
1404
|
return fileName.slice(0, -".json".length);
|
|
1099
1405
|
}
|
|
1100
1406
|
function createPendingStartWorkStore(opts) {
|
|
1101
|
-
const dir = opts.baseDir ??
|
|
1407
|
+
const dir = opts.baseDir ?? join5(tmpdir3(), `opencoder-telegram-pending-start-work-${opts.tokenHash}`);
|
|
1102
1408
|
return {
|
|
1103
1409
|
dir,
|
|
1104
1410
|
async savePending(shortHash, data) {
|
|
1105
1411
|
const filePath = pendingFilePath3(dir, shortHash);
|
|
1106
|
-
await
|
|
1412
|
+
await mkdir5(dirname3(filePath), { recursive: true });
|
|
1107
1413
|
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
1108
|
-
await
|
|
1109
|
-
await
|
|
1414
|
+
await writeFile4(tmpPath, JSON.stringify(data, null, 2), "utf8");
|
|
1415
|
+
await rename4(tmpPath, filePath);
|
|
1110
1416
|
},
|
|
1111
1417
|
async loadPending(shortHash) {
|
|
1112
1418
|
try {
|
|
1113
|
-
return parsePending3(await
|
|
1419
|
+
return parsePending3(await readFile4(pendingFilePath3(dir, shortHash), "utf8"));
|
|
1114
1420
|
} catch (err) {
|
|
1115
|
-
if (err instanceof Error &&
|
|
1421
|
+
if (err instanceof Error && hasCode5(err, "ENOENT")) return void 0;
|
|
1116
1422
|
throw err;
|
|
1117
1423
|
}
|
|
1118
1424
|
},
|
|
1119
1425
|
async deletePending(shortHash) {
|
|
1120
1426
|
try {
|
|
1121
|
-
await
|
|
1427
|
+
await unlink5(pendingFilePath3(dir, shortHash));
|
|
1122
1428
|
} catch (err) {
|
|
1123
|
-
if (!(err instanceof Error) || !
|
|
1429
|
+
if (!(err instanceof Error) || !hasCode5(err, "ENOENT")) throw err;
|
|
1124
1430
|
}
|
|
1125
1431
|
},
|
|
1126
1432
|
async sweepExpired() {
|
|
@@ -1218,9 +1524,21 @@ Session: ${label}`
|
|
|
1218
1524
|
|
|
1219
1525
|
// src/events/session-idle.ts
|
|
1220
1526
|
var ROOT_IDLE_RECHECK_DELAY_MS = 2500;
|
|
1527
|
+
var DEFERRED_PARENT_CONFIRM_DELAY_MS = 2500;
|
|
1528
|
+
var deferredConfirmTimers = /* @__PURE__ */ new Map();
|
|
1221
1529
|
function sleep(ms) {
|
|
1222
1530
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1223
1531
|
}
|
|
1532
|
+
function agentFinishedMessage(title, agent) {
|
|
1533
|
+
const base = title ? `Agent has finished: ${title}` : "Agent has finished.";
|
|
1534
|
+
return agent ? `${base} (${agent})` : base;
|
|
1535
|
+
}
|
|
1536
|
+
function cancelDeferredParentConfirm(sessionId) {
|
|
1537
|
+
const timer = deferredConfirmTimers.get(sessionId);
|
|
1538
|
+
if (timer === void 0) return;
|
|
1539
|
+
clearTimeout(timer);
|
|
1540
|
+
deferredConfirmTimers.delete(sessionId);
|
|
1541
|
+
}
|
|
1224
1542
|
async function resolveParentID(sessionId, ctx) {
|
|
1225
1543
|
const cachedParentID = ctx.sessionTitleService.getParentID(sessionId);
|
|
1226
1544
|
if (cachedParentID !== void 0) return cachedParentID;
|
|
@@ -1228,6 +1546,13 @@ async function resolveParentID(sessionId, ctx) {
|
|
|
1228
1546
|
const result = await ctx.client.session.get({ path: { id: sessionId } });
|
|
1229
1547
|
if (result.data) {
|
|
1230
1548
|
ctx.sessionTitleService.setSessionInfo(result.data);
|
|
1549
|
+
await ctx.sessionRegistry.upsertSession(
|
|
1550
|
+
registryEntryFromSession(
|
|
1551
|
+
result.data,
|
|
1552
|
+
ctx.serverUrl.href,
|
|
1553
|
+
ctx.sessionTitleService.getSessionStatus(sessionId)
|
|
1554
|
+
)
|
|
1555
|
+
);
|
|
1231
1556
|
return ctx.sessionTitleService.getParentID(sessionId);
|
|
1232
1557
|
}
|
|
1233
1558
|
ctx.logger.warn("session parentID cache miss fetch returned no data", { sessionId });
|
|
@@ -1244,6 +1569,9 @@ async function hydrateDescendants(sessionId, ctx, seen = /* @__PURE__ */ new Set
|
|
|
1244
1569
|
const result = await ctx.client.session.children({ path: { id: sessionId } });
|
|
1245
1570
|
for (const child of result.data ?? []) {
|
|
1246
1571
|
ctx.sessionTitleService.setSessionInfo(child);
|
|
1572
|
+
await ctx.sessionRegistry.upsertSession(
|
|
1573
|
+
registryEntryFromSession(child, ctx.serverUrl.href, ctx.sessionTitleService.getSessionStatus(child.id))
|
|
1574
|
+
);
|
|
1247
1575
|
await hydrateDescendants(child.id, ctx, seen);
|
|
1248
1576
|
}
|
|
1249
1577
|
} catch (err) {
|
|
@@ -1262,8 +1590,9 @@ async function sendIdleNotification(sessionId, ctx) {
|
|
|
1262
1590
|
});
|
|
1263
1591
|
if (!claimed) return;
|
|
1264
1592
|
const title = ctx.sessionTitleService.getSessionTitle(sessionId);
|
|
1265
|
-
const
|
|
1266
|
-
const
|
|
1593
|
+
const agent = ctx.sessionTitleService.getSessionAgent(sessionId);
|
|
1594
|
+
const isPlanSession = agent === "plan";
|
|
1595
|
+
const text = isPlanSession ? planCompleteMessage(title) : agentFinishedMessage(title, agent);
|
|
1267
1596
|
try {
|
|
1268
1597
|
if (isPlanSession) {
|
|
1269
1598
|
const shortHash = startWorkShortHash(sessionId);
|
|
@@ -1287,18 +1616,49 @@ async function flushDeferredParentIfReady(parentID, ctx) {
|
|
|
1287
1616
|
if (!ctx.sessionTitleService.hasDeferredIdleNotification(parentID)) return;
|
|
1288
1617
|
if (ctx.sessionTitleService.hasUnfinishedDescendants(parentID)) return;
|
|
1289
1618
|
const parentStatus = ctx.sessionTitleService.getSessionStatus(parentID);
|
|
1290
|
-
if (parentStatus
|
|
1291
|
-
|
|
1619
|
+
if (parentStatus !== "idle") {
|
|
1620
|
+
if (parentStatus !== void 0) {
|
|
1621
|
+
cancelDeferredParentConfirm(parentID);
|
|
1622
|
+
ctx.sessionTitleService.clearDeferredIdleNotification(parentID);
|
|
1623
|
+
ctx.logger.info("clearing deferred parent idle notification - parent resumed", {
|
|
1624
|
+
sessionId: parentID
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
scheduleDeferredParentConfirm(parentID, ctx);
|
|
1630
|
+
}
|
|
1631
|
+
function scheduleDeferredParentConfirm(parentID, ctx) {
|
|
1632
|
+
if (deferredConfirmTimers.has(parentID)) return;
|
|
1633
|
+
const delay = ctx.deferredConfirmDelayMs ?? DEFERRED_PARENT_CONFIRM_DELAY_MS;
|
|
1634
|
+
const timer = setTimeout(() => {
|
|
1635
|
+
deferredConfirmTimers.delete(parentID);
|
|
1636
|
+
void confirmDeferredParentIdle(parentID, ctx);
|
|
1637
|
+
}, delay);
|
|
1638
|
+
timer.unref?.();
|
|
1639
|
+
deferredConfirmTimers.set(parentID, timer);
|
|
1640
|
+
ctx.logger.info("parent idle and descendants finished - confirming deferred notification", {
|
|
1641
|
+
sessionId: parentID
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
async function confirmDeferredParentIdle(parentID, ctx) {
|
|
1645
|
+
if (!ctx.sessionTitleService.hasDeferredIdleNotification(parentID)) return;
|
|
1646
|
+
if (ctx.sessionTitleService.getSessionStatus(parentID) !== "idle") {
|
|
1647
|
+
ctx.sessionTitleService.clearDeferredIdleNotification(parentID);
|
|
1648
|
+
ctx.logger.info("clearing deferred parent idle notification - parent resumed during confirm", {
|
|
1292
1649
|
sessionId: parentID
|
|
1293
1650
|
});
|
|
1294
1651
|
return;
|
|
1295
1652
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
ctx.logger.info("
|
|
1653
|
+
await hydrateDescendants(parentID, ctx);
|
|
1654
|
+
if (ctx.sessionTitleService.hasUnfinishedDescendants(parentID)) {
|
|
1655
|
+
ctx.logger.info("keeping deferred parent idle notification - descendants active again", {
|
|
1299
1656
|
sessionId: parentID
|
|
1300
1657
|
});
|
|
1658
|
+
return;
|
|
1301
1659
|
}
|
|
1660
|
+
ctx.logger.info("sending deferred parent idle notification", { sessionId: parentID });
|
|
1661
|
+
await sendIdleNotification(parentID, ctx);
|
|
1302
1662
|
}
|
|
1303
1663
|
async function deferParentIdleIfDescendantsRunning(sessionId, ctx) {
|
|
1304
1664
|
await hydrateDescendants(sessionId, ctx);
|
|
@@ -1313,6 +1673,7 @@ async function handleSessionIdle(event, ctx) {
|
|
|
1313
1673
|
const sessionId = event.properties.sessionID;
|
|
1314
1674
|
const parentID = await resolveParentID(sessionId, ctx);
|
|
1315
1675
|
ctx.sessionTitleService.setSessionStatus(sessionId, "idle");
|
|
1676
|
+
await ctx.sessionRegistry.updateSession(sessionId, { status: "idle", updatedAt: Date.now() });
|
|
1316
1677
|
if (typeof parentID === "string") {
|
|
1317
1678
|
ctx.logger.info("suppressing child session idle notification", { sessionId, parentID });
|
|
1318
1679
|
await flushDeferredParentIfReady(parentID, ctx);
|
|
@@ -1339,9 +1700,14 @@ async function handleSessionIdle(event, ctx) {
|
|
|
1339
1700
|
async function handleSessionStatus(event, ctx) {
|
|
1340
1701
|
const sessionId = event.properties.sessionID;
|
|
1341
1702
|
const statusType = event.properties.status.type;
|
|
1703
|
+
const previousStatus = ctx.sessionTitleService.getSessionStatus(sessionId);
|
|
1342
1704
|
ctx.sessionTitleService.setSessionStatus(sessionId, statusType);
|
|
1343
1705
|
if (statusType === "idle") {
|
|
1344
1706
|
await handleSessionIdle(event, ctx);
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
if (previousStatus !== statusType) {
|
|
1710
|
+
await ctx.sessionRegistry.updateSession(sessionId, { status: statusType, updatedAt: Date.now() });
|
|
1345
1711
|
}
|
|
1346
1712
|
}
|
|
1347
1713
|
|
|
@@ -1349,6 +1715,9 @@ async function handleSessionStatus(event, ctx) {
|
|
|
1349
1715
|
async function handleSessionUpdated(event, ctx) {
|
|
1350
1716
|
const info = event.properties.info;
|
|
1351
1717
|
ctx.sessionTitleService.setSessionInfo(info);
|
|
1718
|
+
await ctx.sessionRegistry.upsertSession(
|
|
1719
|
+
registryEntryFromSession(info, ctx.serverUrl.href, ctx.sessionTitleService.getSessionStatus(info.id))
|
|
1720
|
+
);
|
|
1352
1721
|
}
|
|
1353
1722
|
|
|
1354
1723
|
// src/lib/html-escape.ts
|
|
@@ -1370,11 +1739,110 @@ function stripCodeFences(input) {
|
|
|
1370
1739
|
var MAX_BODY_CHARS = 3900;
|
|
1371
1740
|
var MAX_TITLE_CHARS = 55;
|
|
1372
1741
|
var MAX_SESSIONS = 20;
|
|
1742
|
+
function agentFromSession2(session) {
|
|
1743
|
+
const candidate = session;
|
|
1744
|
+
return typeof candidate.agent === "string" ? candidate.agent : void 0;
|
|
1745
|
+
}
|
|
1746
|
+
function isRootSession(session) {
|
|
1747
|
+
return session.parentID === void 0 || session.parentID === null;
|
|
1748
|
+
}
|
|
1749
|
+
function isRootRegistryEntry(entry) {
|
|
1750
|
+
return entry.parentID === null;
|
|
1751
|
+
}
|
|
1752
|
+
function isRootRemoteSession(session) {
|
|
1753
|
+
return session.parentID === void 0 || session.parentID === null;
|
|
1754
|
+
}
|
|
1755
|
+
function addRegistryRecord(combined, entry, status) {
|
|
1756
|
+
combined.set(entry.sessionId, {
|
|
1757
|
+
sessionId: entry.sessionId,
|
|
1758
|
+
title: entry.title,
|
|
1759
|
+
agent: entry.agent,
|
|
1760
|
+
status: status ?? entry.status ?? "idle",
|
|
1761
|
+
serverUrl: entry.serverUrl,
|
|
1762
|
+
updatedAt: entry.updatedAt
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
async function addRemoteServerRecords(combined, serverUrl, deps) {
|
|
1766
|
+
const [remoteSessions, remoteStatusMap] = await Promise.all([
|
|
1767
|
+
getRemoteSessions(serverUrl, deps.opencodeFetch),
|
|
1768
|
+
getRemoteStatusMap(serverUrl, deps.opencodeFetch)
|
|
1769
|
+
]);
|
|
1770
|
+
for (const session of remoteSessions.filter(isRootRemoteSession)) {
|
|
1771
|
+
const status = remoteStatusMap[session.id]?.type ?? "idle";
|
|
1772
|
+
combined.set(session.id, {
|
|
1773
|
+
sessionId: session.id,
|
|
1774
|
+
title: session.title,
|
|
1775
|
+
agent: session.agent,
|
|
1776
|
+
status,
|
|
1777
|
+
serverUrl,
|
|
1778
|
+
updatedAt: session.time.updated
|
|
1779
|
+
});
|
|
1780
|
+
await deps.sessionRegistry.upsertSession({
|
|
1781
|
+
sessionId: session.id,
|
|
1782
|
+
title: session.title,
|
|
1783
|
+
parentID: session.parentID ?? null,
|
|
1784
|
+
agent: session.agent,
|
|
1785
|
+
status,
|
|
1786
|
+
serverUrl,
|
|
1787
|
+
updatedAt: session.time.updated
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1373
1791
|
function createSessionsDispatcher(deps) {
|
|
1374
1792
|
return async ({ chatId, bot }) => {
|
|
1375
|
-
|
|
1793
|
+
let sessions;
|
|
1794
|
+
try {
|
|
1795
|
+
const [listResult, statusResult] = await Promise.all([
|
|
1796
|
+
deps.client.session.list(),
|
|
1797
|
+
deps.client.session.status()
|
|
1798
|
+
]);
|
|
1799
|
+
const statusMap = statusResult.data ?? {};
|
|
1800
|
+
for (const session of listResult.data ?? []) {
|
|
1801
|
+
deps.sessionTitleService.setSessionInfo(session);
|
|
1802
|
+
deps.sessionTitleService.setServerUrl(session.id, deps.serverUrl);
|
|
1803
|
+
const status = statusMap[session.id]?.type ?? "idle";
|
|
1804
|
+
await deps.sessionRegistry.upsertSession(
|
|
1805
|
+
registryEntryFromSession(session, deps.serverUrl, status)
|
|
1806
|
+
);
|
|
1807
|
+
if (status !== void 0) deps.sessionTitleService.setSessionStatus(session.id, status);
|
|
1808
|
+
}
|
|
1809
|
+
const registrySessions = await deps.sessionRegistry.listSessions();
|
|
1810
|
+
const combined = /* @__PURE__ */ new Map();
|
|
1811
|
+
const remoteServerUrls = /* @__PURE__ */ new Set();
|
|
1812
|
+
for (const entry of registrySessions.filter(isRootRegistryEntry)) {
|
|
1813
|
+
const serverUrl = normalizeOpenCodeServerUrl(entry.serverUrl);
|
|
1814
|
+
if (!serverUrl) continue;
|
|
1815
|
+
if (isDifferentServerUrl(serverUrl, deps.serverUrl)) {
|
|
1816
|
+
remoteServerUrls.add(serverUrl);
|
|
1817
|
+
continue;
|
|
1818
|
+
}
|
|
1819
|
+
addRegistryRecord(combined, { ...entry, serverUrl }, statusMap[entry.sessionId]?.type);
|
|
1820
|
+
}
|
|
1821
|
+
for (const serverUrl of remoteServerUrls) {
|
|
1822
|
+
try {
|
|
1823
|
+
await addRemoteServerRecords(combined, serverUrl, deps);
|
|
1824
|
+
} catch (err) {
|
|
1825
|
+
deps.logger.error("sessions remote server refresh failed", { serverUrl, error: String(err) });
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
for (const session of (listResult.data ?? []).filter(isRootSession)) {
|
|
1829
|
+
combined.set(session.id, {
|
|
1830
|
+
sessionId: session.id,
|
|
1831
|
+
title: session.title,
|
|
1832
|
+
agent: agentFromSession2(session),
|
|
1833
|
+
status: statusMap[session.id]?.type ?? "idle",
|
|
1834
|
+
serverUrl: deps.serverUrl,
|
|
1835
|
+
updatedAt: session.time.updated
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
sessions = [...combined.values()].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_SESSIONS);
|
|
1839
|
+
} catch (err) {
|
|
1840
|
+
await bot.sendMessage("\uC138\uC158 \uBAA9\uB85D\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.", { parse_mode: "HTML" });
|
|
1841
|
+
deps.logger.error("sessions list failed", { chatId, error: String(err) });
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1376
1844
|
if (sessions.length === 0) {
|
|
1377
|
-
await bot.sendMessage("\
|
|
1845
|
+
await bot.sendMessage("\uC138\uC158\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.", { parse_mode: "HTML" });
|
|
1378
1846
|
return;
|
|
1379
1847
|
}
|
|
1380
1848
|
const capturedAt = Date.now();
|
|
@@ -1382,11 +1850,11 @@ function createSessionsDispatcher(deps) {
|
|
|
1382
1850
|
const entry = {
|
|
1383
1851
|
index: i + 1,
|
|
1384
1852
|
sessionId: session.sessionId,
|
|
1385
|
-
title: session.title
|
|
1853
|
+
title: session.title,
|
|
1386
1854
|
capturedAt
|
|
1387
1855
|
};
|
|
1388
1856
|
if (session.agent !== void 0) entry.agent = session.agent;
|
|
1389
|
-
|
|
1857
|
+
entry.status = session.status;
|
|
1390
1858
|
if (session.serverUrl !== void 0) entry.serverUrl = session.serverUrl;
|
|
1391
1859
|
return entry;
|
|
1392
1860
|
});
|
|
@@ -1394,14 +1862,14 @@ function createSessionsDispatcher(deps) {
|
|
|
1394
1862
|
const lines = entries.map((entry) => {
|
|
1395
1863
|
const agent = entry.agent ? escapeHtml(entry.agent) : "?";
|
|
1396
1864
|
const title = truncateForTelegram(escapeHtml(entry.title), MAX_TITLE_CHARS);
|
|
1397
|
-
const status = entry.status ?? "
|
|
1398
|
-
return `${entry.index}. [${agent}] ${title}
|
|
1865
|
+
const status = ` \u2014 ${escapeHtml(entry.status ?? "idle")}`;
|
|
1866
|
+
return `${entry.index}. [${agent}] ${title}${status}`;
|
|
1399
1867
|
});
|
|
1400
1868
|
let body = lines.join("\n");
|
|
1401
1869
|
if (body.length > MAX_BODY_CHARS) {
|
|
1402
1870
|
body = body.slice(0, MAX_BODY_CHARS) + "\u2026";
|
|
1403
1871
|
}
|
|
1404
|
-
const text = `<b>\
|
|
1872
|
+
const text = `<b>\uCD5C\uADFC \uC138\uC158 (top ${entries.length})</b>
|
|
1405
1873
|
${body}
|
|
1406
1874
|
|
|
1407
1875
|
<i>/status N \uB610\uB294 /start_work N \uC73C\uB85C \uC870\uC791</i>`;
|
|
@@ -1411,13 +1879,13 @@ ${body}
|
|
|
1411
1879
|
}
|
|
1412
1880
|
|
|
1413
1881
|
// src/lib/plan-readiness.ts
|
|
1414
|
-
import { access, readFile as
|
|
1415
|
-
import { join as
|
|
1882
|
+
import { access, readFile as readFile5, readdir as readdir6, stat as stat2 } from "fs/promises";
|
|
1883
|
+
import { join as join6 } from "path";
|
|
1416
1884
|
async function checkPlanReadiness(args) {
|
|
1417
1885
|
const { projectRoot } = args;
|
|
1418
|
-
const omoDir =
|
|
1419
|
-
const plansDir =
|
|
1420
|
-
const boulderPath =
|
|
1886
|
+
const omoDir = join6(projectRoot, ".omo");
|
|
1887
|
+
const plansDir = join6(omoDir, "plans");
|
|
1888
|
+
const boulderPath = join6(omoDir, "boulder.json");
|
|
1421
1889
|
try {
|
|
1422
1890
|
await access(omoDir);
|
|
1423
1891
|
} catch {
|
|
@@ -1438,7 +1906,7 @@ async function checkPlanReadiness(args) {
|
|
|
1438
1906
|
}
|
|
1439
1907
|
let planFiles = [];
|
|
1440
1908
|
try {
|
|
1441
|
-
const entries = await
|
|
1909
|
+
const entries = await readdir6(plansDir);
|
|
1442
1910
|
planFiles = entries.filter((e) => e.endsWith(".md"));
|
|
1443
1911
|
} catch {
|
|
1444
1912
|
return {
|
|
@@ -1456,14 +1924,14 @@ async function checkPlanReadiness(args) {
|
|
|
1456
1924
|
}
|
|
1457
1925
|
const stats = await Promise.all(
|
|
1458
1926
|
planFiles.map(async (f) => {
|
|
1459
|
-
const full =
|
|
1927
|
+
const full = join6(plansDir, f);
|
|
1460
1928
|
const s = await stat2(full);
|
|
1461
1929
|
return { path: full, name: f, mtime: s.mtime.getTime() };
|
|
1462
1930
|
})
|
|
1463
1931
|
);
|
|
1464
1932
|
stats.sort((a, b) => b.mtime - a.mtime);
|
|
1465
1933
|
const latest = stats[0];
|
|
1466
|
-
const content = await
|
|
1934
|
+
const content = await readFile5(latest.path, "utf8");
|
|
1467
1935
|
const totalMatches = content.match(/^- \[[ xX]\]/gm) ?? [];
|
|
1468
1936
|
const completedMatches = content.match(/^- \[[xX]\]/gm) ?? [];
|
|
1469
1937
|
const total = totalMatches.length;
|
|
@@ -1494,7 +1962,7 @@ async function recheckSessionIdle(client, sessionId) {
|
|
|
1494
1962
|
const result = await client.session.status();
|
|
1495
1963
|
const statuses = result.data ?? {};
|
|
1496
1964
|
const sessionStatus = statuses[sessionId];
|
|
1497
|
-
return sessionStatus?.type === "idle";
|
|
1965
|
+
return (sessionStatus?.type ?? "idle") === "idle";
|
|
1498
1966
|
}
|
|
1499
1967
|
|
|
1500
1968
|
// src/events/status-command.ts
|
|
@@ -1593,25 +2061,65 @@ function createStatusDispatcher(deps) {
|
|
|
1593
2061
|
});
|
|
1594
2062
|
return;
|
|
1595
2063
|
}
|
|
1596
|
-
const
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
})
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
const
|
|
2064
|
+
const rawSourceServerUrl = entry.serverUrl ?? deps.sessionTitleService.getServerUrl(entry.sessionId);
|
|
2065
|
+
const sourceServerUrl = normalizeOpenCodeServerUrl(rawSourceServerUrl);
|
|
2066
|
+
if (rawSourceServerUrl && !sourceServerUrl) {
|
|
2067
|
+
await bot.sendMessage("\uC138\uC158 \uC11C\uBC84 \uC815\uBCF4\uAC00 \uC720\uD6A8\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
|
|
2068
|
+
parse_mode: "HTML"
|
|
2069
|
+
});
|
|
2070
|
+
deps.logger.error("status invalid server url", { chatId, sessionId: entry.sessionId });
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
const useRemoteServer = isDifferentServerUrl(sourceServerUrl, deps.serverUrl);
|
|
2074
|
+
let session;
|
|
2075
|
+
let responseStatus;
|
|
2076
|
+
let sessionStatus = "idle";
|
|
2077
|
+
let messages = [];
|
|
2078
|
+
if (sourceServerUrl && useRemoteServer) {
|
|
2079
|
+
try {
|
|
2080
|
+
const getResult = await getRemoteSession(sourceServerUrl, entry.sessionId, deps.opencodeFetch);
|
|
2081
|
+
session = getResult.data;
|
|
2082
|
+
responseStatus = getResult.response.status;
|
|
2083
|
+
if (!session || responseStatus === 404) {
|
|
2084
|
+
await bot.sendMessage("\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
|
|
2085
|
+
parse_mode: "HTML"
|
|
2086
|
+
});
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
const [statusMap, remoteMessages] = await Promise.all([
|
|
2090
|
+
getRemoteStatusMap(sourceServerUrl, deps.opencodeFetch),
|
|
2091
|
+
getRemoteMessages(sourceServerUrl, entry.sessionId, MESSAGES_LIMIT, deps.opencodeFetch)
|
|
2092
|
+
]);
|
|
2093
|
+
sessionStatus = statusMap[entry.sessionId]?.type ?? "idle";
|
|
2094
|
+
messages = remoteMessages;
|
|
2095
|
+
} catch (err) {
|
|
2096
|
+
await bot.sendMessage("\uC138\uC158 \uC0C1\uD0DC\uB97C \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
|
|
2097
|
+
parse_mode: "HTML"
|
|
2098
|
+
});
|
|
2099
|
+
deps.logger.error("status remote lookup failed", { chatId, sessionId: entry.sessionId, error: String(err) });
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
} else {
|
|
2103
|
+
const [getResult, statusResult, messagesResult] = await Promise.all([
|
|
2104
|
+
deps.client.session.get({ path: { id: entry.sessionId } }),
|
|
2105
|
+
deps.client.session.status(),
|
|
2106
|
+
deps.client.session.messages({
|
|
2107
|
+
path: { id: entry.sessionId },
|
|
2108
|
+
query: { limit: MESSAGES_LIMIT }
|
|
2109
|
+
})
|
|
2110
|
+
]);
|
|
2111
|
+
session = normalizeSession(getResult.data);
|
|
2112
|
+
responseStatus = getResult.response?.status;
|
|
2113
|
+
const statusMap = normalizeStatusMap(statusResult.data);
|
|
2114
|
+
sessionStatus = statusMap[entry.sessionId]?.type ?? "idle";
|
|
2115
|
+
messages = normalizeMessages(messagesResult.data);
|
|
2116
|
+
}
|
|
1606
2117
|
if (!session || responseStatus === 404) {
|
|
1607
2118
|
await bot.sendMessage("\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
|
|
1608
2119
|
parse_mode: "HTML"
|
|
1609
2120
|
});
|
|
1610
2121
|
return;
|
|
1611
2122
|
}
|
|
1612
|
-
const statusMap = statusResult.data ?? {};
|
|
1613
|
-
const sessionStatus = statusMap[entry.sessionId]?.type ?? "unknown";
|
|
1614
|
-
const messages = messagesResult.data ?? [];
|
|
1615
2123
|
const projectRoot = resolveProjectRoot(session);
|
|
1616
2124
|
const planReady = await checkPlanReadiness({ projectRoot });
|
|
1617
2125
|
const userSnippet = buildSnippet(findLastByRole(messages, "user"));
|
|
@@ -1641,9 +2149,8 @@ function createStatusDispatcher(deps) {
|
|
|
1641
2149
|
}
|
|
1642
2150
|
|
|
1643
2151
|
// src/events/start-work-command.ts
|
|
1644
|
-
function
|
|
1645
|
-
|
|
1646
|
-
return typeof candidate.agent === "string" ? candidate.agent : void 0;
|
|
2152
|
+
function agentFromSession3(session) {
|
|
2153
|
+
return session.agent;
|
|
1647
2154
|
}
|
|
1648
2155
|
function resolveProjectRoot2(session) {
|
|
1649
2156
|
return session.directory;
|
|
@@ -1695,24 +2202,41 @@ function createStartWorkCommandDispatcher(deps) {
|
|
|
1695
2202
|
return;
|
|
1696
2203
|
}
|
|
1697
2204
|
const sessionId = entry.sessionId;
|
|
2205
|
+
const rawSourceServerUrl = entry.serverUrl ?? deps.sessionTitleService.getServerUrl(sessionId);
|
|
2206
|
+
const sourceServerUrl = normalizeOpenCodeServerUrl(rawSourceServerUrl);
|
|
2207
|
+
if (rawSourceServerUrl && !sourceServerUrl) {
|
|
2208
|
+
await sendPlain(bot, "\uC138\uC158 \uC11C\uBC84 \uC815\uBCF4\uAC00 \uC720\uD6A8\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694");
|
|
2209
|
+
deps.logger.error("start-work invalid server url", { sessionId });
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
const useRemoteServer = isDifferentServerUrl(sourceServerUrl, deps.serverUrl);
|
|
1698
2213
|
let session;
|
|
1699
2214
|
try {
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
2215
|
+
if (sourceServerUrl && useRemoteServer) {
|
|
2216
|
+
const result = await getRemoteSession(sourceServerUrl, sessionId, deps.opencodeFetch);
|
|
2217
|
+
if (!result.data || result.response.status === 404) {
|
|
2218
|
+
await sendPlain(bot, "\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
session = result.data;
|
|
2222
|
+
} else {
|
|
2223
|
+
const result = await deps.client.session.get({ path: { id: sessionId } });
|
|
2224
|
+
if (!result.data) {
|
|
2225
|
+
await sendPlain(bot, "\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
session = result.data;
|
|
1704
2229
|
}
|
|
1705
|
-
session = result.data;
|
|
1706
2230
|
} catch (err) {
|
|
1707
2231
|
if (err instanceof Error && isSessionNotFoundError(err)) {
|
|
1708
2232
|
await sendPlain(bot, "\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
|
|
1709
2233
|
return;
|
|
1710
2234
|
}
|
|
1711
|
-
await sendPlain(bot,
|
|
2235
|
+
await sendPlain(bot, "\uC138\uC158 \uD655\uC778 \uC2E4\uD328. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694");
|
|
1712
2236
|
deps.logger.error("start-work session lookup failed", { sessionId, error: String(err) });
|
|
1713
2237
|
return;
|
|
1714
2238
|
}
|
|
1715
|
-
const agent = deps.sessionTitleService.getSessionAgent(sessionId) ??
|
|
2239
|
+
const agent = deps.sessionTitleService.getSessionAgent(sessionId) ?? agentFromSession3(session) ?? entry.agent;
|
|
1716
2240
|
if (agent !== "plan") {
|
|
1717
2241
|
await sendPlain(
|
|
1718
2242
|
bot,
|
|
@@ -1720,7 +2244,14 @@ function createStartWorkCommandDispatcher(deps) {
|
|
|
1720
2244
|
);
|
|
1721
2245
|
return;
|
|
1722
2246
|
}
|
|
1723
|
-
|
|
2247
|
+
let idle;
|
|
2248
|
+
try {
|
|
2249
|
+
idle = sourceServerUrl && useRemoteServer ? ((await getRemoteStatusMap(sourceServerUrl, deps.opencodeFetch))[sessionId]?.type ?? "idle") === "idle" : await recheckSessionIdle(deps.client, sessionId);
|
|
2250
|
+
} catch (err) {
|
|
2251
|
+
await sendPlain(bot, "\uC138\uC158 \uC0C1\uD0DC \uD655\uC778 \uC2E4\uD328. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694");
|
|
2252
|
+
deps.logger.error("start-work idle recheck failed", { sessionId, error: String(err) });
|
|
2253
|
+
return;
|
|
2254
|
+
}
|
|
1724
2255
|
if (!idle) {
|
|
1725
2256
|
await sendPlain(bot, `${index}\uBC88 \uC138\uC158\uC774 idle \uC0C1\uD0DC\uAC00 \uC544\uB2D9\uB2C8\uB2E4. \uC791\uC5C5 \uC644\uB8CC\uB97C \uAE30\uB2E4\uB9AC\uC138\uC694`);
|
|
1726
2257
|
return;
|
|
@@ -1731,15 +2262,14 @@ function createStartWorkCommandDispatcher(deps) {
|
|
|
1731
2262
|
return;
|
|
1732
2263
|
}
|
|
1733
2264
|
try {
|
|
1734
|
-
|
|
1735
|
-
await deps.runSessionCommand(sessionId, "start-work", serverUrl);
|
|
2265
|
+
await deps.runSessionCommand(sessionId, "start-work", sourceServerUrl);
|
|
1736
2266
|
await sendHtml(
|
|
1737
2267
|
bot,
|
|
1738
2268
|
`${index}\uBC88 \uC138\uC158\uC5D0 opencode /start-work \uC2AC\uB798\uC2DC \uCEE4\uB9E8\uB4DC \uC804\uC1A1 \uC644\uB8CC. (${escapeHtml(entry.title)})`
|
|
1739
2269
|
);
|
|
1740
2270
|
deps.logger.info("start-work dispatched", { chatId, sessionId, index });
|
|
1741
2271
|
} catch (err) {
|
|
1742
|
-
await sendHtml(bot, "opencode /start-work \uC804\uC1A1 \uC2E4\uD328
|
|
2272
|
+
await sendHtml(bot, "opencode /start-work \uC804\uC1A1 \uC2E4\uD328");
|
|
1743
2273
|
deps.logger.error("start-work dispatch failed", { sessionId, error: String(err) });
|
|
1744
2274
|
}
|
|
1745
2275
|
};
|
|
@@ -1776,14 +2306,14 @@ function createHelpDispatcher(deps) {
|
|
|
1776
2306
|
// src/lib/env-loader.ts
|
|
1777
2307
|
import { existsSync } from "fs";
|
|
1778
2308
|
import { homedir } from "os";
|
|
1779
|
-
import { join as
|
|
2309
|
+
import { join as join7 } from "path";
|
|
1780
2310
|
import dotenv from "dotenv";
|
|
1781
2311
|
function loadPluginEnv(opts) {
|
|
1782
2312
|
const paths = [
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
2313
|
+
join7(opts.pluginDir, "../../.env"),
|
|
2314
|
+
join7(opts.pluginDir, "..", ".env"),
|
|
2315
|
+
join7(opts.pluginDir, ".env"),
|
|
2316
|
+
join7(opts.homeDir ?? homedir(), ".config/opencode/telegram-remote/.env")
|
|
1787
2317
|
];
|
|
1788
2318
|
const loadedFrom = [];
|
|
1789
2319
|
const values = {};
|
|
@@ -1801,10 +2331,10 @@ function loadPluginEnv(opts) {
|
|
|
1801
2331
|
}
|
|
1802
2332
|
|
|
1803
2333
|
// src/lib/lock.ts
|
|
1804
|
-
import { open as open2, readFile as
|
|
2334
|
+
import { open as open2, readFile as readFile6, stat as stat3, unlink as unlink6 } from "fs/promises";
|
|
1805
2335
|
import { hostname } from "os";
|
|
1806
2336
|
var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
|
|
1807
|
-
function
|
|
2337
|
+
function hasCode6(err, code) {
|
|
1808
2338
|
return "code" in err && err.code === code;
|
|
1809
2339
|
}
|
|
1810
2340
|
function parseLockData(text) {
|
|
@@ -1823,7 +2353,7 @@ function isPidAlive(pid) {
|
|
|
1823
2353
|
process.kill(pid, 0);
|
|
1824
2354
|
return true;
|
|
1825
2355
|
} catch (err) {
|
|
1826
|
-
if (err instanceof Error &&
|
|
2356
|
+
if (err instanceof Error && hasCode6(err, "ESRCH")) return false;
|
|
1827
2357
|
return true;
|
|
1828
2358
|
}
|
|
1829
2359
|
}
|
|
@@ -1844,7 +2374,7 @@ async function createLock(lockPath, pid) {
|
|
|
1844
2374
|
if (released) return;
|
|
1845
2375
|
released = true;
|
|
1846
2376
|
try {
|
|
1847
|
-
await
|
|
2377
|
+
await unlink6(lockPath);
|
|
1848
2378
|
} catch {
|
|
1849
2379
|
}
|
|
1850
2380
|
}
|
|
@@ -1854,7 +2384,7 @@ async function inspectExisting(lockPath, ttlMs) {
|
|
|
1854
2384
|
let ownerPid;
|
|
1855
2385
|
let dead = false;
|
|
1856
2386
|
try {
|
|
1857
|
-
const text = await
|
|
2387
|
+
const text = await readFile6(lockPath, "utf8");
|
|
1858
2388
|
const data = parseLockData(text);
|
|
1859
2389
|
if (data) {
|
|
1860
2390
|
ownerPid = data.pid;
|
|
@@ -1880,7 +2410,7 @@ async function acquireLock(opts) {
|
|
|
1880
2410
|
try {
|
|
1881
2411
|
return { acquired: true, handle: await createLock(opts.lockPath, pid) };
|
|
1882
2412
|
} catch (err) {
|
|
1883
|
-
if (!(err instanceof Error) || !
|
|
2413
|
+
if (!(err instanceof Error) || !hasCode6(err, "EEXIST")) {
|
|
1884
2414
|
return { acquired: false, reason: err instanceof Error ? err.message : String(err) };
|
|
1885
2415
|
}
|
|
1886
2416
|
const existing = await inspectExisting(opts.lockPath, ttlMs);
|
|
@@ -1888,7 +2418,7 @@ async function acquireLock(opts) {
|
|
|
1888
2418
|
return { acquired: false, reason: existing.reason, ownerPid: existing.ownerPid };
|
|
1889
2419
|
}
|
|
1890
2420
|
try {
|
|
1891
|
-
await
|
|
2421
|
+
await unlink6(opts.lockPath);
|
|
1892
2422
|
} catch {
|
|
1893
2423
|
return { acquired: false, reason: "failed to remove stale lock", ownerPid: existing.ownerPid };
|
|
1894
2424
|
}
|
|
@@ -1968,10 +2498,10 @@ function createLogger(opts = {}) {
|
|
|
1968
2498
|
}
|
|
1969
2499
|
|
|
1970
2500
|
// src/lib/session-snapshot.ts
|
|
1971
|
-
import { chmod, mkdir as
|
|
1972
|
-
import { dirname as dirname4, join as
|
|
2501
|
+
import { chmod as chmod2, mkdir as mkdir6, readFile as readFile7, rename as rename5, unlink as unlink7, writeFile as writeFile5 } from "fs/promises";
|
|
2502
|
+
import { dirname as dirname4, join as join8 } from "path";
|
|
1973
2503
|
var TTL_MS = 60 * 60 * 1e3;
|
|
1974
|
-
function
|
|
2504
|
+
function hasCode7(err, code) {
|
|
1975
2505
|
return err instanceof Error && "code" in err && err.code === code;
|
|
1976
2506
|
}
|
|
1977
2507
|
function isSnapshotFile(value) {
|
|
@@ -1990,11 +2520,14 @@ function isSnapshotFile(value) {
|
|
|
1990
2520
|
if (typeof e.capturedAt !== "number") return false;
|
|
1991
2521
|
if (e.agent !== void 0 && typeof e.agent !== "string") return false;
|
|
1992
2522
|
if (e.status !== void 0 && typeof e.status !== "string") return false;
|
|
1993
|
-
if (e.serverUrl !== void 0
|
|
2523
|
+
if (e.serverUrl !== void 0) {
|
|
2524
|
+
if (typeof e.serverUrl !== "string") return false;
|
|
2525
|
+
if (!normalizeOpenCodeServerUrl(e.serverUrl)) return false;
|
|
2526
|
+
}
|
|
1994
2527
|
}
|
|
1995
2528
|
return true;
|
|
1996
2529
|
}
|
|
1997
|
-
function
|
|
2530
|
+
function normalizeEntry2(entry) {
|
|
1998
2531
|
const out = {
|
|
1999
2532
|
index: entry.index,
|
|
2000
2533
|
sessionId: entry.sessionId,
|
|
@@ -2003,22 +2536,25 @@ function normalizeEntry(entry) {
|
|
|
2003
2536
|
};
|
|
2004
2537
|
if (entry.agent !== void 0) out.agent = entry.agent;
|
|
2005
2538
|
if (entry.status !== void 0) out.status = entry.status;
|
|
2006
|
-
if (entry.serverUrl !== void 0)
|
|
2539
|
+
if (entry.serverUrl !== void 0) {
|
|
2540
|
+
const serverUrl = normalizeOpenCodeServerUrl(entry.serverUrl);
|
|
2541
|
+
if (serverUrl !== void 0) out.serverUrl = serverUrl;
|
|
2542
|
+
}
|
|
2007
2543
|
return out;
|
|
2008
2544
|
}
|
|
2009
2545
|
function createSnapshotStore(opts) {
|
|
2010
2546
|
const { configDir, tokenHash, logger } = opts;
|
|
2011
|
-
const snapshotsDir =
|
|
2547
|
+
const snapshotsDir = join8(configDir, "snapshots");
|
|
2012
2548
|
const writeLocks = /* @__PURE__ */ new Map();
|
|
2013
2549
|
function snapshotFilePath(chatId) {
|
|
2014
|
-
return
|
|
2550
|
+
return join8(snapshotsDir, `${tokenHash}-${chatId}.json`);
|
|
2015
2551
|
}
|
|
2016
2552
|
async function performSave(chatId, entries) {
|
|
2017
2553
|
const filePath = snapshotFilePath(chatId);
|
|
2018
2554
|
const parent = dirname4(filePath);
|
|
2019
|
-
await
|
|
2555
|
+
await mkdir6(parent, { recursive: true });
|
|
2020
2556
|
try {
|
|
2021
|
-
await
|
|
2557
|
+
await chmod2(parent, 448);
|
|
2022
2558
|
} catch (err) {
|
|
2023
2559
|
logger.error("snapshot: failed to chmod parent dir", {
|
|
2024
2560
|
path: parent,
|
|
@@ -2029,20 +2565,20 @@ function createSnapshotStore(opts) {
|
|
|
2029
2565
|
version: 1,
|
|
2030
2566
|
chatId,
|
|
2031
2567
|
createdAt: Date.now(),
|
|
2032
|
-
entries: entries.map(
|
|
2568
|
+
entries: entries.map(normalizeEntry2)
|
|
2033
2569
|
};
|
|
2034
2570
|
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
2035
|
-
await
|
|
2571
|
+
await writeFile5(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
|
2036
2572
|
try {
|
|
2037
|
-
await
|
|
2573
|
+
await rename5(tmpPath, filePath);
|
|
2038
2574
|
} catch (err) {
|
|
2039
2575
|
try {
|
|
2040
|
-
await
|
|
2576
|
+
await unlink7(tmpPath);
|
|
2041
2577
|
} catch {
|
|
2042
2578
|
}
|
|
2043
2579
|
throw err;
|
|
2044
2580
|
}
|
|
2045
|
-
await
|
|
2581
|
+
await chmod2(filePath, 384);
|
|
2046
2582
|
}
|
|
2047
2583
|
async function saveSnapshot(chatId, entries) {
|
|
2048
2584
|
const prev = writeLocks.get(chatId) ?? Promise.resolve();
|
|
@@ -2061,9 +2597,9 @@ function createSnapshotStore(opts) {
|
|
|
2061
2597
|
const filePath = snapshotFilePath(chatId);
|
|
2062
2598
|
let text;
|
|
2063
2599
|
try {
|
|
2064
|
-
text = await
|
|
2600
|
+
text = await readFile7(filePath, "utf8");
|
|
2065
2601
|
} catch (err) {
|
|
2066
|
-
if (
|
|
2602
|
+
if (hasCode7(err, "ENOENT")) return null;
|
|
2067
2603
|
logger.error("snapshot: failed to read file", {
|
|
2068
2604
|
path: filePath,
|
|
2069
2605
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -2086,9 +2622,9 @@ function createSnapshotStore(opts) {
|
|
|
2086
2622
|
}
|
|
2087
2623
|
if (parsed.createdAt + TTL_MS < Date.now()) {
|
|
2088
2624
|
try {
|
|
2089
|
-
await
|
|
2625
|
+
await unlink7(filePath);
|
|
2090
2626
|
} catch (err) {
|
|
2091
|
-
if (!
|
|
2627
|
+
if (!hasCode7(err, "ENOENT")) {
|
|
2092
2628
|
logger.error("snapshot: failed to unlink expired file", {
|
|
2093
2629
|
path: filePath,
|
|
2094
2630
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -2097,16 +2633,16 @@ function createSnapshotStore(opts) {
|
|
|
2097
2633
|
}
|
|
2098
2634
|
return null;
|
|
2099
2635
|
}
|
|
2100
|
-
return parsed.entries.map(
|
|
2636
|
+
return parsed.entries.map(normalizeEntry2);
|
|
2101
2637
|
}
|
|
2102
2638
|
return { saveSnapshot, loadSnapshot, snapshotFilePath };
|
|
2103
2639
|
}
|
|
2104
2640
|
|
|
2105
2641
|
// src/lib/state-store.ts
|
|
2106
|
-
import { mkdir as
|
|
2642
|
+
import { mkdir as mkdir7, readFile as readFile8, rename as rename6, writeFile as writeFile6 } from "fs/promises";
|
|
2107
2643
|
import { homedir as homedir2 } from "os";
|
|
2108
|
-
import { dirname as dirname5, join as
|
|
2109
|
-
function
|
|
2644
|
+
import { dirname as dirname5, join as join9 } from "path";
|
|
2645
|
+
function hasCode8(err, code) {
|
|
2110
2646
|
return "code" in err && err.code === code;
|
|
2111
2647
|
}
|
|
2112
2648
|
function parseState(text) {
|
|
@@ -2118,28 +2654,28 @@ function parseState(text) {
|
|
|
2118
2654
|
return state;
|
|
2119
2655
|
}
|
|
2120
2656
|
function createStateStore(opts = {}) {
|
|
2121
|
-
const filePath = opts.filePath ??
|
|
2657
|
+
const filePath = opts.filePath ?? join9(homedir2(), ".config/opencode/telegram-remote/state.json");
|
|
2122
2658
|
return {
|
|
2123
2659
|
async read() {
|
|
2124
2660
|
try {
|
|
2125
|
-
return parseState(await
|
|
2661
|
+
return parseState(await readFile8(filePath, "utf8"));
|
|
2126
2662
|
} catch (err) {
|
|
2127
|
-
if (err instanceof Error &&
|
|
2663
|
+
if (err instanceof Error && hasCode8(err, "ENOENT")) return {};
|
|
2128
2664
|
throw err;
|
|
2129
2665
|
}
|
|
2130
2666
|
},
|
|
2131
2667
|
async write(patch) {
|
|
2132
2668
|
const existing = await this.read();
|
|
2133
2669
|
const next = { ...existing, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
2134
|
-
await
|
|
2670
|
+
await mkdir7(dirname5(filePath), { recursive: true });
|
|
2135
2671
|
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
2136
|
-
await
|
|
2672
|
+
await writeFile6(tmpPath, JSON.stringify(next, null, 2), "utf8");
|
|
2137
2673
|
try {
|
|
2138
|
-
await
|
|
2674
|
+
await rename6(tmpPath, filePath);
|
|
2139
2675
|
} catch (err) {
|
|
2140
|
-
if (!(err instanceof Error) || !
|
|
2141
|
-
await
|
|
2142
|
-
await
|
|
2676
|
+
if (!(err instanceof Error) || !hasCode8(err, "ENOENT")) throw err;
|
|
2677
|
+
await writeFile6(tmpPath, JSON.stringify(next, null, 2), "utf8");
|
|
2678
|
+
await rename6(tmpPath, filePath);
|
|
2143
2679
|
}
|
|
2144
2680
|
return next;
|
|
2145
2681
|
}
|
|
@@ -2147,7 +2683,7 @@ function createStateStore(opts = {}) {
|
|
|
2147
2683
|
}
|
|
2148
2684
|
|
|
2149
2685
|
// src/services/session-title-service.ts
|
|
2150
|
-
function
|
|
2686
|
+
function agentFromSession4(info) {
|
|
2151
2687
|
const candidate = info;
|
|
2152
2688
|
return typeof candidate.agent === "string" ? candidate.agent : void 0;
|
|
2153
2689
|
}
|
|
@@ -2158,7 +2694,7 @@ var SessionTitleService = class {
|
|
|
2158
2694
|
this.sessions.set(info.id, {
|
|
2159
2695
|
title: info.title || null,
|
|
2160
2696
|
parentID: info.parentID ?? null,
|
|
2161
|
-
agent:
|
|
2697
|
+
agent: agentFromSession4(info) ?? existing?.agent,
|
|
2162
2698
|
status: existing?.status,
|
|
2163
2699
|
idleNotificationPending: existing?.idleNotificationPending ?? false,
|
|
2164
2700
|
lastSeenAt: Date.now(),
|
|
@@ -2286,17 +2822,17 @@ var SessionTitleService = class {
|
|
|
2286
2822
|
// src/telegram-remote.ts
|
|
2287
2823
|
var pluginDir = dirname6(fileURLToPath(import.meta.url));
|
|
2288
2824
|
async function postToServer(serverUrl, path, body) {
|
|
2289
|
-
const
|
|
2825
|
+
const safeServerUrl = normalizeOpenCodeServerUrl(serverUrl);
|
|
2826
|
+
if (!safeServerUrl) throw new Error("Invalid OpenCode server URL");
|
|
2827
|
+
const url = new URL(path, safeServerUrl);
|
|
2290
2828
|
const response = await fetch(url, {
|
|
2291
2829
|
method: "POST",
|
|
2292
2830
|
headers: { "Content-Type": "application/json" },
|
|
2293
|
-
body: JSON.stringify(body)
|
|
2831
|
+
body: JSON.stringify(body),
|
|
2832
|
+
redirect: "error"
|
|
2294
2833
|
});
|
|
2295
2834
|
if (response.ok) return;
|
|
2296
|
-
|
|
2297
|
-
throw new Error(
|
|
2298
|
-
`OpenCode request failed: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`
|
|
2299
|
-
);
|
|
2835
|
+
throw new Error(`OpenCode request failed: ${response.status} ${response.statusText}`);
|
|
2300
2836
|
}
|
|
2301
2837
|
function getSessionAgentFromMessage(event) {
|
|
2302
2838
|
const info = event.properties.info;
|
|
@@ -2320,10 +2856,11 @@ var TelegramRemote = async (input) => {
|
|
|
2320
2856
|
const stateStore = createStateStore();
|
|
2321
2857
|
const initialState = await stateStore.read();
|
|
2322
2858
|
const tokenHash = createHash5("sha256").update(config.botToken).digest("hex").slice(0, 16);
|
|
2323
|
-
const configDir =
|
|
2859
|
+
const configDir = join10(homedir3(), ".config/opencode/telegram-remote");
|
|
2324
2860
|
const snapshotStore = createSnapshotStore({ configDir, tokenHash, logger });
|
|
2325
|
-
const
|
|
2326
|
-
const
|
|
2861
|
+
const sessionRegistry = createSessionRegistryStore({ configDir, tokenHash, logger });
|
|
2862
|
+
const lockPath = join10(tmpdir5(), `opencoder-telegram-${tokenHash}.lock`);
|
|
2863
|
+
const claimsDir = join10(tmpdir5(), `opencoder-telegram-claims-${tokenHash}`);
|
|
2327
2864
|
const pendingQuestions = createPendingQuestionStore({ tokenHash });
|
|
2328
2865
|
const pendingPermissions = createPendingPermissionStore({ tokenHash });
|
|
2329
2866
|
const pendingStartWorks = createPendingStartWorkStore({ tokenHash });
|
|
@@ -2353,8 +2890,8 @@ var TelegramRemote = async (input) => {
|
|
|
2353
2890
|
throwOnError: true
|
|
2354
2891
|
});
|
|
2355
2892
|
};
|
|
2356
|
-
const replyToPermission = async (requestID, sessionID, reply,
|
|
2357
|
-
if (
|
|
2893
|
+
const replyToPermission = async (requestID, sessionID, reply, endpoint2, serverUrl = input.serverUrl.href) => {
|
|
2894
|
+
if (endpoint2 === "request") {
|
|
2358
2895
|
const path2 = `/permission/${encodeURIComponent(requestID)}/reply`;
|
|
2359
2896
|
if (serverUrl !== input.serverUrl.href) {
|
|
2360
2897
|
await postToServer(serverUrl, path2, { reply });
|
|
@@ -2438,6 +2975,7 @@ var TelegramRemote = async (input) => {
|
|
|
2438
2975
|
pendingQuestions,
|
|
2439
2976
|
pendingPermissions,
|
|
2440
2977
|
pendingStartWorks,
|
|
2978
|
+
sessionRegistry,
|
|
2441
2979
|
replyToQuestion,
|
|
2442
2980
|
replyToPermission,
|
|
2443
2981
|
runSessionCommand
|
|
@@ -2446,26 +2984,30 @@ var TelegramRemote = async (input) => {
|
|
|
2446
2984
|
bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
|
|
2447
2985
|
bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
|
|
2448
2986
|
bot.setStartWorkDispatcher(createStartWorkDispatcher(ctx));
|
|
2449
|
-
bot.setSessionsDispatcher(createSessionsDispatcher({
|
|
2450
|
-
|
|
2987
|
+
bot.setSessionsDispatcher(createSessionsDispatcher({
|
|
2988
|
+
client: input.client,
|
|
2989
|
+
sessionTitleService,
|
|
2990
|
+
sessionRegistry,
|
|
2991
|
+
snapshotStore,
|
|
2992
|
+
serverUrl: input.serverUrl.href,
|
|
2993
|
+
logger
|
|
2994
|
+
}));
|
|
2995
|
+
bot.setStatusDispatcher(createStatusDispatcher({
|
|
2996
|
+
snapshotStore,
|
|
2997
|
+
sessionTitleService,
|
|
2998
|
+
client: input.client,
|
|
2999
|
+
logger,
|
|
3000
|
+
serverUrl: input.serverUrl.href
|
|
3001
|
+
}));
|
|
2451
3002
|
bot.setStartWorkCommandDispatcher(createStartWorkCommandDispatcher({
|
|
2452
3003
|
snapshotStore,
|
|
2453
3004
|
sessionTitleService,
|
|
2454
3005
|
client: input.client,
|
|
3006
|
+
serverUrl: input.serverUrl.href,
|
|
2455
3007
|
runSessionCommand,
|
|
2456
3008
|
logger
|
|
2457
3009
|
}));
|
|
2458
3010
|
bot.setHelpDispatcher(createHelpDispatcher({ logger }));
|
|
2459
|
-
try {
|
|
2460
|
-
const sessions = await input.client.session.list();
|
|
2461
|
-
for (const s of sessions.data ?? []) {
|
|
2462
|
-
sessionTitleService.setSessionInfo(s);
|
|
2463
|
-
sessionTitleService.setServerUrl(s.id, input.serverUrl.href);
|
|
2464
|
-
}
|
|
2465
|
-
logger.info("cold-start cache primed", { count: (sessions.data ?? []).length });
|
|
2466
|
-
} catch (err) {
|
|
2467
|
-
logger.error("cold-start priming failed", { error: String(err) });
|
|
2468
|
-
}
|
|
2469
3011
|
}
|
|
2470
3012
|
return {
|
|
2471
3013
|
event: async ({ event }) => {
|
|
@@ -2485,8 +3027,16 @@ var TelegramRemote = async (input) => {
|
|
|
2485
3027
|
case "message.updated": {
|
|
2486
3028
|
const messageAgent = getSessionAgentFromMessage(event);
|
|
2487
3029
|
if (messageAgent) {
|
|
3030
|
+
const previousAgent = ctx.sessionTitleService.getSessionAgent(messageAgent.sessionID);
|
|
2488
3031
|
ctx.sessionTitleService.setSessionAgent(messageAgent.sessionID, messageAgent.agent);
|
|
2489
3032
|
ctx.sessionTitleService.setServerUrl(messageAgent.sessionID, input.serverUrl.href);
|
|
3033
|
+
if (previousAgent !== messageAgent.agent) {
|
|
3034
|
+
await ctx.sessionRegistry.updateSession(messageAgent.sessionID, {
|
|
3035
|
+
agent: messageAgent.agent,
|
|
3036
|
+
serverUrl: input.serverUrl.href,
|
|
3037
|
+
updatedAt: Date.now()
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
2490
3040
|
}
|
|
2491
3041
|
return;
|
|
2492
3042
|
}
|
|
@@ -2495,7 +3045,16 @@ var TelegramRemote = async (input) => {
|
|
|
2495
3045
|
default: {
|
|
2496
3046
|
const stepAgent = getSessionAgentFromNextStep(extEvent);
|
|
2497
3047
|
if (stepAgent) {
|
|
3048
|
+
const previousAgent = ctx.sessionTitleService.getSessionAgent(stepAgent.sessionID);
|
|
2498
3049
|
ctx.sessionTitleService.setSessionAgent(stepAgent.sessionID, stepAgent.agent);
|
|
3050
|
+
ctx.sessionTitleService.setServerUrl(stepAgent.sessionID, input.serverUrl.href);
|
|
3051
|
+
if (previousAgent !== stepAgent.agent) {
|
|
3052
|
+
await ctx.sessionRegistry.updateSession(stepAgent.sessionID, {
|
|
3053
|
+
agent: stepAgent.agent,
|
|
3054
|
+
serverUrl: input.serverUrl.href,
|
|
3055
|
+
updatedAt: Date.now()
|
|
3056
|
+
});
|
|
3057
|
+
}
|
|
2499
3058
|
return;
|
|
2500
3059
|
}
|
|
2501
3060
|
if (isEventPermissionAsked(extEvent)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coinseeker/opencode-telegram-plugin",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Control and monitor OpenCode from Telegram with notifications, question replies, and subagent-aware completion.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/telegram-remote.js",
|