@coinseeker/opencode-telegram-plugin 1.0.13 → 1.0.14

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 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.12"]
18
+ "plugin": ["@coinseeker/opencode-telegram-plugin@1.0.14"]
19
19
  }
20
20
  ```
21
21
 
22
- Current stable version: `@coinseeker/opencode-telegram-plugin@1.0.12`.
22
+ Current stable version: `@coinseeker/opencode-telegram-plugin@1.0.14`.
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
- - Remote session listing via `/sessions`, `/status N`, `/start_work N`, `/help` slash commands.
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
@@ -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 join9 } from "path";
9
+ import { dirname as dirname6, join as join10 } from "path";
10
10
  import { fileURLToPath } from "url";
11
11
 
12
12
  // src/bot.ts
@@ -1017,9 +1017,313 @@ async function handleQuestionReplied(event, ctx) {
1017
1017
  }
1018
1018
  }
1019
1019
 
1020
+ // src/lib/session-registry.ts
1021
+ import { chmod, mkdir as mkdir4, readFile as readFile3, readdir as readdir4, rename as rename3, unlink as unlink4, writeFile as writeFile3 } from "fs/promises";
1022
+ import { join as join4 } from "path";
1023
+
1024
+ // src/lib/opencode-http.ts
1025
+ var ALLOWED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
1026
+ function asRecord(value) {
1027
+ if (!value || typeof value !== "object") return void 0;
1028
+ return value;
1029
+ }
1030
+ function isStatusType(value) {
1031
+ return value === "idle" || value === "busy" || value === "retry";
1032
+ }
1033
+ function normalizeOpenCodeServerUrl(value) {
1034
+ if (!value) return void 0;
1035
+ try {
1036
+ const url = new URL(value);
1037
+ if (url.protocol !== "http:" && url.protocol !== "https:") return void 0;
1038
+ if (url.username || url.password) return void 0;
1039
+ if (url.search || url.hash) return void 0;
1040
+ if (url.pathname !== "/" && url.pathname !== "") return void 0;
1041
+ if (!ALLOWED_HOSTNAMES.has(url.hostname)) return void 0;
1042
+ return url.href;
1043
+ } catch {
1044
+ return void 0;
1045
+ }
1046
+ }
1047
+ function requireOpenCodeServerUrl(serverUrl) {
1048
+ const normalized = normalizeOpenCodeServerUrl(serverUrl);
1049
+ if (!normalized) throw new Error("Invalid OpenCode server URL");
1050
+ return normalized;
1051
+ }
1052
+ function endpoint(serverUrl, path) {
1053
+ return new URL(path, requireOpenCodeServerUrl(serverUrl));
1054
+ }
1055
+ function isDifferentServerUrl(sourceServerUrl, currentServerUrl) {
1056
+ const source = normalizeOpenCodeServerUrl(sourceServerUrl);
1057
+ const current = normalizeOpenCodeServerUrl(currentServerUrl);
1058
+ if (!source || !current) return false;
1059
+ return source !== current;
1060
+ }
1061
+ function normalizeSession(value) {
1062
+ const record = asRecord(value);
1063
+ if (!record || typeof record.directory !== "string") return void 0;
1064
+ const session = { directory: record.directory };
1065
+ if (typeof record.id === "string") session.id = record.id;
1066
+ if (typeof record.title === "string") session.title = record.title;
1067
+ if (typeof record.parentID === "string" || record.parentID === null) {
1068
+ session.parentID = record.parentID;
1069
+ }
1070
+ if (typeof record.agent === "string") session.agent = record.agent;
1071
+ return session;
1072
+ }
1073
+ function normalizeSessionList(value) {
1074
+ if (!Array.isArray(value)) return [];
1075
+ const sessions = [];
1076
+ for (const raw of value) {
1077
+ const record = asRecord(raw);
1078
+ const time = asRecord(record?.time);
1079
+ if (!record) continue;
1080
+ if (typeof record.id !== "string") continue;
1081
+ if (typeof record.title !== "string") continue;
1082
+ if (!time || typeof time.updated !== "number") continue;
1083
+ const session = {
1084
+ id: record.id,
1085
+ title: record.title,
1086
+ time: { updated: time.updated }
1087
+ };
1088
+ if (typeof record.parentID === "string" || record.parentID === null) {
1089
+ session.parentID = record.parentID;
1090
+ }
1091
+ if (typeof record.agent === "string") session.agent = record.agent;
1092
+ sessions.push(session);
1093
+ }
1094
+ return sessions;
1095
+ }
1096
+ function normalizeStatusMap(value) {
1097
+ const record = asRecord(value);
1098
+ if (!record) return {};
1099
+ const out = {};
1100
+ for (const [sessionId, rawStatus] of Object.entries(record)) {
1101
+ const status = asRecord(rawStatus);
1102
+ if (status && isStatusType(status.type)) out[sessionId] = { type: status.type };
1103
+ }
1104
+ return out;
1105
+ }
1106
+ function normalizeMessages(value) {
1107
+ if (!Array.isArray(value)) return [];
1108
+ const messages = [];
1109
+ for (const rawMessage of value) {
1110
+ const message = asRecord(rawMessage);
1111
+ const info = asRecord(message?.info);
1112
+ if (!message || !info || typeof info.role !== "string" || !Array.isArray(message.parts)) continue;
1113
+ const parts = [];
1114
+ for (const rawPart of message.parts) {
1115
+ const part = asRecord(rawPart);
1116
+ if (!part || typeof part.type !== "string") continue;
1117
+ const normalized = { type: part.type };
1118
+ if (typeof part.text === "string") normalized.text = part.text;
1119
+ parts.push(normalized);
1120
+ }
1121
+ messages.push({ info: { role: info.role }, parts });
1122
+ }
1123
+ return messages;
1124
+ }
1125
+ async function fetchJson(serverUrl, path, fetcher) {
1126
+ const response = await fetcher(endpoint(serverUrl, path), { redirect: "error" });
1127
+ if (response.status === 404) return { data: void 0, response: { status: response.status } };
1128
+ if (!response.ok) {
1129
+ throw new Error(`OpenCode request failed: ${response.status} ${response.statusText}`);
1130
+ }
1131
+ return { data: await response.json(), response: { status: response.status } };
1132
+ }
1133
+ async function getRemoteSession(serverUrl, sessionId, fetcher = fetch) {
1134
+ const result = await fetchJson(serverUrl, `/session/${encodeURIComponent(sessionId)}`, fetcher);
1135
+ return { data: normalizeSession(result.data), response: result.response };
1136
+ }
1137
+ async function getRemoteStatusMap(serverUrl, fetcher = fetch) {
1138
+ const result = await fetchJson(serverUrl, "/session/status", fetcher);
1139
+ return normalizeStatusMap(result.data);
1140
+ }
1141
+ async function getRemoteSessions(serverUrl, fetcher = fetch) {
1142
+ const result = await fetchJson(serverUrl, "/session", fetcher);
1143
+ return normalizeSessionList(result.data);
1144
+ }
1145
+ async function getRemoteMessages(serverUrl, sessionId, limit, fetcher = fetch) {
1146
+ const url = endpoint(serverUrl, `/session/${encodeURIComponent(sessionId)}/message`);
1147
+ url.searchParams.set("limit", String(limit));
1148
+ const response = await fetcher(url, { redirect: "error" });
1149
+ if (response.status === 404) return [];
1150
+ if (!response.ok) {
1151
+ throw new Error(`OpenCode request failed: ${response.status} ${response.statusText}`);
1152
+ }
1153
+ return normalizeMessages(await response.json());
1154
+ }
1155
+
1156
+ // src/lib/session-registry.ts
1157
+ function filenameForSession(sessionId) {
1158
+ return Buffer.from(sessionId).toString("base64url") + ".json";
1159
+ }
1160
+ function hasCode4(err, code) {
1161
+ return err instanceof Error && "code" in err && err.code === code;
1162
+ }
1163
+ function normalizeEntry(entry) {
1164
+ const serverUrl = normalizeOpenCodeServerUrl(entry.serverUrl);
1165
+ if (!serverUrl) throw new Error("invalid registry serverUrl");
1166
+ const out = {
1167
+ sessionId: entry.sessionId,
1168
+ title: entry.title,
1169
+ parentID: entry.parentID,
1170
+ serverUrl,
1171
+ updatedAt: entry.updatedAt
1172
+ };
1173
+ if (entry.agent !== void 0) out.agent = entry.agent;
1174
+ if (entry.status !== void 0) out.status = entry.status;
1175
+ return out;
1176
+ }
1177
+ function isRegistryFile(value) {
1178
+ if (!value || typeof value !== "object") return false;
1179
+ const file = value;
1180
+ if (file.version !== 1) return false;
1181
+ const entry = file.entry;
1182
+ if (!entry || typeof entry !== "object") return false;
1183
+ const e = entry;
1184
+ if (typeof e.sessionId !== "string") return false;
1185
+ if (typeof e.title !== "string") return false;
1186
+ if (e.parentID !== null && typeof e.parentID !== "string") return false;
1187
+ if (e.agent !== void 0 && typeof e.agent !== "string") return false;
1188
+ if (e.status !== void 0 && e.status !== "idle" && e.status !== "busy" && e.status !== "retry") {
1189
+ return false;
1190
+ }
1191
+ if (typeof e.serverUrl !== "string") return false;
1192
+ if (!normalizeOpenCodeServerUrl(e.serverUrl)) return false;
1193
+ if (typeof e.updatedAt !== "number") return false;
1194
+ return true;
1195
+ }
1196
+ function agentFromSession(session) {
1197
+ const candidate = session;
1198
+ return typeof candidate.agent === "string" ? candidate.agent : void 0;
1199
+ }
1200
+ function registryEntryFromSession(session, serverUrl, status) {
1201
+ const entry = {
1202
+ sessionId: session.id,
1203
+ title: session.title,
1204
+ parentID: session.parentID ?? null,
1205
+ serverUrl,
1206
+ updatedAt: session.time.updated
1207
+ };
1208
+ const agent = agentFromSession(session);
1209
+ if (agent !== void 0) entry.agent = agent;
1210
+ if (status !== void 0) entry.status = status;
1211
+ return entry;
1212
+ }
1213
+ function createSessionRegistryStore(opts) {
1214
+ const registryDir = join4(opts.configDir, "session-registry", opts.tokenHash);
1215
+ function filePath(sessionId) {
1216
+ return join4(registryDir, filenameForSession(sessionId));
1217
+ }
1218
+ async function readEntry(sessionId) {
1219
+ let text;
1220
+ try {
1221
+ text = await readFile3(filePath(sessionId), "utf8");
1222
+ } catch (err) {
1223
+ if (hasCode4(err, "ENOENT")) return null;
1224
+ opts.logger.error("session-registry: failed to read file", {
1225
+ sessionId,
1226
+ error: String(err)
1227
+ });
1228
+ return null;
1229
+ }
1230
+ try {
1231
+ const parsed = JSON.parse(text);
1232
+ if (!isRegistryFile(parsed)) return null;
1233
+ return normalizeEntry(parsed.entry);
1234
+ } catch (err) {
1235
+ opts.logger.error("session-registry: corrupted JSON", { sessionId, error: String(err) });
1236
+ return null;
1237
+ }
1238
+ }
1239
+ async function writeEntry(entry) {
1240
+ await mkdir4(registryDir, { recursive: true });
1241
+ try {
1242
+ await chmod(registryDir, 448);
1243
+ } catch (err) {
1244
+ opts.logger.error("session-registry: failed to chmod dir", { error: String(err) });
1245
+ }
1246
+ const payload = { version: 1, entry: normalizeEntry(entry) };
1247
+ const target = filePath(entry.sessionId);
1248
+ const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
1249
+ await writeFile3(tmp, JSON.stringify(payload, null, 2), "utf8");
1250
+ try {
1251
+ await rename3(tmp, target);
1252
+ } catch (err) {
1253
+ try {
1254
+ await unlink4(tmp);
1255
+ } catch {
1256
+ }
1257
+ throw err;
1258
+ }
1259
+ await chmod(target, 384);
1260
+ }
1261
+ async function upsertSession(entry) {
1262
+ const existing = await readEntry(entry.sessionId);
1263
+ await writeEntry({
1264
+ ...existing,
1265
+ ...entry,
1266
+ agent: entry.agent ?? existing?.agent,
1267
+ status: entry.status ?? existing?.status
1268
+ });
1269
+ }
1270
+ async function updateSession(sessionId, patch) {
1271
+ const existing = await readEntry(sessionId);
1272
+ if (!existing) return;
1273
+ await writeEntry({
1274
+ ...existing,
1275
+ ...patch,
1276
+ sessionId,
1277
+ title: patch.title ?? existing.title,
1278
+ parentID: patch.parentID ?? existing.parentID,
1279
+ serverUrl: patch.serverUrl ?? existing.serverUrl,
1280
+ updatedAt: patch.updatedAt ?? Date.now()
1281
+ });
1282
+ }
1283
+ async function listSessions() {
1284
+ let names;
1285
+ try {
1286
+ names = await readdir4(registryDir);
1287
+ } catch (err) {
1288
+ if (hasCode4(err, "ENOENT")) return [];
1289
+ opts.logger.error("session-registry: failed to list dir", { error: String(err) });
1290
+ return [];
1291
+ }
1292
+ const entries = [];
1293
+ for (const name of names) {
1294
+ if (!name.endsWith(".json")) continue;
1295
+ let text;
1296
+ try {
1297
+ text = await readFile3(join4(registryDir, name), "utf8");
1298
+ } catch (err) {
1299
+ opts.logger.error("session-registry: failed to read listed file", {
1300
+ file: name,
1301
+ error: String(err)
1302
+ });
1303
+ continue;
1304
+ }
1305
+ try {
1306
+ const parsed = JSON.parse(text);
1307
+ if (isRegistryFile(parsed)) entries.push(normalizeEntry(parsed.entry));
1308
+ } catch (err) {
1309
+ opts.logger.error("session-registry: corrupted listed file", {
1310
+ file: name,
1311
+ error: String(err)
1312
+ });
1313
+ }
1314
+ }
1315
+ return entries;
1316
+ }
1317
+ return { upsertSession, updateSession, listSessions };
1318
+ }
1319
+
1020
1320
  // src/events/session-created.ts
1021
1321
  async function handleSessionCreated(event, ctx) {
1022
- ctx.sessionTitleService.setSessionInfo(event.properties.info);
1322
+ const info = event.properties.info;
1323
+ ctx.sessionTitleService.setSessionInfo(info);
1324
+ await ctx.sessionRegistry.upsertSession(
1325
+ registryEntryFromSession(info, ctx.serverUrl.href, ctx.sessionTitleService.getSessionStatus(info.id))
1326
+ );
1023
1327
  }
1024
1328
 
1025
1329
  // src/lib/abort-tracker.ts
@@ -1061,14 +1365,14 @@ async function handleSessionError(event, ctx) {
1061
1365
 
1062
1366
  // src/lib/pending-start-work.ts
1063
1367
  import { createHash as createHash4 } from "crypto";
1064
- import { mkdir as mkdir4, readdir as readdir4, readFile as readFile3, rename as rename3, unlink as unlink4, writeFile as writeFile3 } from "fs/promises";
1368
+ import { mkdir as mkdir5, readdir as readdir5, readFile as readFile4, rename as rename4, unlink as unlink5, writeFile as writeFile4 } from "fs/promises";
1065
1369
  import { tmpdir as tmpdir3 } from "os";
1066
- import { dirname as dirname3, join as join4 } from "path";
1067
- function hasCode4(err, code) {
1370
+ import { dirname as dirname3, join as join5 } from "path";
1371
+ function hasCode5(err, code) {
1068
1372
  return "code" in err && err.code === code;
1069
1373
  }
1070
1374
  function pendingFilePath3(dir, shortHash) {
1071
- return join4(dir, `${shortHash}.json`);
1375
+ return join5(dir, `${shortHash}.json`);
1072
1376
  }
1073
1377
  function parsePending3(text) {
1074
1378
  const parsed = JSON.parse(text);
@@ -1087,10 +1391,10 @@ function parsePending3(text) {
1087
1391
  }
1088
1392
  async function listPendingFiles3(dir) {
1089
1393
  try {
1090
- const entries = await readdir4(dir, { withFileTypes: true });
1394
+ const entries = await readdir5(dir, { withFileTypes: true });
1091
1395
  return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name);
1092
1396
  } catch (err) {
1093
- if (err instanceof Error && hasCode4(err, "ENOENT")) return [];
1397
+ if (err instanceof Error && hasCode5(err, "ENOENT")) return [];
1094
1398
  throw err;
1095
1399
  }
1096
1400
  }
@@ -1098,29 +1402,29 @@ function shortHashFromFileName3(fileName) {
1098
1402
  return fileName.slice(0, -".json".length);
1099
1403
  }
1100
1404
  function createPendingStartWorkStore(opts) {
1101
- const dir = opts.baseDir ?? join4(tmpdir3(), `opencoder-telegram-pending-start-work-${opts.tokenHash}`);
1405
+ const dir = opts.baseDir ?? join5(tmpdir3(), `opencoder-telegram-pending-start-work-${opts.tokenHash}`);
1102
1406
  return {
1103
1407
  dir,
1104
1408
  async savePending(shortHash, data) {
1105
1409
  const filePath = pendingFilePath3(dir, shortHash);
1106
- await mkdir4(dirname3(filePath), { recursive: true });
1410
+ await mkdir5(dirname3(filePath), { recursive: true });
1107
1411
  const tmpPath = `${filePath}.tmp.${process.pid}`;
1108
- await writeFile3(tmpPath, JSON.stringify(data, null, 2), "utf8");
1109
- await rename3(tmpPath, filePath);
1412
+ await writeFile4(tmpPath, JSON.stringify(data, null, 2), "utf8");
1413
+ await rename4(tmpPath, filePath);
1110
1414
  },
1111
1415
  async loadPending(shortHash) {
1112
1416
  try {
1113
- return parsePending3(await readFile3(pendingFilePath3(dir, shortHash), "utf8"));
1417
+ return parsePending3(await readFile4(pendingFilePath3(dir, shortHash), "utf8"));
1114
1418
  } catch (err) {
1115
- if (err instanceof Error && hasCode4(err, "ENOENT")) return void 0;
1419
+ if (err instanceof Error && hasCode5(err, "ENOENT")) return void 0;
1116
1420
  throw err;
1117
1421
  }
1118
1422
  },
1119
1423
  async deletePending(shortHash) {
1120
1424
  try {
1121
- await unlink4(pendingFilePath3(dir, shortHash));
1425
+ await unlink5(pendingFilePath3(dir, shortHash));
1122
1426
  } catch (err) {
1123
- if (!(err instanceof Error) || !hasCode4(err, "ENOENT")) throw err;
1427
+ if (!(err instanceof Error) || !hasCode5(err, "ENOENT")) throw err;
1124
1428
  }
1125
1429
  },
1126
1430
  async sweepExpired() {
@@ -1228,6 +1532,13 @@ async function resolveParentID(sessionId, ctx) {
1228
1532
  const result = await ctx.client.session.get({ path: { id: sessionId } });
1229
1533
  if (result.data) {
1230
1534
  ctx.sessionTitleService.setSessionInfo(result.data);
1535
+ await ctx.sessionRegistry.upsertSession(
1536
+ registryEntryFromSession(
1537
+ result.data,
1538
+ ctx.serverUrl.href,
1539
+ ctx.sessionTitleService.getSessionStatus(sessionId)
1540
+ )
1541
+ );
1231
1542
  return ctx.sessionTitleService.getParentID(sessionId);
1232
1543
  }
1233
1544
  ctx.logger.warn("session parentID cache miss fetch returned no data", { sessionId });
@@ -1244,6 +1555,9 @@ async function hydrateDescendants(sessionId, ctx, seen = /* @__PURE__ */ new Set
1244
1555
  const result = await ctx.client.session.children({ path: { id: sessionId } });
1245
1556
  for (const child of result.data ?? []) {
1246
1557
  ctx.sessionTitleService.setSessionInfo(child);
1558
+ await ctx.sessionRegistry.upsertSession(
1559
+ registryEntryFromSession(child, ctx.serverUrl.href, ctx.sessionTitleService.getSessionStatus(child.id))
1560
+ );
1247
1561
  await hydrateDescendants(child.id, ctx, seen);
1248
1562
  }
1249
1563
  } catch (err) {
@@ -1313,6 +1627,7 @@ async function handleSessionIdle(event, ctx) {
1313
1627
  const sessionId = event.properties.sessionID;
1314
1628
  const parentID = await resolveParentID(sessionId, ctx);
1315
1629
  ctx.sessionTitleService.setSessionStatus(sessionId, "idle");
1630
+ await ctx.sessionRegistry.updateSession(sessionId, { status: "idle", updatedAt: Date.now() });
1316
1631
  if (typeof parentID === "string") {
1317
1632
  ctx.logger.info("suppressing child session idle notification", { sessionId, parentID });
1318
1633
  await flushDeferredParentIfReady(parentID, ctx);
@@ -1339,9 +1654,14 @@ async function handleSessionIdle(event, ctx) {
1339
1654
  async function handleSessionStatus(event, ctx) {
1340
1655
  const sessionId = event.properties.sessionID;
1341
1656
  const statusType = event.properties.status.type;
1657
+ const previousStatus = ctx.sessionTitleService.getSessionStatus(sessionId);
1342
1658
  ctx.sessionTitleService.setSessionStatus(sessionId, statusType);
1343
1659
  if (statusType === "idle") {
1344
1660
  await handleSessionIdle(event, ctx);
1661
+ return;
1662
+ }
1663
+ if (previousStatus !== statusType) {
1664
+ await ctx.sessionRegistry.updateSession(sessionId, { status: statusType, updatedAt: Date.now() });
1345
1665
  }
1346
1666
  }
1347
1667
 
@@ -1349,6 +1669,9 @@ async function handleSessionStatus(event, ctx) {
1349
1669
  async function handleSessionUpdated(event, ctx) {
1350
1670
  const info = event.properties.info;
1351
1671
  ctx.sessionTitleService.setSessionInfo(info);
1672
+ await ctx.sessionRegistry.upsertSession(
1673
+ registryEntryFromSession(info, ctx.serverUrl.href, ctx.sessionTitleService.getSessionStatus(info.id))
1674
+ );
1352
1675
  }
1353
1676
 
1354
1677
  // src/lib/html-escape.ts
@@ -1370,11 +1693,110 @@ function stripCodeFences(input) {
1370
1693
  var MAX_BODY_CHARS = 3900;
1371
1694
  var MAX_TITLE_CHARS = 55;
1372
1695
  var MAX_SESSIONS = 20;
1696
+ function agentFromSession2(session) {
1697
+ const candidate = session;
1698
+ return typeof candidate.agent === "string" ? candidate.agent : void 0;
1699
+ }
1700
+ function isRootSession(session) {
1701
+ return session.parentID === void 0 || session.parentID === null;
1702
+ }
1703
+ function isRootRegistryEntry(entry) {
1704
+ return entry.parentID === null;
1705
+ }
1706
+ function isRootRemoteSession(session) {
1707
+ return session.parentID === void 0 || session.parentID === null;
1708
+ }
1709
+ function addRegistryRecord(combined, entry, status) {
1710
+ combined.set(entry.sessionId, {
1711
+ sessionId: entry.sessionId,
1712
+ title: entry.title,
1713
+ agent: entry.agent,
1714
+ status: status ?? entry.status ?? "idle",
1715
+ serverUrl: entry.serverUrl,
1716
+ updatedAt: entry.updatedAt
1717
+ });
1718
+ }
1719
+ async function addRemoteServerRecords(combined, serverUrl, deps) {
1720
+ const [remoteSessions, remoteStatusMap] = await Promise.all([
1721
+ getRemoteSessions(serverUrl, deps.opencodeFetch),
1722
+ getRemoteStatusMap(serverUrl, deps.opencodeFetch)
1723
+ ]);
1724
+ for (const session of remoteSessions.filter(isRootRemoteSession)) {
1725
+ const status = remoteStatusMap[session.id]?.type ?? "idle";
1726
+ combined.set(session.id, {
1727
+ sessionId: session.id,
1728
+ title: session.title,
1729
+ agent: session.agent,
1730
+ status,
1731
+ serverUrl,
1732
+ updatedAt: session.time.updated
1733
+ });
1734
+ await deps.sessionRegistry.upsertSession({
1735
+ sessionId: session.id,
1736
+ title: session.title,
1737
+ parentID: session.parentID ?? null,
1738
+ agent: session.agent,
1739
+ status,
1740
+ serverUrl,
1741
+ updatedAt: session.time.updated
1742
+ });
1743
+ }
1744
+ }
1373
1745
  function createSessionsDispatcher(deps) {
1374
1746
  return async ({ chatId, bot }) => {
1375
- const sessions = deps.sessionTitleService.getRootSessionsByRecency(MAX_SESSIONS);
1747
+ let sessions;
1748
+ try {
1749
+ const [listResult, statusResult] = await Promise.all([
1750
+ deps.client.session.list(),
1751
+ deps.client.session.status()
1752
+ ]);
1753
+ const statusMap = statusResult.data ?? {};
1754
+ for (const session of listResult.data ?? []) {
1755
+ deps.sessionTitleService.setSessionInfo(session);
1756
+ deps.sessionTitleService.setServerUrl(session.id, deps.serverUrl);
1757
+ const status = statusMap[session.id]?.type ?? "idle";
1758
+ await deps.sessionRegistry.upsertSession(
1759
+ registryEntryFromSession(session, deps.serverUrl, status)
1760
+ );
1761
+ if (status !== void 0) deps.sessionTitleService.setSessionStatus(session.id, status);
1762
+ }
1763
+ const registrySessions = await deps.sessionRegistry.listSessions();
1764
+ const combined = /* @__PURE__ */ new Map();
1765
+ const remoteServerUrls = /* @__PURE__ */ new Set();
1766
+ for (const entry of registrySessions.filter(isRootRegistryEntry)) {
1767
+ const serverUrl = normalizeOpenCodeServerUrl(entry.serverUrl);
1768
+ if (!serverUrl) continue;
1769
+ if (isDifferentServerUrl(serverUrl, deps.serverUrl)) {
1770
+ remoteServerUrls.add(serverUrl);
1771
+ continue;
1772
+ }
1773
+ addRegistryRecord(combined, { ...entry, serverUrl }, statusMap[entry.sessionId]?.type);
1774
+ }
1775
+ for (const serverUrl of remoteServerUrls) {
1776
+ try {
1777
+ await addRemoteServerRecords(combined, serverUrl, deps);
1778
+ } catch (err) {
1779
+ deps.logger.error("sessions remote server refresh failed", { serverUrl, error: String(err) });
1780
+ }
1781
+ }
1782
+ for (const session of (listResult.data ?? []).filter(isRootSession)) {
1783
+ combined.set(session.id, {
1784
+ sessionId: session.id,
1785
+ title: session.title,
1786
+ agent: agentFromSession2(session),
1787
+ status: statusMap[session.id]?.type ?? "idle",
1788
+ serverUrl: deps.serverUrl,
1789
+ updatedAt: session.time.updated
1790
+ });
1791
+ }
1792
+ sessions = [...combined.values()].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_SESSIONS);
1793
+ } catch (err) {
1794
+ await bot.sendMessage("\uC138\uC158 \uBAA9\uB85D\uC744 \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.", { parse_mode: "HTML" });
1795
+ deps.logger.error("sessions list failed", { chatId, error: String(err) });
1796
+ return;
1797
+ }
1376
1798
  if (sessions.length === 0) {
1377
- await bot.sendMessage("\uD65C\uC131 \uC138\uC158\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.", { parse_mode: "HTML" });
1799
+ await bot.sendMessage("\uC138\uC158\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.", { parse_mode: "HTML" });
1378
1800
  return;
1379
1801
  }
1380
1802
  const capturedAt = Date.now();
@@ -1382,11 +1804,11 @@ function createSessionsDispatcher(deps) {
1382
1804
  const entry = {
1383
1805
  index: i + 1,
1384
1806
  sessionId: session.sessionId,
1385
- title: session.title ?? "",
1807
+ title: session.title,
1386
1808
  capturedAt
1387
1809
  };
1388
1810
  if (session.agent !== void 0) entry.agent = session.agent;
1389
- if (session.status !== void 0) entry.status = session.status;
1811
+ entry.status = session.status;
1390
1812
  if (session.serverUrl !== void 0) entry.serverUrl = session.serverUrl;
1391
1813
  return entry;
1392
1814
  });
@@ -1394,14 +1816,14 @@ function createSessionsDispatcher(deps) {
1394
1816
  const lines = entries.map((entry) => {
1395
1817
  const agent = entry.agent ? escapeHtml(entry.agent) : "?";
1396
1818
  const title = truncateForTelegram(escapeHtml(entry.title), MAX_TITLE_CHARS);
1397
- const status = entry.status ?? "unknown";
1398
- return `${entry.index}. [${agent}] ${title} \u2014 ${status}`;
1819
+ const status = ` \u2014 ${escapeHtml(entry.status ?? "idle")}`;
1820
+ return `${entry.index}. [${agent}] ${title}${status}`;
1399
1821
  });
1400
1822
  let body = lines.join("\n");
1401
1823
  if (body.length > MAX_BODY_CHARS) {
1402
1824
  body = body.slice(0, MAX_BODY_CHARS) + "\u2026";
1403
1825
  }
1404
- const text = `<b>\uD65C\uC131 \uC138\uC158 (top ${entries.length})</b>
1826
+ const text = `<b>\uCD5C\uADFC \uC138\uC158 (top ${entries.length})</b>
1405
1827
  ${body}
1406
1828
 
1407
1829
  <i>/status N \uB610\uB294 /start_work N \uC73C\uB85C \uC870\uC791</i>`;
@@ -1411,13 +1833,13 @@ ${body}
1411
1833
  }
1412
1834
 
1413
1835
  // src/lib/plan-readiness.ts
1414
- import { access, readFile as readFile4, readdir as readdir5, stat as stat2 } from "fs/promises";
1415
- import { join as join5 } from "path";
1836
+ import { access, readFile as readFile5, readdir as readdir6, stat as stat2 } from "fs/promises";
1837
+ import { join as join6 } from "path";
1416
1838
  async function checkPlanReadiness(args) {
1417
1839
  const { projectRoot } = args;
1418
- const omoDir = join5(projectRoot, ".omo");
1419
- const plansDir = join5(omoDir, "plans");
1420
- const boulderPath = join5(omoDir, "boulder.json");
1840
+ const omoDir = join6(projectRoot, ".omo");
1841
+ const plansDir = join6(omoDir, "plans");
1842
+ const boulderPath = join6(omoDir, "boulder.json");
1421
1843
  try {
1422
1844
  await access(omoDir);
1423
1845
  } catch {
@@ -1438,7 +1860,7 @@ async function checkPlanReadiness(args) {
1438
1860
  }
1439
1861
  let planFiles = [];
1440
1862
  try {
1441
- const entries = await readdir5(plansDir);
1863
+ const entries = await readdir6(plansDir);
1442
1864
  planFiles = entries.filter((e) => e.endsWith(".md"));
1443
1865
  } catch {
1444
1866
  return {
@@ -1456,14 +1878,14 @@ async function checkPlanReadiness(args) {
1456
1878
  }
1457
1879
  const stats = await Promise.all(
1458
1880
  planFiles.map(async (f) => {
1459
- const full = join5(plansDir, f);
1881
+ const full = join6(plansDir, f);
1460
1882
  const s = await stat2(full);
1461
1883
  return { path: full, name: f, mtime: s.mtime.getTime() };
1462
1884
  })
1463
1885
  );
1464
1886
  stats.sort((a, b) => b.mtime - a.mtime);
1465
1887
  const latest = stats[0];
1466
- const content = await readFile4(latest.path, "utf8");
1888
+ const content = await readFile5(latest.path, "utf8");
1467
1889
  const totalMatches = content.match(/^- \[[ xX]\]/gm) ?? [];
1468
1890
  const completedMatches = content.match(/^- \[[xX]\]/gm) ?? [];
1469
1891
  const total = totalMatches.length;
@@ -1494,7 +1916,7 @@ async function recheckSessionIdle(client, sessionId) {
1494
1916
  const result = await client.session.status();
1495
1917
  const statuses = result.data ?? {};
1496
1918
  const sessionStatus = statuses[sessionId];
1497
- return sessionStatus?.type === "idle";
1919
+ return (sessionStatus?.type ?? "idle") === "idle";
1498
1920
  }
1499
1921
 
1500
1922
  // src/events/status-command.ts
@@ -1593,25 +2015,65 @@ function createStatusDispatcher(deps) {
1593
2015
  });
1594
2016
  return;
1595
2017
  }
1596
- const [getResult, statusResult, messagesResult] = await Promise.all([
1597
- deps.client.session.get({ path: { id: entry.sessionId } }),
1598
- deps.client.session.status(),
1599
- deps.client.session.messages({
1600
- path: { id: entry.sessionId },
1601
- query: { limit: MESSAGES_LIMIT }
1602
- })
1603
- ]);
1604
- const session = getResult.data;
1605
- const responseStatus = getResult.response?.status;
2018
+ const rawSourceServerUrl = entry.serverUrl ?? deps.sessionTitleService.getServerUrl(entry.sessionId);
2019
+ const sourceServerUrl = normalizeOpenCodeServerUrl(rawSourceServerUrl);
2020
+ if (rawSourceServerUrl && !sourceServerUrl) {
2021
+ await bot.sendMessage("\uC138\uC158 \uC11C\uBC84 \uC815\uBCF4\uAC00 \uC720\uD6A8\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
2022
+ parse_mode: "HTML"
2023
+ });
2024
+ deps.logger.error("status invalid server url", { chatId, sessionId: entry.sessionId });
2025
+ return;
2026
+ }
2027
+ const useRemoteServer = isDifferentServerUrl(sourceServerUrl, deps.serverUrl);
2028
+ let session;
2029
+ let responseStatus;
2030
+ let sessionStatus = "idle";
2031
+ let messages = [];
2032
+ if (sourceServerUrl && useRemoteServer) {
2033
+ try {
2034
+ const getResult = await getRemoteSession(sourceServerUrl, entry.sessionId, deps.opencodeFetch);
2035
+ session = getResult.data;
2036
+ responseStatus = getResult.response.status;
2037
+ if (!session || responseStatus === 404) {
2038
+ await bot.sendMessage("\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
2039
+ parse_mode: "HTML"
2040
+ });
2041
+ return;
2042
+ }
2043
+ const [statusMap, remoteMessages] = await Promise.all([
2044
+ getRemoteStatusMap(sourceServerUrl, deps.opencodeFetch),
2045
+ getRemoteMessages(sourceServerUrl, entry.sessionId, MESSAGES_LIMIT, deps.opencodeFetch)
2046
+ ]);
2047
+ sessionStatus = statusMap[entry.sessionId]?.type ?? "idle";
2048
+ messages = remoteMessages;
2049
+ } catch (err) {
2050
+ await bot.sendMessage("\uC138\uC158 \uC0C1\uD0DC\uB97C \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
2051
+ parse_mode: "HTML"
2052
+ });
2053
+ deps.logger.error("status remote lookup failed", { chatId, sessionId: entry.sessionId, error: String(err) });
2054
+ return;
2055
+ }
2056
+ } else {
2057
+ const [getResult, statusResult, messagesResult] = await Promise.all([
2058
+ deps.client.session.get({ path: { id: entry.sessionId } }),
2059
+ deps.client.session.status(),
2060
+ deps.client.session.messages({
2061
+ path: { id: entry.sessionId },
2062
+ query: { limit: MESSAGES_LIMIT }
2063
+ })
2064
+ ]);
2065
+ session = normalizeSession(getResult.data);
2066
+ responseStatus = getResult.response?.status;
2067
+ const statusMap = normalizeStatusMap(statusResult.data);
2068
+ sessionStatus = statusMap[entry.sessionId]?.type ?? "idle";
2069
+ messages = normalizeMessages(messagesResult.data);
2070
+ }
1606
2071
  if (!session || responseStatus === 404) {
1607
2072
  await bot.sendMessage("\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
1608
2073
  parse_mode: "HTML"
1609
2074
  });
1610
2075
  return;
1611
2076
  }
1612
- const statusMap = statusResult.data ?? {};
1613
- const sessionStatus = statusMap[entry.sessionId]?.type ?? "unknown";
1614
- const messages = messagesResult.data ?? [];
1615
2077
  const projectRoot = resolveProjectRoot(session);
1616
2078
  const planReady = await checkPlanReadiness({ projectRoot });
1617
2079
  const userSnippet = buildSnippet(findLastByRole(messages, "user"));
@@ -1641,9 +2103,8 @@ function createStatusDispatcher(deps) {
1641
2103
  }
1642
2104
 
1643
2105
  // src/events/start-work-command.ts
1644
- function agentFromSession(session) {
1645
- const candidate = session;
1646
- return typeof candidate.agent === "string" ? candidate.agent : void 0;
2106
+ function agentFromSession3(session) {
2107
+ return session.agent;
1647
2108
  }
1648
2109
  function resolveProjectRoot2(session) {
1649
2110
  return session.directory;
@@ -1695,24 +2156,41 @@ function createStartWorkCommandDispatcher(deps) {
1695
2156
  return;
1696
2157
  }
1697
2158
  const sessionId = entry.sessionId;
2159
+ const rawSourceServerUrl = entry.serverUrl ?? deps.sessionTitleService.getServerUrl(sessionId);
2160
+ const sourceServerUrl = normalizeOpenCodeServerUrl(rawSourceServerUrl);
2161
+ if (rawSourceServerUrl && !sourceServerUrl) {
2162
+ await sendPlain(bot, "\uC138\uC158 \uC11C\uBC84 \uC815\uBCF4\uAC00 \uC720\uD6A8\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694");
2163
+ deps.logger.error("start-work invalid server url", { sessionId });
2164
+ return;
2165
+ }
2166
+ const useRemoteServer = isDifferentServerUrl(sourceServerUrl, deps.serverUrl);
1698
2167
  let session;
1699
2168
  try {
1700
- const result = await deps.client.session.get({ path: { id: sessionId } });
1701
- if (!result.data) {
1702
- await sendPlain(bot, "\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
1703
- return;
2169
+ if (sourceServerUrl && useRemoteServer) {
2170
+ const result = await getRemoteSession(sourceServerUrl, sessionId, deps.opencodeFetch);
2171
+ if (!result.data || result.response.status === 404) {
2172
+ await sendPlain(bot, "\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
2173
+ return;
2174
+ }
2175
+ session = result.data;
2176
+ } else {
2177
+ const result = await deps.client.session.get({ path: { id: sessionId } });
2178
+ if (!result.data) {
2179
+ await sendPlain(bot, "\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
2180
+ return;
2181
+ }
2182
+ session = result.data;
1704
2183
  }
1705
- session = result.data;
1706
2184
  } catch (err) {
1707
2185
  if (err instanceof Error && isSessionNotFoundError(err)) {
1708
2186
  await sendPlain(bot, "\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
1709
2187
  return;
1710
2188
  }
1711
- await sendPlain(bot, `\uC138\uC158 \uD655\uC778 \uC2E4\uD328: ${String(err)}`);
2189
+ await sendPlain(bot, "\uC138\uC158 \uD655\uC778 \uC2E4\uD328. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694");
1712
2190
  deps.logger.error("start-work session lookup failed", { sessionId, error: String(err) });
1713
2191
  return;
1714
2192
  }
1715
- const agent = deps.sessionTitleService.getSessionAgent(sessionId) ?? agentFromSession(session);
2193
+ const agent = deps.sessionTitleService.getSessionAgent(sessionId) ?? agentFromSession3(session) ?? entry.agent;
1716
2194
  if (agent !== "plan") {
1717
2195
  await sendPlain(
1718
2196
  bot,
@@ -1720,7 +2198,14 @@ function createStartWorkCommandDispatcher(deps) {
1720
2198
  );
1721
2199
  return;
1722
2200
  }
1723
- const idle = await recheckSessionIdle(deps.client, sessionId);
2201
+ let idle;
2202
+ try {
2203
+ idle = sourceServerUrl && useRemoteServer ? ((await getRemoteStatusMap(sourceServerUrl, deps.opencodeFetch))[sessionId]?.type ?? "idle") === "idle" : await recheckSessionIdle(deps.client, sessionId);
2204
+ } catch (err) {
2205
+ await sendPlain(bot, "\uC138\uC158 \uC0C1\uD0DC \uD655\uC778 \uC2E4\uD328. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694");
2206
+ deps.logger.error("start-work idle recheck failed", { sessionId, error: String(err) });
2207
+ return;
2208
+ }
1724
2209
  if (!idle) {
1725
2210
  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
2211
  return;
@@ -1731,15 +2216,14 @@ function createStartWorkCommandDispatcher(deps) {
1731
2216
  return;
1732
2217
  }
1733
2218
  try {
1734
- const serverUrl = deps.sessionTitleService.getServerUrl(sessionId);
1735
- await deps.runSessionCommand(sessionId, "start-work", serverUrl);
2219
+ await deps.runSessionCommand(sessionId, "start-work", sourceServerUrl);
1736
2220
  await sendHtml(
1737
2221
  bot,
1738
2222
  `${index}\uBC88 \uC138\uC158\uC5D0 opencode /start-work \uC2AC\uB798\uC2DC \uCEE4\uB9E8\uB4DC \uC804\uC1A1 \uC644\uB8CC. (${escapeHtml(entry.title)})`
1739
2223
  );
1740
2224
  deps.logger.info("start-work dispatched", { chatId, sessionId, index });
1741
2225
  } catch (err) {
1742
- await sendHtml(bot, "opencode /start-work \uC804\uC1A1 \uC2E4\uD328: " + String(err));
2226
+ await sendHtml(bot, "opencode /start-work \uC804\uC1A1 \uC2E4\uD328");
1743
2227
  deps.logger.error("start-work dispatch failed", { sessionId, error: String(err) });
1744
2228
  }
1745
2229
  };
@@ -1776,14 +2260,14 @@ function createHelpDispatcher(deps) {
1776
2260
  // src/lib/env-loader.ts
1777
2261
  import { existsSync } from "fs";
1778
2262
  import { homedir } from "os";
1779
- import { join as join6 } from "path";
2263
+ import { join as join7 } from "path";
1780
2264
  import dotenv from "dotenv";
1781
2265
  function loadPluginEnv(opts) {
1782
2266
  const paths = [
1783
- join6(opts.pluginDir, "../../.env"),
1784
- join6(opts.pluginDir, "..", ".env"),
1785
- join6(opts.pluginDir, ".env"),
1786
- join6(opts.homeDir ?? homedir(), ".config/opencode/telegram-remote/.env")
2267
+ join7(opts.pluginDir, "../../.env"),
2268
+ join7(opts.pluginDir, "..", ".env"),
2269
+ join7(opts.pluginDir, ".env"),
2270
+ join7(opts.homeDir ?? homedir(), ".config/opencode/telegram-remote/.env")
1787
2271
  ];
1788
2272
  const loadedFrom = [];
1789
2273
  const values = {};
@@ -1801,10 +2285,10 @@ function loadPluginEnv(opts) {
1801
2285
  }
1802
2286
 
1803
2287
  // src/lib/lock.ts
1804
- import { open as open2, readFile as readFile5, stat as stat3, unlink as unlink5 } from "fs/promises";
2288
+ import { open as open2, readFile as readFile6, stat as stat3, unlink as unlink6 } from "fs/promises";
1805
2289
  import { hostname } from "os";
1806
2290
  var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
1807
- function hasCode5(err, code) {
2291
+ function hasCode6(err, code) {
1808
2292
  return "code" in err && err.code === code;
1809
2293
  }
1810
2294
  function parseLockData(text) {
@@ -1823,7 +2307,7 @@ function isPidAlive(pid) {
1823
2307
  process.kill(pid, 0);
1824
2308
  return true;
1825
2309
  } catch (err) {
1826
- if (err instanceof Error && hasCode5(err, "ESRCH")) return false;
2310
+ if (err instanceof Error && hasCode6(err, "ESRCH")) return false;
1827
2311
  return true;
1828
2312
  }
1829
2313
  }
@@ -1844,7 +2328,7 @@ async function createLock(lockPath, pid) {
1844
2328
  if (released) return;
1845
2329
  released = true;
1846
2330
  try {
1847
- await unlink5(lockPath);
2331
+ await unlink6(lockPath);
1848
2332
  } catch {
1849
2333
  }
1850
2334
  }
@@ -1854,7 +2338,7 @@ async function inspectExisting(lockPath, ttlMs) {
1854
2338
  let ownerPid;
1855
2339
  let dead = false;
1856
2340
  try {
1857
- const text = await readFile5(lockPath, "utf8");
2341
+ const text = await readFile6(lockPath, "utf8");
1858
2342
  const data = parseLockData(text);
1859
2343
  if (data) {
1860
2344
  ownerPid = data.pid;
@@ -1880,7 +2364,7 @@ async function acquireLock(opts) {
1880
2364
  try {
1881
2365
  return { acquired: true, handle: await createLock(opts.lockPath, pid) };
1882
2366
  } catch (err) {
1883
- if (!(err instanceof Error) || !hasCode5(err, "EEXIST")) {
2367
+ if (!(err instanceof Error) || !hasCode6(err, "EEXIST")) {
1884
2368
  return { acquired: false, reason: err instanceof Error ? err.message : String(err) };
1885
2369
  }
1886
2370
  const existing = await inspectExisting(opts.lockPath, ttlMs);
@@ -1888,7 +2372,7 @@ async function acquireLock(opts) {
1888
2372
  return { acquired: false, reason: existing.reason, ownerPid: existing.ownerPid };
1889
2373
  }
1890
2374
  try {
1891
- await unlink5(opts.lockPath);
2375
+ await unlink6(opts.lockPath);
1892
2376
  } catch {
1893
2377
  return { acquired: false, reason: "failed to remove stale lock", ownerPid: existing.ownerPid };
1894
2378
  }
@@ -1968,10 +2452,10 @@ function createLogger(opts = {}) {
1968
2452
  }
1969
2453
 
1970
2454
  // src/lib/session-snapshot.ts
1971
- import { chmod, mkdir as mkdir5, readFile as readFile6, rename as rename4, unlink as unlink6, writeFile as writeFile4 } from "fs/promises";
1972
- import { dirname as dirname4, join as join7 } from "path";
2455
+ import { chmod as chmod2, mkdir as mkdir6, readFile as readFile7, rename as rename5, unlink as unlink7, writeFile as writeFile5 } from "fs/promises";
2456
+ import { dirname as dirname4, join as join8 } from "path";
1973
2457
  var TTL_MS = 60 * 60 * 1e3;
1974
- function hasCode6(err, code) {
2458
+ function hasCode7(err, code) {
1975
2459
  return err instanceof Error && "code" in err && err.code === code;
1976
2460
  }
1977
2461
  function isSnapshotFile(value) {
@@ -1990,11 +2474,14 @@ function isSnapshotFile(value) {
1990
2474
  if (typeof e.capturedAt !== "number") return false;
1991
2475
  if (e.agent !== void 0 && typeof e.agent !== "string") return false;
1992
2476
  if (e.status !== void 0 && typeof e.status !== "string") return false;
1993
- if (e.serverUrl !== void 0 && typeof e.serverUrl !== "string") return false;
2477
+ if (e.serverUrl !== void 0) {
2478
+ if (typeof e.serverUrl !== "string") return false;
2479
+ if (!normalizeOpenCodeServerUrl(e.serverUrl)) return false;
2480
+ }
1994
2481
  }
1995
2482
  return true;
1996
2483
  }
1997
- function normalizeEntry(entry) {
2484
+ function normalizeEntry2(entry) {
1998
2485
  const out = {
1999
2486
  index: entry.index,
2000
2487
  sessionId: entry.sessionId,
@@ -2003,22 +2490,25 @@ function normalizeEntry(entry) {
2003
2490
  };
2004
2491
  if (entry.agent !== void 0) out.agent = entry.agent;
2005
2492
  if (entry.status !== void 0) out.status = entry.status;
2006
- if (entry.serverUrl !== void 0) out.serverUrl = entry.serverUrl;
2493
+ if (entry.serverUrl !== void 0) {
2494
+ const serverUrl = normalizeOpenCodeServerUrl(entry.serverUrl);
2495
+ if (serverUrl !== void 0) out.serverUrl = serverUrl;
2496
+ }
2007
2497
  return out;
2008
2498
  }
2009
2499
  function createSnapshotStore(opts) {
2010
2500
  const { configDir, tokenHash, logger } = opts;
2011
- const snapshotsDir = join7(configDir, "snapshots");
2501
+ const snapshotsDir = join8(configDir, "snapshots");
2012
2502
  const writeLocks = /* @__PURE__ */ new Map();
2013
2503
  function snapshotFilePath(chatId) {
2014
- return join7(snapshotsDir, `${tokenHash}-${chatId}.json`);
2504
+ return join8(snapshotsDir, `${tokenHash}-${chatId}.json`);
2015
2505
  }
2016
2506
  async function performSave(chatId, entries) {
2017
2507
  const filePath = snapshotFilePath(chatId);
2018
2508
  const parent = dirname4(filePath);
2019
- await mkdir5(parent, { recursive: true });
2509
+ await mkdir6(parent, { recursive: true });
2020
2510
  try {
2021
- await chmod(parent, 448);
2511
+ await chmod2(parent, 448);
2022
2512
  } catch (err) {
2023
2513
  logger.error("snapshot: failed to chmod parent dir", {
2024
2514
  path: parent,
@@ -2029,20 +2519,20 @@ function createSnapshotStore(opts) {
2029
2519
  version: 1,
2030
2520
  chatId,
2031
2521
  createdAt: Date.now(),
2032
- entries: entries.map(normalizeEntry)
2522
+ entries: entries.map(normalizeEntry2)
2033
2523
  };
2034
2524
  const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
2035
- await writeFile4(tmpPath, JSON.stringify(payload, null, 2), "utf8");
2525
+ await writeFile5(tmpPath, JSON.stringify(payload, null, 2), "utf8");
2036
2526
  try {
2037
- await rename4(tmpPath, filePath);
2527
+ await rename5(tmpPath, filePath);
2038
2528
  } catch (err) {
2039
2529
  try {
2040
- await unlink6(tmpPath);
2530
+ await unlink7(tmpPath);
2041
2531
  } catch {
2042
2532
  }
2043
2533
  throw err;
2044
2534
  }
2045
- await chmod(filePath, 384);
2535
+ await chmod2(filePath, 384);
2046
2536
  }
2047
2537
  async function saveSnapshot(chatId, entries) {
2048
2538
  const prev = writeLocks.get(chatId) ?? Promise.resolve();
@@ -2061,9 +2551,9 @@ function createSnapshotStore(opts) {
2061
2551
  const filePath = snapshotFilePath(chatId);
2062
2552
  let text;
2063
2553
  try {
2064
- text = await readFile6(filePath, "utf8");
2554
+ text = await readFile7(filePath, "utf8");
2065
2555
  } catch (err) {
2066
- if (hasCode6(err, "ENOENT")) return null;
2556
+ if (hasCode7(err, "ENOENT")) return null;
2067
2557
  logger.error("snapshot: failed to read file", {
2068
2558
  path: filePath,
2069
2559
  error: err instanceof Error ? err.message : String(err)
@@ -2086,9 +2576,9 @@ function createSnapshotStore(opts) {
2086
2576
  }
2087
2577
  if (parsed.createdAt + TTL_MS < Date.now()) {
2088
2578
  try {
2089
- await unlink6(filePath);
2579
+ await unlink7(filePath);
2090
2580
  } catch (err) {
2091
- if (!hasCode6(err, "ENOENT")) {
2581
+ if (!hasCode7(err, "ENOENT")) {
2092
2582
  logger.error("snapshot: failed to unlink expired file", {
2093
2583
  path: filePath,
2094
2584
  error: err instanceof Error ? err.message : String(err)
@@ -2097,16 +2587,16 @@ function createSnapshotStore(opts) {
2097
2587
  }
2098
2588
  return null;
2099
2589
  }
2100
- return parsed.entries.map(normalizeEntry);
2590
+ return parsed.entries.map(normalizeEntry2);
2101
2591
  }
2102
2592
  return { saveSnapshot, loadSnapshot, snapshotFilePath };
2103
2593
  }
2104
2594
 
2105
2595
  // src/lib/state-store.ts
2106
- import { mkdir as mkdir6, readFile as readFile7, rename as rename5, writeFile as writeFile5 } from "fs/promises";
2596
+ import { mkdir as mkdir7, readFile as readFile8, rename as rename6, writeFile as writeFile6 } from "fs/promises";
2107
2597
  import { homedir as homedir2 } from "os";
2108
- import { dirname as dirname5, join as join8 } from "path";
2109
- function hasCode7(err, code) {
2598
+ import { dirname as dirname5, join as join9 } from "path";
2599
+ function hasCode8(err, code) {
2110
2600
  return "code" in err && err.code === code;
2111
2601
  }
2112
2602
  function parseState(text) {
@@ -2118,28 +2608,28 @@ function parseState(text) {
2118
2608
  return state;
2119
2609
  }
2120
2610
  function createStateStore(opts = {}) {
2121
- const filePath = opts.filePath ?? join8(homedir2(), ".config/opencode/telegram-remote/state.json");
2611
+ const filePath = opts.filePath ?? join9(homedir2(), ".config/opencode/telegram-remote/state.json");
2122
2612
  return {
2123
2613
  async read() {
2124
2614
  try {
2125
- return parseState(await readFile7(filePath, "utf8"));
2615
+ return parseState(await readFile8(filePath, "utf8"));
2126
2616
  } catch (err) {
2127
- if (err instanceof Error && hasCode7(err, "ENOENT")) return {};
2617
+ if (err instanceof Error && hasCode8(err, "ENOENT")) return {};
2128
2618
  throw err;
2129
2619
  }
2130
2620
  },
2131
2621
  async write(patch) {
2132
2622
  const existing = await this.read();
2133
2623
  const next = { ...existing, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
2134
- await mkdir6(dirname5(filePath), { recursive: true });
2624
+ await mkdir7(dirname5(filePath), { recursive: true });
2135
2625
  const tmpPath = `${filePath}.tmp.${process.pid}`;
2136
- await writeFile5(tmpPath, JSON.stringify(next, null, 2), "utf8");
2626
+ await writeFile6(tmpPath, JSON.stringify(next, null, 2), "utf8");
2137
2627
  try {
2138
- await rename5(tmpPath, filePath);
2628
+ await rename6(tmpPath, filePath);
2139
2629
  } catch (err) {
2140
- if (!(err instanceof Error) || !hasCode7(err, "ENOENT")) throw err;
2141
- await writeFile5(tmpPath, JSON.stringify(next, null, 2), "utf8");
2142
- await rename5(tmpPath, filePath);
2630
+ if (!(err instanceof Error) || !hasCode8(err, "ENOENT")) throw err;
2631
+ await writeFile6(tmpPath, JSON.stringify(next, null, 2), "utf8");
2632
+ await rename6(tmpPath, filePath);
2143
2633
  }
2144
2634
  return next;
2145
2635
  }
@@ -2147,7 +2637,7 @@ function createStateStore(opts = {}) {
2147
2637
  }
2148
2638
 
2149
2639
  // src/services/session-title-service.ts
2150
- function agentFromSession2(info) {
2640
+ function agentFromSession4(info) {
2151
2641
  const candidate = info;
2152
2642
  return typeof candidate.agent === "string" ? candidate.agent : void 0;
2153
2643
  }
@@ -2158,7 +2648,7 @@ var SessionTitleService = class {
2158
2648
  this.sessions.set(info.id, {
2159
2649
  title: info.title || null,
2160
2650
  parentID: info.parentID ?? null,
2161
- agent: agentFromSession2(info) ?? existing?.agent,
2651
+ agent: agentFromSession4(info) ?? existing?.agent,
2162
2652
  status: existing?.status,
2163
2653
  idleNotificationPending: existing?.idleNotificationPending ?? false,
2164
2654
  lastSeenAt: Date.now(),
@@ -2286,17 +2776,17 @@ var SessionTitleService = class {
2286
2776
  // src/telegram-remote.ts
2287
2777
  var pluginDir = dirname6(fileURLToPath(import.meta.url));
2288
2778
  async function postToServer(serverUrl, path, body) {
2289
- const url = new URL(path, serverUrl);
2779
+ const safeServerUrl = normalizeOpenCodeServerUrl(serverUrl);
2780
+ if (!safeServerUrl) throw new Error("Invalid OpenCode server URL");
2781
+ const url = new URL(path, safeServerUrl);
2290
2782
  const response = await fetch(url, {
2291
2783
  method: "POST",
2292
2784
  headers: { "Content-Type": "application/json" },
2293
- body: JSON.stringify(body)
2785
+ body: JSON.stringify(body),
2786
+ redirect: "error"
2294
2787
  });
2295
2788
  if (response.ok) return;
2296
- const text = await response.text();
2297
- throw new Error(
2298
- `OpenCode request failed: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`
2299
- );
2789
+ throw new Error(`OpenCode request failed: ${response.status} ${response.statusText}`);
2300
2790
  }
2301
2791
  function getSessionAgentFromMessage(event) {
2302
2792
  const info = event.properties.info;
@@ -2320,10 +2810,11 @@ var TelegramRemote = async (input) => {
2320
2810
  const stateStore = createStateStore();
2321
2811
  const initialState = await stateStore.read();
2322
2812
  const tokenHash = createHash5("sha256").update(config.botToken).digest("hex").slice(0, 16);
2323
- const configDir = join9(homedir3(), ".config/opencode/telegram-remote");
2813
+ const configDir = join10(homedir3(), ".config/opencode/telegram-remote");
2324
2814
  const snapshotStore = createSnapshotStore({ configDir, tokenHash, logger });
2325
- const lockPath = join9(tmpdir5(), `opencoder-telegram-${tokenHash}.lock`);
2326
- const claimsDir = join9(tmpdir5(), `opencoder-telegram-claims-${tokenHash}`);
2815
+ const sessionRegistry = createSessionRegistryStore({ configDir, tokenHash, logger });
2816
+ const lockPath = join10(tmpdir5(), `opencoder-telegram-${tokenHash}.lock`);
2817
+ const claimsDir = join10(tmpdir5(), `opencoder-telegram-claims-${tokenHash}`);
2327
2818
  const pendingQuestions = createPendingQuestionStore({ tokenHash });
2328
2819
  const pendingPermissions = createPendingPermissionStore({ tokenHash });
2329
2820
  const pendingStartWorks = createPendingStartWorkStore({ tokenHash });
@@ -2353,8 +2844,8 @@ var TelegramRemote = async (input) => {
2353
2844
  throwOnError: true
2354
2845
  });
2355
2846
  };
2356
- const replyToPermission = async (requestID, sessionID, reply, endpoint, serverUrl = input.serverUrl.href) => {
2357
- if (endpoint === "request") {
2847
+ const replyToPermission = async (requestID, sessionID, reply, endpoint2, serverUrl = input.serverUrl.href) => {
2848
+ if (endpoint2 === "request") {
2358
2849
  const path2 = `/permission/${encodeURIComponent(requestID)}/reply`;
2359
2850
  if (serverUrl !== input.serverUrl.href) {
2360
2851
  await postToServer(serverUrl, path2, { reply });
@@ -2438,6 +2929,7 @@ var TelegramRemote = async (input) => {
2438
2929
  pendingQuestions,
2439
2930
  pendingPermissions,
2440
2931
  pendingStartWorks,
2932
+ sessionRegistry,
2441
2933
  replyToQuestion,
2442
2934
  replyToPermission,
2443
2935
  runSessionCommand
@@ -2446,26 +2938,30 @@ var TelegramRemote = async (input) => {
2446
2938
  bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
2447
2939
  bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
2448
2940
  bot.setStartWorkDispatcher(createStartWorkDispatcher(ctx));
2449
- bot.setSessionsDispatcher(createSessionsDispatcher({ sessionTitleService, snapshotStore, logger }));
2450
- bot.setStatusDispatcher(createStatusDispatcher({ snapshotStore, sessionTitleService, client: input.client, logger }));
2941
+ bot.setSessionsDispatcher(createSessionsDispatcher({
2942
+ client: input.client,
2943
+ sessionTitleService,
2944
+ sessionRegistry,
2945
+ snapshotStore,
2946
+ serverUrl: input.serverUrl.href,
2947
+ logger
2948
+ }));
2949
+ bot.setStatusDispatcher(createStatusDispatcher({
2950
+ snapshotStore,
2951
+ sessionTitleService,
2952
+ client: input.client,
2953
+ logger,
2954
+ serverUrl: input.serverUrl.href
2955
+ }));
2451
2956
  bot.setStartWorkCommandDispatcher(createStartWorkCommandDispatcher({
2452
2957
  snapshotStore,
2453
2958
  sessionTitleService,
2454
2959
  client: input.client,
2960
+ serverUrl: input.serverUrl.href,
2455
2961
  runSessionCommand,
2456
2962
  logger
2457
2963
  }));
2458
2964
  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
2965
  }
2470
2966
  return {
2471
2967
  event: async ({ event }) => {
@@ -2485,8 +2981,16 @@ var TelegramRemote = async (input) => {
2485
2981
  case "message.updated": {
2486
2982
  const messageAgent = getSessionAgentFromMessage(event);
2487
2983
  if (messageAgent) {
2984
+ const previousAgent = ctx.sessionTitleService.getSessionAgent(messageAgent.sessionID);
2488
2985
  ctx.sessionTitleService.setSessionAgent(messageAgent.sessionID, messageAgent.agent);
2489
2986
  ctx.sessionTitleService.setServerUrl(messageAgent.sessionID, input.serverUrl.href);
2987
+ if (previousAgent !== messageAgent.agent) {
2988
+ await ctx.sessionRegistry.updateSession(messageAgent.sessionID, {
2989
+ agent: messageAgent.agent,
2990
+ serverUrl: input.serverUrl.href,
2991
+ updatedAt: Date.now()
2992
+ });
2993
+ }
2490
2994
  }
2491
2995
  return;
2492
2996
  }
@@ -2495,7 +2999,16 @@ var TelegramRemote = async (input) => {
2495
2999
  default: {
2496
3000
  const stepAgent = getSessionAgentFromNextStep(extEvent);
2497
3001
  if (stepAgent) {
3002
+ const previousAgent = ctx.sessionTitleService.getSessionAgent(stepAgent.sessionID);
2498
3003
  ctx.sessionTitleService.setSessionAgent(stepAgent.sessionID, stepAgent.agent);
3004
+ ctx.sessionTitleService.setServerUrl(stepAgent.sessionID, input.serverUrl.href);
3005
+ if (previousAgent !== stepAgent.agent) {
3006
+ await ctx.sessionRegistry.updateSession(stepAgent.sessionID, {
3007
+ agent: stepAgent.agent,
3008
+ serverUrl: input.serverUrl.href,
3009
+ updatedAt: Date.now()
3010
+ });
3011
+ }
2499
3012
  return;
2500
3013
  }
2501
3014
  if (isEventPermissionAsked(extEvent)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coinseeker/opencode-telegram-plugin",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
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",