@coinseeker/opencode-telegram-plugin 1.0.12 → 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.
@@ -5,8 +5,8 @@
5
5
 
6
6
  // src/telegram-remote.ts
7
7
  import { createHash as createHash5 } from "crypto";
8
- import { tmpdir as tmpdir5 } from "os";
9
- import { dirname as dirname5, join as join7 } from "path";
8
+ import { homedir as homedir3, tmpdir as tmpdir5 } from "os";
9
+ import { dirname as dirname6, join as join10 } from "path";
10
10
  import { fileURLToPath } from "url";
11
11
 
12
12
  // src/bot.ts
@@ -46,6 +46,11 @@ function createTelegramBot(opts) {
46
46
  let questionDispatcher;
47
47
  let permissionDispatcher;
48
48
  let startWorkDispatcher;
49
+ let sessionsDispatcher;
50
+ let statusDispatcher;
51
+ let startWorkCommandDispatcher;
52
+ let helpDispatcher;
53
+ let managerObj;
49
54
  if (polling) {
50
55
  bot.use(async (ctx, next) => {
51
56
  const userId = ctx.from?.id;
@@ -105,6 +110,36 @@ This chat is now active for OpenCode notifications.`
105
110
  if (!startWorkDispatcher || messageId === void 0) return;
106
111
  await startWorkDispatcher.handleCallbackQuery(data, messageId);
107
112
  });
113
+ bot.command("sessions", async (ctx) => {
114
+ if (!sessionsDispatcher) return;
115
+ const chatId = ctx.chat?.id;
116
+ const userId = ctx.from?.id;
117
+ if (chatId === void 0 || userId === void 0) return;
118
+ await sessionsDispatcher({ chatId, userId, bot: managerObj });
119
+ });
120
+ bot.command("status", async (ctx) => {
121
+ if (!statusDispatcher) return;
122
+ const chatId = ctx.chat?.id;
123
+ const userId = ctx.from?.id;
124
+ if (chatId === void 0 || userId === void 0) return;
125
+ const args = ctx.match.trim().split(/\s+/).filter(Boolean);
126
+ await statusDispatcher({ chatId, userId, bot: managerObj, args });
127
+ });
128
+ bot.command("start_work", async (ctx) => {
129
+ if (!startWorkCommandDispatcher) return;
130
+ const chatId = ctx.chat?.id;
131
+ const userId = ctx.from?.id;
132
+ if (chatId === void 0 || userId === void 0) return;
133
+ const args = ctx.match.trim().split(/\s+/).filter(Boolean);
134
+ await startWorkCommandDispatcher({ chatId, userId, bot: managerObj, args });
135
+ });
136
+ bot.command("help", async (ctx) => {
137
+ if (!helpDispatcher) return;
138
+ const chatId = ctx.chat?.id;
139
+ const userId = ctx.from?.id;
140
+ if (chatId === void 0 || userId === void 0) return;
141
+ await helpDispatcher({ chatId, userId, bot: managerObj });
142
+ });
108
143
  bot.on("message:text", async (ctx) => {
109
144
  const replyToMessageId = ctx.message.reply_to_message?.message_id;
110
145
  const chatId = ctx.chat.id;
@@ -122,12 +157,22 @@ This chat is now active for OpenCode notifications.`
122
157
  }
123
158
  throw new Error(`No active chat for ${action}. Send any message to the bot first.`);
124
159
  };
125
- return {
160
+ managerObj = {
126
161
  async start() {
127
162
  if (!polling) {
128
163
  logger.info("pass-through mode - skipping bot.start()");
129
164
  return;
130
165
  }
166
+ try {
167
+ await bot.api.setMyCommands([
168
+ { command: "sessions", description: "\uD65C\uC131 \uC138\uC158 \uBAA9\uB85D (top 20)" },
169
+ { command: "status", description: "\uC138\uC158 \uC0C1\uD0DC \uC870\uD68C (/status N)" },
170
+ { command: "start_work", description: "plan-ready \uC138\uC158 \uC2E4\uD589 (/start_work N)" },
171
+ { command: "help", description: "\uBA85\uB839 \uB3C4\uC6C0\uB9D0" }
172
+ ]);
173
+ } catch (err) {
174
+ logger.warn("setMyCommands failed", { error: String(err) });
175
+ }
131
176
  await bot.start({
132
177
  drop_pending_updates: true,
133
178
  onStart: () => {
@@ -201,8 +246,21 @@ This chat is now active for OpenCode notifications.`
201
246
  },
202
247
  setStartWorkDispatcher(dispatcher) {
203
248
  startWorkDispatcher = dispatcher;
249
+ },
250
+ setSessionsDispatcher(dispatcher) {
251
+ sessionsDispatcher = dispatcher;
252
+ },
253
+ setStatusDispatcher(dispatcher) {
254
+ statusDispatcher = dispatcher;
255
+ },
256
+ setStartWorkCommandDispatcher(dispatcher) {
257
+ startWorkCommandDispatcher = dispatcher;
258
+ },
259
+ setHelpDispatcher(dispatcher) {
260
+ helpDispatcher = dispatcher;
204
261
  }
205
262
  };
263
+ return managerObj;
206
264
  }
207
265
 
208
266
  // src/config.ts
@@ -959,9 +1017,313 @@ async function handleQuestionReplied(event, ctx) {
959
1017
  }
960
1018
  }
961
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
+
962
1320
  // src/events/session-created.ts
963
1321
  async function handleSessionCreated(event, ctx) {
964
- 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
+ );
965
1327
  }
966
1328
 
967
1329
  // src/lib/abort-tracker.ts
@@ -1003,14 +1365,14 @@ async function handleSessionError(event, ctx) {
1003
1365
 
1004
1366
  // src/lib/pending-start-work.ts
1005
1367
  import { createHash as createHash4 } from "crypto";
1006
- 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";
1007
1369
  import { tmpdir as tmpdir3 } from "os";
1008
- import { dirname as dirname3, join as join4 } from "path";
1009
- function hasCode4(err, code) {
1370
+ import { dirname as dirname3, join as join5 } from "path";
1371
+ function hasCode5(err, code) {
1010
1372
  return "code" in err && err.code === code;
1011
1373
  }
1012
1374
  function pendingFilePath3(dir, shortHash) {
1013
- return join4(dir, `${shortHash}.json`);
1375
+ return join5(dir, `${shortHash}.json`);
1014
1376
  }
1015
1377
  function parsePending3(text) {
1016
1378
  const parsed = JSON.parse(text);
@@ -1029,10 +1391,10 @@ function parsePending3(text) {
1029
1391
  }
1030
1392
  async function listPendingFiles3(dir) {
1031
1393
  try {
1032
- const entries = await readdir4(dir, { withFileTypes: true });
1394
+ const entries = await readdir5(dir, { withFileTypes: true });
1033
1395
  return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => entry.name);
1034
1396
  } catch (err) {
1035
- if (err instanceof Error && hasCode4(err, "ENOENT")) return [];
1397
+ if (err instanceof Error && hasCode5(err, "ENOENT")) return [];
1036
1398
  throw err;
1037
1399
  }
1038
1400
  }
@@ -1040,29 +1402,29 @@ function shortHashFromFileName3(fileName) {
1040
1402
  return fileName.slice(0, -".json".length);
1041
1403
  }
1042
1404
  function createPendingStartWorkStore(opts) {
1043
- 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}`);
1044
1406
  return {
1045
1407
  dir,
1046
1408
  async savePending(shortHash, data) {
1047
1409
  const filePath = pendingFilePath3(dir, shortHash);
1048
- await mkdir4(dirname3(filePath), { recursive: true });
1410
+ await mkdir5(dirname3(filePath), { recursive: true });
1049
1411
  const tmpPath = `${filePath}.tmp.${process.pid}`;
1050
- await writeFile3(tmpPath, JSON.stringify(data, null, 2), "utf8");
1051
- await rename3(tmpPath, filePath);
1412
+ await writeFile4(tmpPath, JSON.stringify(data, null, 2), "utf8");
1413
+ await rename4(tmpPath, filePath);
1052
1414
  },
1053
1415
  async loadPending(shortHash) {
1054
1416
  try {
1055
- return parsePending3(await readFile3(pendingFilePath3(dir, shortHash), "utf8"));
1417
+ return parsePending3(await readFile4(pendingFilePath3(dir, shortHash), "utf8"));
1056
1418
  } catch (err) {
1057
- if (err instanceof Error && hasCode4(err, "ENOENT")) return void 0;
1419
+ if (err instanceof Error && hasCode5(err, "ENOENT")) return void 0;
1058
1420
  throw err;
1059
1421
  }
1060
1422
  },
1061
1423
  async deletePending(shortHash) {
1062
1424
  try {
1063
- await unlink4(pendingFilePath3(dir, shortHash));
1425
+ await unlink5(pendingFilePath3(dir, shortHash));
1064
1426
  } catch (err) {
1065
- if (!(err instanceof Error) || !hasCode4(err, "ENOENT")) throw err;
1427
+ if (!(err instanceof Error) || !hasCode5(err, "ENOENT")) throw err;
1066
1428
  }
1067
1429
  },
1068
1430
  async sweepExpired() {
@@ -1170,6 +1532,13 @@ async function resolveParentID(sessionId, ctx) {
1170
1532
  const result = await ctx.client.session.get({ path: { id: sessionId } });
1171
1533
  if (result.data) {
1172
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
+ );
1173
1542
  return ctx.sessionTitleService.getParentID(sessionId);
1174
1543
  }
1175
1544
  ctx.logger.warn("session parentID cache miss fetch returned no data", { sessionId });
@@ -1186,6 +1555,9 @@ async function hydrateDescendants(sessionId, ctx, seen = /* @__PURE__ */ new Set
1186
1555
  const result = await ctx.client.session.children({ path: { id: sessionId } });
1187
1556
  for (const child of result.data ?? []) {
1188
1557
  ctx.sessionTitleService.setSessionInfo(child);
1558
+ await ctx.sessionRegistry.upsertSession(
1559
+ registryEntryFromSession(child, ctx.serverUrl.href, ctx.sessionTitleService.getSessionStatus(child.id))
1560
+ );
1189
1561
  await hydrateDescendants(child.id, ctx, seen);
1190
1562
  }
1191
1563
  } catch (err) {
@@ -1255,6 +1627,7 @@ async function handleSessionIdle(event, ctx) {
1255
1627
  const sessionId = event.properties.sessionID;
1256
1628
  const parentID = await resolveParentID(sessionId, ctx);
1257
1629
  ctx.sessionTitleService.setSessionStatus(sessionId, "idle");
1630
+ await ctx.sessionRegistry.updateSession(sessionId, { status: "idle", updatedAt: Date.now() });
1258
1631
  if (typeof parentID === "string") {
1259
1632
  ctx.logger.info("suppressing child session idle notification", { sessionId, parentID });
1260
1633
  await flushDeferredParentIfReady(parentID, ctx);
@@ -1281,9 +1654,14 @@ async function handleSessionIdle(event, ctx) {
1281
1654
  async function handleSessionStatus(event, ctx) {
1282
1655
  const sessionId = event.properties.sessionID;
1283
1656
  const statusType = event.properties.status.type;
1657
+ const previousStatus = ctx.sessionTitleService.getSessionStatus(sessionId);
1284
1658
  ctx.sessionTitleService.setSessionStatus(sessionId, statusType);
1285
1659
  if (statusType === "idle") {
1286
1660
  await handleSessionIdle(event, ctx);
1661
+ return;
1662
+ }
1663
+ if (previousStatus !== statusType) {
1664
+ await ctx.sessionRegistry.updateSession(sessionId, { status: statusType, updatedAt: Date.now() });
1287
1665
  }
1288
1666
  }
1289
1667
 
@@ -1291,19 +1669,605 @@ async function handleSessionStatus(event, ctx) {
1291
1669
  async function handleSessionUpdated(event, ctx) {
1292
1670
  const info = event.properties.info;
1293
1671
  ctx.sessionTitleService.setSessionInfo(info);
1672
+ await ctx.sessionRegistry.upsertSession(
1673
+ registryEntryFromSession(info, ctx.serverUrl.href, ctx.sessionTitleService.getSessionStatus(info.id))
1674
+ );
1675
+ }
1676
+
1677
+ // src/lib/html-escape.ts
1678
+ function escapeHtml(input) {
1679
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1680
+ }
1681
+ function truncateForTelegram(input, maxChars, ellipsis = "\u2026") {
1682
+ const single = input.replace(/\s+/g, " ").trim();
1683
+ if (single.length <= maxChars) return single;
1684
+ if (maxChars <= 0) return "";
1685
+ if (ellipsis.length >= maxChars) return ellipsis.slice(0, maxChars);
1686
+ return single.slice(0, maxChars - ellipsis.length) + ellipsis;
1687
+ }
1688
+ function stripCodeFences(input) {
1689
+ return input.replace(/```[^\r\n`]*\r?\n([\s\S]*?)```/g, "$1").replace(/```([\s\S]*?)```/g, "$1").replace(/`([^`]*)`/g, "$1").replace(/\s+/g, " ").trim();
1690
+ }
1691
+
1692
+ // src/events/sessions-command.ts
1693
+ var MAX_BODY_CHARS = 3900;
1694
+ var MAX_TITLE_CHARS = 55;
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
+ }
1745
+ function createSessionsDispatcher(deps) {
1746
+ return async ({ chatId, bot }) => {
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
+ }
1798
+ if (sessions.length === 0) {
1799
+ await bot.sendMessage("\uC138\uC158\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.", { parse_mode: "HTML" });
1800
+ return;
1801
+ }
1802
+ const capturedAt = Date.now();
1803
+ const entries = sessions.map((session, i) => {
1804
+ const entry = {
1805
+ index: i + 1,
1806
+ sessionId: session.sessionId,
1807
+ title: session.title,
1808
+ capturedAt
1809
+ };
1810
+ if (session.agent !== void 0) entry.agent = session.agent;
1811
+ entry.status = session.status;
1812
+ if (session.serverUrl !== void 0) entry.serverUrl = session.serverUrl;
1813
+ return entry;
1814
+ });
1815
+ await deps.snapshotStore.saveSnapshot(chatId, entries);
1816
+ const lines = entries.map((entry) => {
1817
+ const agent = entry.agent ? escapeHtml(entry.agent) : "?";
1818
+ const title = truncateForTelegram(escapeHtml(entry.title), MAX_TITLE_CHARS);
1819
+ const status = ` \u2014 ${escapeHtml(entry.status ?? "idle")}`;
1820
+ return `${entry.index}. [${agent}] ${title}${status}`;
1821
+ });
1822
+ let body = lines.join("\n");
1823
+ if (body.length > MAX_BODY_CHARS) {
1824
+ body = body.slice(0, MAX_BODY_CHARS) + "\u2026";
1825
+ }
1826
+ const text = `<b>\uCD5C\uADFC \uC138\uC158 (top ${entries.length})</b>
1827
+ ${body}
1828
+
1829
+ <i>/status N \uB610\uB294 /start_work N \uC73C\uB85C \uC870\uC791</i>`;
1830
+ await bot.sendMessage(text, { parse_mode: "HTML" });
1831
+ deps.logger.info("sessions listed", { chatId, count: entries.length });
1832
+ };
1833
+ }
1834
+
1835
+ // src/lib/plan-readiness.ts
1836
+ import { access, readFile as readFile5, readdir as readdir6, stat as stat2 } from "fs/promises";
1837
+ import { join as join6 } from "path";
1838
+ async function checkPlanReadiness(args) {
1839
+ const { projectRoot } = args;
1840
+ const omoDir = join6(projectRoot, ".omo");
1841
+ const plansDir = join6(omoDir, "plans");
1842
+ const boulderPath = join6(omoDir, "boulder.json");
1843
+ try {
1844
+ await access(omoDir);
1845
+ } catch {
1846
+ return {
1847
+ ready: false,
1848
+ reason: "no-omo-dir",
1849
+ detail: `${omoDir} does not exist`
1850
+ };
1851
+ }
1852
+ try {
1853
+ await access(boulderPath);
1854
+ return {
1855
+ ready: false,
1856
+ reason: "boulder-active",
1857
+ detail: `${boulderPath} exists`
1858
+ };
1859
+ } catch {
1860
+ }
1861
+ let planFiles = [];
1862
+ try {
1863
+ const entries = await readdir6(plansDir);
1864
+ planFiles = entries.filter((e) => e.endsWith(".md"));
1865
+ } catch {
1866
+ return {
1867
+ ready: false,
1868
+ reason: "no-plans",
1869
+ detail: `${plansDir} not found or empty`
1870
+ };
1871
+ }
1872
+ if (planFiles.length === 0) {
1873
+ return {
1874
+ ready: false,
1875
+ reason: "no-plans",
1876
+ detail: `No .md files in ${plansDir}`
1877
+ };
1878
+ }
1879
+ const stats = await Promise.all(
1880
+ planFiles.map(async (f) => {
1881
+ const full = join6(plansDir, f);
1882
+ const s = await stat2(full);
1883
+ return { path: full, name: f, mtime: s.mtime.getTime() };
1884
+ })
1885
+ );
1886
+ stats.sort((a, b) => b.mtime - a.mtime);
1887
+ const latest = stats[0];
1888
+ const content = await readFile5(latest.path, "utf8");
1889
+ const totalMatches = content.match(/^- \[[ xX]\]/gm) ?? [];
1890
+ const completedMatches = content.match(/^- \[[xX]\]/gm) ?? [];
1891
+ const total = totalMatches.length;
1892
+ const completed = completedMatches.length;
1893
+ if (total === 0) {
1894
+ return {
1895
+ ready: false,
1896
+ reason: "plan-empty",
1897
+ detail: `${latest.name}: no checkboxes found`
1898
+ };
1899
+ }
1900
+ if (completed >= total) {
1901
+ return {
1902
+ ready: false,
1903
+ reason: "all-plans-complete",
1904
+ detail: `${latest.name}: ${completed}/${total} complete`
1905
+ };
1906
+ }
1907
+ return {
1908
+ ready: true,
1909
+ planPath: latest.path,
1910
+ planName: latest.name.replace(/\.md$/, ""),
1911
+ total,
1912
+ completed
1913
+ };
1914
+ }
1915
+ async function recheckSessionIdle(client, sessionId) {
1916
+ const result = await client.session.status();
1917
+ const statuses = result.data ?? {};
1918
+ const sessionStatus = statuses[sessionId];
1919
+ return (sessionStatus?.type ?? "idle") === "idle";
1920
+ }
1921
+
1922
+ // src/events/status-command.ts
1923
+ var SNIPPET_MAX_CHARS = 80;
1924
+ var MESSAGES_LIMIT = 10;
1925
+ var EMPTY_MESSAGE = "\uBA54\uC2DC\uC9C0 \uC5C6\uC74C";
1926
+ function resolveProjectRoot(session) {
1927
+ if (!session.directory) throw new Error("session directory missing");
1928
+ return session.directory;
1929
+ }
1930
+ function extractTextFromParts(parts) {
1931
+ const pieces = [];
1932
+ for (const part of parts) {
1933
+ if (part.type === "text" && typeof part.text === "string") {
1934
+ pieces.push(part.text);
1935
+ }
1936
+ }
1937
+ return pieces.join(" ");
1938
+ }
1939
+ function buildSnippet(envelope) {
1940
+ if (!envelope) return EMPTY_MESSAGE;
1941
+ try {
1942
+ const raw = extractTextFromParts(envelope.parts);
1943
+ const cleaned = stripCodeFences(raw);
1944
+ const truncated = truncateForTelegram(cleaned, SNIPPET_MAX_CHARS);
1945
+ if (!truncated) return EMPTY_MESSAGE;
1946
+ return escapeHtml(truncated);
1947
+ } catch {
1948
+ return EMPTY_MESSAGE;
1949
+ }
1950
+ }
1951
+ function findLastByRole(messages, role) {
1952
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1953
+ const msg = messages[i];
1954
+ if (msg && msg.info.role === role) return msg;
1955
+ }
1956
+ return void 0;
1957
+ }
1958
+ function planReadinessKorean(result) {
1959
+ if (result.ready) {
1960
+ return `${result.completed}/${result.total} (${result.planName})`;
1961
+ }
1962
+ switch (result.reason) {
1963
+ case "no-omo-dir":
1964
+ return "`.omo/` \uC5C6\uC74C";
1965
+ case "no-plans":
1966
+ return "plan \uD30C\uC77C \uC5C6\uC74C";
1967
+ case "plan-empty":
1968
+ return "\uCCB4\uD06C\uBC15\uC2A4 \uC5C6\uC74C";
1969
+ case "all-plans-complete": {
1970
+ const match = result.detail.match(/(\d+)\/(\d+)/);
1971
+ if (match) return `${match[1]}/${match[2]} \uC644\uB8CC`;
1972
+ return "\uC644\uB8CC";
1973
+ }
1974
+ case "boulder-active":
1975
+ return "boulder \uD65C\uC131";
1976
+ }
1977
+ }
1978
+ function planLine(result) {
1979
+ if (result.ready) {
1980
+ return `<b>\uD50C\uB79C \uC9C4\uD589\uB3C4</b>: ${result.completed}/${result.total} (${escapeHtml(result.planName)})`;
1981
+ }
1982
+ return `<b>\uD50C\uB79C \uC0C1\uD0DC</b>: ${planReadinessKorean(result)}`;
1983
+ }
1984
+ function boulderLine(result) {
1985
+ const active = !result.ready && result.reason === "boulder-active";
1986
+ return active ? "<b>Boulder</b>: \uD65C\uC131" : "<b>Boulder</b>: \uC5C6\uC74C";
1987
+ }
1988
+ function createStatusDispatcher(deps) {
1989
+ return async ({ chatId, bot, args }) => {
1990
+ const rawN = args[0];
1991
+ if (rawN === void 0 || rawN === "") {
1992
+ await bot.sendMessage("\uC0AC\uC6A9\uBC95: /status <\uBC88\uD638>. \uBA3C\uC800 /sessions \uB85C \uBAA9\uB85D \uD655\uC778", {
1993
+ parse_mode: "HTML"
1994
+ });
1995
+ return;
1996
+ }
1997
+ const n = Number(rawN);
1998
+ if (Number.isNaN(n)) {
1999
+ await bot.sendMessage(`\uC798\uBABB\uB41C \uC785\uB825: ${escapeHtml(rawN)}\uC740 \uC22B\uC790\uC5EC\uC57C \uD569\uB2C8\uB2E4`, {
2000
+ parse_mode: "HTML"
2001
+ });
2002
+ return;
2003
+ }
2004
+ const snapshot = await deps.snapshotStore.loadSnapshot(chatId);
2005
+ if (!snapshot) {
2006
+ await bot.sendMessage("\uC138\uC158 \uBAA9\uB85D\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 /sessions \uB97C \uC2E4\uD589\uD558\uC138\uC694.", {
2007
+ parse_mode: "HTML"
2008
+ });
2009
+ return;
2010
+ }
2011
+ const entry = snapshot.find((e) => e.index === n);
2012
+ if (!entry) {
2013
+ await bot.sendMessage(`${n}\uBC88 \uC138\uC158 \uC5C6\uC74C. \uD604\uC7AC \uBAA9\uB85D \uD06C\uAE30: ${snapshot.length}`, {
2014
+ parse_mode: "HTML"
2015
+ });
2016
+ return;
2017
+ }
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
+ }
2071
+ if (!session || responseStatus === 404) {
2072
+ await bot.sendMessage("\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
2073
+ parse_mode: "HTML"
2074
+ });
2075
+ return;
2076
+ }
2077
+ const projectRoot = resolveProjectRoot(session);
2078
+ const planReady = await checkPlanReadiness({ projectRoot });
2079
+ const userSnippet = buildSnippet(findLastByRole(messages, "user"));
2080
+ const assistantSnippet = buildSnippet(findLastByRole(messages, "assistant"));
2081
+ const title = escapeHtml(session.title ?? "");
2082
+ const agent = entry.agent ? escapeHtml(entry.agent) : "?";
2083
+ const text = [
2084
+ `<b>\uC138\uC158 #${n}</b>: ${title}`,
2085
+ `\uC5D0\uC774\uC804\uD2B8: ${agent}`,
2086
+ `\uC0C1\uD0DC: ${escapeHtml(sessionStatus)}`,
2087
+ ``,
2088
+ `<b>\uB9C8\uC9C0\uB9C9 \uBA54\uC2DC\uC9C0</b>`,
2089
+ `\uC720\uC800: ${userSnippet}`,
2090
+ `\uC5D0\uC774\uC804\uD2B8: ${assistantSnippet}`,
2091
+ ``,
2092
+ planLine(planReady),
2093
+ ``,
2094
+ boulderLine(planReady)
2095
+ ].join("\n");
2096
+ await bot.sendMessage(text, { parse_mode: "HTML" });
2097
+ deps.logger.info("status shown", {
2098
+ chatId,
2099
+ sessionId: entry.sessionId,
2100
+ snapshotIndex: n
2101
+ });
2102
+ };
2103
+ }
2104
+
2105
+ // src/events/start-work-command.ts
2106
+ function agentFromSession3(session) {
2107
+ return session.agent;
2108
+ }
2109
+ function resolveProjectRoot2(session) {
2110
+ return session.directory;
2111
+ }
2112
+ function readinessMessage(reason) {
2113
+ switch (reason) {
2114
+ case "no-omo-dir":
2115
+ return ".omo/ \uB514\uB809\uD1A0\uB9AC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. plan \uC791\uC131\uC774 \uC120\uD589\uB418\uC5B4\uC57C \uD569\uB2C8\uB2E4";
2116
+ case "no-plans":
2117
+ return ".omo/plans/ \uC5D0 plan \uD30C\uC77C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4";
2118
+ case "plan-empty":
2119
+ return "plan \uD30C\uC77C\uC5D0 \uCCB4\uD06C\uBC15\uC2A4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4 (\uD5E4\uB354\uB9CC \uC874\uC7AC)";
2120
+ case "all-plans-complete":
2121
+ return "plan \uC758 \uBAA8\uB4E0 task \uAC00 \uC644\uB8CC\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uC0C8 plan \uC791\uC131 \uD544\uC694";
2122
+ case "boulder-active":
2123
+ return ".omo/boulder.json \uC774 \uC774\uBBF8 \uC874\uC7AC\uD569\uB2C8\uB2E4. \uAE30\uC874 \uC791\uC5C5\uC774 \uC9C4\uD589 \uC911\uC774\uAC70\uB098 archive \uAC00 \uD544\uC694\uD569\uB2C8\uB2E4";
2124
+ }
2125
+ }
2126
+ function isSessionNotFoundError(err) {
2127
+ const httpError = err;
2128
+ return httpError.status === 404 || httpError.statusCode === 404 || httpError.response?.status === 404 || err.message.includes("404");
2129
+ }
2130
+ async function sendHtml(bot, text) {
2131
+ await bot.sendMessage(text, { parse_mode: "HTML" });
2132
+ }
2133
+ async function sendPlain(bot, text) {
2134
+ await bot.sendMessage(text);
2135
+ }
2136
+ function createStartWorkCommandDispatcher(deps) {
2137
+ return async ({ chatId, bot, args }) => {
2138
+ const rawIndex = args[0]?.trim();
2139
+ if (!rawIndex) {
2140
+ await sendPlain(bot, "\uC0AC\uC6A9\uBC95: /start_work <\uBC88\uD638>. \uBA3C\uC800 /sessions \uB85C \uBAA9\uB85D \uD655\uC778");
2141
+ return;
2142
+ }
2143
+ const index = Number(rawIndex);
2144
+ if (Number.isNaN(index)) {
2145
+ await sendPlain(bot, `\uC798\uBABB\uB41C \uC785\uB825: ${rawIndex}\uC740 \uC22B\uC790\uC5EC\uC57C \uD569\uB2C8\uB2E4`);
2146
+ return;
2147
+ }
2148
+ const snapshot = await deps.snapshotStore.loadSnapshot(chatId);
2149
+ if (snapshot === null) {
2150
+ await sendPlain(bot, "\uC138\uC158 \uBAA9\uB85D\uC774 \uC5C6\uC2B5\uB2C8\uB2E4. \uBA3C\uC800 /sessions \uC2E4\uD589");
2151
+ return;
2152
+ }
2153
+ const entry = snapshot.find((candidate) => candidate.index === index);
2154
+ if (!entry) {
2155
+ await sendPlain(bot, `${index}\uBC88 \uC138\uC158 \uC5C6\uC74C (\uBAA9\uB85D \uD06C\uAE30: ${snapshot.length})`);
2156
+ return;
2157
+ }
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);
2167
+ let session;
2168
+ try {
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;
2183
+ }
2184
+ } catch (err) {
2185
+ if (err instanceof Error && isSessionNotFoundError(err)) {
2186
+ await sendPlain(bot, "\uC138\uC158\uC774 \uB354 \uC774\uC0C1 \uC874\uC7AC\uD558\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4");
2187
+ return;
2188
+ }
2189
+ await sendPlain(bot, "\uC138\uC158 \uD655\uC778 \uC2E4\uD328. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694");
2190
+ deps.logger.error("start-work session lookup failed", { sessionId, error: String(err) });
2191
+ return;
2192
+ }
2193
+ const agent = deps.sessionTitleService.getSessionAgent(sessionId) ?? agentFromSession3(session) ?? entry.agent;
2194
+ if (agent !== "plan") {
2195
+ await sendPlain(
2196
+ bot,
2197
+ `${index}\uBC88 \uC138\uC158\uC758 \uC5D0\uC774\uC804\uD2B8\uB294 'plan' \uC774 \uC544\uB2D9\uB2C8\uB2E4 (\uD604\uC7AC: ${agent ?? "unknown"}). /start_work \uB294 plan \uC138\uC158\uC5D0\uC11C\uB9CC \uAC00\uB2A5\uD569\uB2C8\uB2E4`
2198
+ );
2199
+ return;
2200
+ }
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
+ }
2209
+ if (!idle) {
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`);
2211
+ return;
2212
+ }
2213
+ const readiness = await checkPlanReadiness({ projectRoot: resolveProjectRoot2(session) });
2214
+ if (!readiness.ready) {
2215
+ await sendPlain(bot, readinessMessage(readiness.reason));
2216
+ return;
2217
+ }
2218
+ try {
2219
+ await deps.runSessionCommand(sessionId, "start-work", sourceServerUrl);
2220
+ await sendHtml(
2221
+ bot,
2222
+ `${index}\uBC88 \uC138\uC158\uC5D0 opencode /start-work \uC2AC\uB798\uC2DC \uCEE4\uB9E8\uB4DC \uC804\uC1A1 \uC644\uB8CC. (${escapeHtml(entry.title)})`
2223
+ );
2224
+ deps.logger.info("start-work dispatched", { chatId, sessionId, index });
2225
+ } catch (err) {
2226
+ await sendHtml(bot, "opencode /start-work \uC804\uC1A1 \uC2E4\uD328");
2227
+ deps.logger.error("start-work dispatch failed", { sessionId, error: String(err) });
2228
+ }
2229
+ };
2230
+ }
2231
+
2232
+ // src/events/help-command.ts
2233
+ var HELP_TEXT = `<b>OpenCode Telegram Plugin \u2014 \uBA85\uB839 \uB3C4\uC6C0\uB9D0</b>
2234
+
2235
+ <b>/sessions</b>
2236
+ \uD65C\uC131 root \uC138\uC158 \uBAA9\uB85D\uC744 \uBC88\uD638\uC640 \uD568\uAED8 \uD45C\uC2DC (\uCD5C\uADFC\uD65C\uB3D9\uC21C top 20).
2237
+
2238
+ <b>/status &lt;\uBC88\uD638&gt;</b>
2239
+ \uD574\uB2F9 \uC138\uC158\uC758 \uC5D0\uC774\uC804\uD2B8/\uC0C1\uD0DC/\uB9C8\uC9C0\uB9C9 \uBA54\uC2DC\uC9C0 \uC2A4\uB2C8\uD3AB/\uD50C\uB79C \uC9C4\uD589\uB3C4/boulder \uC0C1\uD0DC \uD45C\uC2DC.
2240
+
2241
+ <b>/start_work &lt;\uBC88\uD638&gt;</b>
2242
+ \uD574\uB2F9 \uC138\uC158\uC5D0 opencode <code>/start-work</code> \uC2AC\uB798\uC2DC \uCEE4\uB9E8\uB4DC \uC804\uC1A1.
2243
+ \uC548\uC804 \uAC8C\uC774\uD2B8: agent='plan' AND status=idle AND .omo/plans \uC5D0 \uBBF8\uC644\uB8CC plan \uC874\uC7AC AND .omo/boulder.json \uBD80\uC7AC.
2244
+ \uC870\uAC74 \uBBF8\uCDA9\uC871\uC2DC \uAD6C\uCCB4\uC801 \uC0AC\uC720 \uC548\uB0B4.
2245
+ (Telegram \uBD07 \uBA85\uB839\uC740 <code>/start_work</code>, \uB0B4\uBD80 \uD2B8\uB9AC\uAC70 \uB300\uC0C1\uC740 opencode \uC758 <code>/start-work</code>)
2246
+
2247
+ <b>/help</b>
2248
+ \uC774 \uB3C4\uC6C0\uB9D0 \uD45C\uC2DC.
2249
+
2250
+ <b>\uC81C\uC57D</b>
2251
+ \uBC88\uD638\uB294 <code>/sessions</code> \uB9C8\uC9C0\uB9C9 \uD638\uCD9C\uC758 \uC2A4\uB0C5\uC0F7\uC5D0 \uC885\uC18D (TTL 1\uC2DC\uAC04).
2252
+ leader \uD504\uB85C\uC138\uC2A4\uAC00 \uAD00\uCC30\uD55C \uC138\uC158\uB9CC \uD45C\uC2DC \u2014 \uB2E4\uB978 OpenCode \uD504\uB85C\uC138\uC2A4\uC758 \uC138\uC158\uC740 \uBCF4\uC774\uC9C0 \uC54A\uC744 \uC218 \uC788\uC74C.`;
2253
+ function createHelpDispatcher(deps) {
2254
+ return async ({ chatId, bot }) => {
2255
+ await bot.sendMessage(HELP_TEXT, { parse_mode: "HTML" });
2256
+ deps.logger.info("help shown", { chatId });
2257
+ };
1294
2258
  }
1295
2259
 
1296
2260
  // src/lib/env-loader.ts
1297
2261
  import { existsSync } from "fs";
1298
2262
  import { homedir } from "os";
1299
- import { join as join5 } from "path";
2263
+ import { join as join7 } from "path";
1300
2264
  import dotenv from "dotenv";
1301
2265
  function loadPluginEnv(opts) {
1302
2266
  const paths = [
1303
- join5(opts.pluginDir, "../../.env"),
1304
- join5(opts.pluginDir, "..", ".env"),
1305
- join5(opts.pluginDir, ".env"),
1306
- join5(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")
1307
2271
  ];
1308
2272
  const loadedFrom = [];
1309
2273
  const values = {};
@@ -1321,10 +2285,10 @@ function loadPluginEnv(opts) {
1321
2285
  }
1322
2286
 
1323
2287
  // src/lib/lock.ts
1324
- import { open as open2, readFile as readFile4, stat as stat2, unlink as unlink5 } from "fs/promises";
2288
+ import { open as open2, readFile as readFile6, stat as stat3, unlink as unlink6 } from "fs/promises";
1325
2289
  import { hostname } from "os";
1326
2290
  var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
1327
- function hasCode5(err, code) {
2291
+ function hasCode6(err, code) {
1328
2292
  return "code" in err && err.code === code;
1329
2293
  }
1330
2294
  function parseLockData(text) {
@@ -1343,7 +2307,7 @@ function isPidAlive(pid) {
1343
2307
  process.kill(pid, 0);
1344
2308
  return true;
1345
2309
  } catch (err) {
1346
- if (err instanceof Error && hasCode5(err, "ESRCH")) return false;
2310
+ if (err instanceof Error && hasCode6(err, "ESRCH")) return false;
1347
2311
  return true;
1348
2312
  }
1349
2313
  }
@@ -1364,7 +2328,7 @@ async function createLock(lockPath, pid) {
1364
2328
  if (released) return;
1365
2329
  released = true;
1366
2330
  try {
1367
- await unlink5(lockPath);
2331
+ await unlink6(lockPath);
1368
2332
  } catch {
1369
2333
  }
1370
2334
  }
@@ -1374,7 +2338,7 @@ async function inspectExisting(lockPath, ttlMs) {
1374
2338
  let ownerPid;
1375
2339
  let dead = false;
1376
2340
  try {
1377
- const text = await readFile4(lockPath, "utf8");
2341
+ const text = await readFile6(lockPath, "utf8");
1378
2342
  const data = parseLockData(text);
1379
2343
  if (data) {
1380
2344
  ownerPid = data.pid;
@@ -1384,7 +2348,7 @@ async function inspectExisting(lockPath, ttlMs) {
1384
2348
  return { stale: true, reason: "unreadable lock" };
1385
2349
  }
1386
2350
  try {
1387
- const fileStat = await stat2(lockPath);
2351
+ const fileStat = await stat3(lockPath);
1388
2352
  const expired = Date.now() - fileStat.mtimeMs > ttlMs;
1389
2353
  if (dead) return { stale: true, ownerPid, reason: "dead owner" };
1390
2354
  if (expired) return { stale: true, ownerPid, reason: "expired lock" };
@@ -1400,7 +2364,7 @@ async function acquireLock(opts) {
1400
2364
  try {
1401
2365
  return { acquired: true, handle: await createLock(opts.lockPath, pid) };
1402
2366
  } catch (err) {
1403
- if (!(err instanceof Error) || !hasCode5(err, "EEXIST")) {
2367
+ if (!(err instanceof Error) || !hasCode6(err, "EEXIST")) {
1404
2368
  return { acquired: false, reason: err instanceof Error ? err.message : String(err) };
1405
2369
  }
1406
2370
  const existing = await inspectExisting(opts.lockPath, ttlMs);
@@ -1408,7 +2372,7 @@ async function acquireLock(opts) {
1408
2372
  return { acquired: false, reason: existing.reason, ownerPid: existing.ownerPid };
1409
2373
  }
1410
2374
  try {
1411
- await unlink5(opts.lockPath);
2375
+ await unlink6(opts.lockPath);
1412
2376
  } catch {
1413
2377
  return { acquired: false, reason: "failed to remove stale lock", ownerPid: existing.ownerPid };
1414
2378
  }
@@ -1487,11 +2451,152 @@ function createLogger(opts = {}) {
1487
2451
  };
1488
2452
  }
1489
2453
 
2454
+ // src/lib/session-snapshot.ts
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";
2457
+ var TTL_MS = 60 * 60 * 1e3;
2458
+ function hasCode7(err, code) {
2459
+ return err instanceof Error && "code" in err && err.code === code;
2460
+ }
2461
+ function isSnapshotFile(value) {
2462
+ if (!value || typeof value !== "object") return false;
2463
+ const v = value;
2464
+ if (v.version !== 1) return false;
2465
+ if (typeof v.chatId !== "number") return false;
2466
+ if (typeof v.createdAt !== "number") return false;
2467
+ if (!Array.isArray(v.entries)) return false;
2468
+ for (const entry of v.entries) {
2469
+ if (!entry || typeof entry !== "object") return false;
2470
+ const e = entry;
2471
+ if (typeof e.index !== "number") return false;
2472
+ if (typeof e.sessionId !== "string") return false;
2473
+ if (typeof e.title !== "string") return false;
2474
+ if (typeof e.capturedAt !== "number") return false;
2475
+ if (e.agent !== void 0 && typeof e.agent !== "string") return false;
2476
+ if (e.status !== void 0 && typeof e.status !== "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
+ }
2481
+ }
2482
+ return true;
2483
+ }
2484
+ function normalizeEntry2(entry) {
2485
+ const out = {
2486
+ index: entry.index,
2487
+ sessionId: entry.sessionId,
2488
+ title: entry.title,
2489
+ capturedAt: entry.capturedAt
2490
+ };
2491
+ if (entry.agent !== void 0) out.agent = entry.agent;
2492
+ if (entry.status !== void 0) out.status = entry.status;
2493
+ if (entry.serverUrl !== void 0) {
2494
+ const serverUrl = normalizeOpenCodeServerUrl(entry.serverUrl);
2495
+ if (serverUrl !== void 0) out.serverUrl = serverUrl;
2496
+ }
2497
+ return out;
2498
+ }
2499
+ function createSnapshotStore(opts) {
2500
+ const { configDir, tokenHash, logger } = opts;
2501
+ const snapshotsDir = join8(configDir, "snapshots");
2502
+ const writeLocks = /* @__PURE__ */ new Map();
2503
+ function snapshotFilePath(chatId) {
2504
+ return join8(snapshotsDir, `${tokenHash}-${chatId}.json`);
2505
+ }
2506
+ async function performSave(chatId, entries) {
2507
+ const filePath = snapshotFilePath(chatId);
2508
+ const parent = dirname4(filePath);
2509
+ await mkdir6(parent, { recursive: true });
2510
+ try {
2511
+ await chmod2(parent, 448);
2512
+ } catch (err) {
2513
+ logger.error("snapshot: failed to chmod parent dir", {
2514
+ path: parent,
2515
+ error: err instanceof Error ? err.message : String(err)
2516
+ });
2517
+ }
2518
+ const payload = {
2519
+ version: 1,
2520
+ chatId,
2521
+ createdAt: Date.now(),
2522
+ entries: entries.map(normalizeEntry2)
2523
+ };
2524
+ const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
2525
+ await writeFile5(tmpPath, JSON.stringify(payload, null, 2), "utf8");
2526
+ try {
2527
+ await rename5(tmpPath, filePath);
2528
+ } catch (err) {
2529
+ try {
2530
+ await unlink7(tmpPath);
2531
+ } catch {
2532
+ }
2533
+ throw err;
2534
+ }
2535
+ await chmod2(filePath, 384);
2536
+ }
2537
+ async function saveSnapshot(chatId, entries) {
2538
+ const prev = writeLocks.get(chatId) ?? Promise.resolve();
2539
+ const next = prev.catch(() => void 0).then(() => performSave(chatId, entries));
2540
+ const tracked = next.catch(() => void 0);
2541
+ writeLocks.set(chatId, tracked);
2542
+ try {
2543
+ await next;
2544
+ } finally {
2545
+ if (writeLocks.get(chatId) === tracked) {
2546
+ writeLocks.delete(chatId);
2547
+ }
2548
+ }
2549
+ }
2550
+ async function loadSnapshot(chatId) {
2551
+ const filePath = snapshotFilePath(chatId);
2552
+ let text;
2553
+ try {
2554
+ text = await readFile7(filePath, "utf8");
2555
+ } catch (err) {
2556
+ if (hasCode7(err, "ENOENT")) return null;
2557
+ logger.error("snapshot: failed to read file", {
2558
+ path: filePath,
2559
+ error: err instanceof Error ? err.message : String(err)
2560
+ });
2561
+ return null;
2562
+ }
2563
+ let parsed;
2564
+ try {
2565
+ parsed = JSON.parse(text);
2566
+ } catch (err) {
2567
+ logger.error("snapshot: corrupted JSON", {
2568
+ path: filePath,
2569
+ error: err instanceof Error ? err.message : String(err)
2570
+ });
2571
+ return null;
2572
+ }
2573
+ if (!isSnapshotFile(parsed)) {
2574
+ logger.error("snapshot: invalid shape", { path: filePath });
2575
+ return null;
2576
+ }
2577
+ if (parsed.createdAt + TTL_MS < Date.now()) {
2578
+ try {
2579
+ await unlink7(filePath);
2580
+ } catch (err) {
2581
+ if (!hasCode7(err, "ENOENT")) {
2582
+ logger.error("snapshot: failed to unlink expired file", {
2583
+ path: filePath,
2584
+ error: err instanceof Error ? err.message : String(err)
2585
+ });
2586
+ }
2587
+ }
2588
+ return null;
2589
+ }
2590
+ return parsed.entries.map(normalizeEntry2);
2591
+ }
2592
+ return { saveSnapshot, loadSnapshot, snapshotFilePath };
2593
+ }
2594
+
1490
2595
  // src/lib/state-store.ts
1491
- import { mkdir as mkdir5, readFile as readFile5, rename as rename4, writeFile as writeFile4 } from "fs/promises";
2596
+ import { mkdir as mkdir7, readFile as readFile8, rename as rename6, writeFile as writeFile6 } from "fs/promises";
1492
2597
  import { homedir as homedir2 } from "os";
1493
- import { dirname as dirname4, join as join6 } from "path";
1494
- function hasCode6(err, code) {
2598
+ import { dirname as dirname5, join as join9 } from "path";
2599
+ function hasCode8(err, code) {
1495
2600
  return "code" in err && err.code === code;
1496
2601
  }
1497
2602
  function parseState(text) {
@@ -1503,28 +2608,28 @@ function parseState(text) {
1503
2608
  return state;
1504
2609
  }
1505
2610
  function createStateStore(opts = {}) {
1506
- const filePath = opts.filePath ?? join6(homedir2(), ".config/opencode/telegram-remote/state.json");
2611
+ const filePath = opts.filePath ?? join9(homedir2(), ".config/opencode/telegram-remote/state.json");
1507
2612
  return {
1508
2613
  async read() {
1509
2614
  try {
1510
- return parseState(await readFile5(filePath, "utf8"));
2615
+ return parseState(await readFile8(filePath, "utf8"));
1511
2616
  } catch (err) {
1512
- if (err instanceof Error && hasCode6(err, "ENOENT")) return {};
2617
+ if (err instanceof Error && hasCode8(err, "ENOENT")) return {};
1513
2618
  throw err;
1514
2619
  }
1515
2620
  },
1516
2621
  async write(patch) {
1517
2622
  const existing = await this.read();
1518
2623
  const next = { ...existing, ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
1519
- await mkdir5(dirname4(filePath), { recursive: true });
2624
+ await mkdir7(dirname5(filePath), { recursive: true });
1520
2625
  const tmpPath = `${filePath}.tmp.${process.pid}`;
1521
- await writeFile4(tmpPath, JSON.stringify(next, null, 2), "utf8");
2626
+ await writeFile6(tmpPath, JSON.stringify(next, null, 2), "utf8");
1522
2627
  try {
1523
- await rename4(tmpPath, filePath);
2628
+ await rename6(tmpPath, filePath);
1524
2629
  } catch (err) {
1525
- if (!(err instanceof Error) || !hasCode6(err, "ENOENT")) throw err;
1526
- await writeFile4(tmpPath, JSON.stringify(next, null, 2), "utf8");
1527
- await rename4(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);
1528
2633
  }
1529
2634
  return next;
1530
2635
  }
@@ -1532,7 +2637,7 @@ function createStateStore(opts = {}) {
1532
2637
  }
1533
2638
 
1534
2639
  // src/services/session-title-service.ts
1535
- function agentFromSession(info) {
2640
+ function agentFromSession4(info) {
1536
2641
  const candidate = info;
1537
2642
  return typeof candidate.agent === "string" ? candidate.agent : void 0;
1538
2643
  }
@@ -1543,9 +2648,11 @@ var SessionTitleService = class {
1543
2648
  this.sessions.set(info.id, {
1544
2649
  title: info.title || null,
1545
2650
  parentID: info.parentID ?? null,
1546
- agent: agentFromSession(info) ?? existing?.agent,
2651
+ agent: agentFromSession4(info) ?? existing?.agent,
1547
2652
  status: existing?.status,
1548
- idleNotificationPending: existing?.idleNotificationPending ?? false
2653
+ idleNotificationPending: existing?.idleNotificationPending ?? false,
2654
+ lastSeenAt: Date.now(),
2655
+ serverUrl: existing?.serverUrl
1549
2656
  });
1550
2657
  }
1551
2658
  setSessionTitle(sessionId, title) {
@@ -1555,7 +2662,9 @@ var SessionTitleService = class {
1555
2662
  parentID: existing?.parentID,
1556
2663
  agent: existing?.agent,
1557
2664
  status: existing?.status,
1558
- idleNotificationPending: existing?.idleNotificationPending ?? false
2665
+ idleNotificationPending: existing?.idleNotificationPending ?? false,
2666
+ lastSeenAt: Date.now(),
2667
+ serverUrl: existing?.serverUrl
1559
2668
  });
1560
2669
  }
1561
2670
  setSessionAgent(sessionId, agent) {
@@ -1565,7 +2674,9 @@ var SessionTitleService = class {
1565
2674
  parentID: existing?.parentID,
1566
2675
  agent,
1567
2676
  status: existing?.status,
1568
- idleNotificationPending: existing?.idleNotificationPending ?? false
2677
+ idleNotificationPending: existing?.idleNotificationPending ?? false,
2678
+ lastSeenAt: Date.now(),
2679
+ serverUrl: existing?.serverUrl
1569
2680
  });
1570
2681
  }
1571
2682
  setSessionStatus(sessionId, status) {
@@ -1575,9 +2686,48 @@ var SessionTitleService = class {
1575
2686
  parentID: existing?.parentID,
1576
2687
  agent: existing?.agent,
1577
2688
  status,
1578
- idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false
2689
+ idleNotificationPending: status === "idle" ? existing?.idleNotificationPending ?? false : false,
2690
+ lastSeenAt: Date.now(),
2691
+ serverUrl: existing?.serverUrl
1579
2692
  });
1580
2693
  }
2694
+ setServerUrl(sessionId, serverUrl) {
2695
+ const existing = this.sessions.get(sessionId);
2696
+ if (existing?.serverUrl) return;
2697
+ const lastSeenAt = existing?.lastSeenAt ?? Date.now();
2698
+ this.sessions.set(sessionId, {
2699
+ ...existing ?? {
2700
+ title: null,
2701
+ parentID: void 0,
2702
+ idleNotificationPending: false,
2703
+ lastSeenAt
2704
+ },
2705
+ lastSeenAt,
2706
+ serverUrl
2707
+ });
2708
+ }
2709
+ getServerUrl(sessionId) {
2710
+ return this.sessions.get(sessionId)?.serverUrl;
2711
+ }
2712
+ getRootSessionsByRecency(limit) {
2713
+ const results = [];
2714
+ for (const [sessionId, entry] of this.sessions.entries()) {
2715
+ if (entry.parentID !== null) continue;
2716
+ results.push({
2717
+ sessionId,
2718
+ title: entry.title,
2719
+ agent: entry.agent,
2720
+ status: entry.status,
2721
+ serverUrl: entry.serverUrl
2722
+ });
2723
+ }
2724
+ results.sort((a, b) => {
2725
+ const lastSeenA = this.sessions.get(a.sessionId)?.lastSeenAt ?? 0;
2726
+ const lastSeenB = this.sessions.get(b.sessionId)?.lastSeenAt ?? 0;
2727
+ return lastSeenB - lastSeenA;
2728
+ });
2729
+ return results.slice(0, limit);
2730
+ }
1581
2731
  getSessionTitle(sessionId) {
1582
2732
  return this.sessions.get(sessionId)?.title ?? null;
1583
2733
  }
@@ -1605,7 +2755,9 @@ var SessionTitleService = class {
1605
2755
  parentID: existing?.parentID,
1606
2756
  agent: existing?.agent,
1607
2757
  status: existing?.status ?? "idle",
1608
- idleNotificationPending: true
2758
+ idleNotificationPending: true,
2759
+ lastSeenAt: existing?.lastSeenAt ?? Date.now(),
2760
+ serverUrl: existing?.serverUrl
1609
2761
  });
1610
2762
  }
1611
2763
  hasDeferredIdleNotification(sessionId) {
@@ -1622,19 +2774,19 @@ var SessionTitleService = class {
1622
2774
  };
1623
2775
 
1624
2776
  // src/telegram-remote.ts
1625
- var pluginDir = dirname5(fileURLToPath(import.meta.url));
2777
+ var pluginDir = dirname6(fileURLToPath(import.meta.url));
1626
2778
  async function postToServer(serverUrl, path, body) {
1627
- 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);
1628
2782
  const response = await fetch(url, {
1629
2783
  method: "POST",
1630
2784
  headers: { "Content-Type": "application/json" },
1631
- body: JSON.stringify(body)
2785
+ body: JSON.stringify(body),
2786
+ redirect: "error"
1632
2787
  });
1633
2788
  if (response.ok) return;
1634
- const text = await response.text();
1635
- throw new Error(
1636
- `OpenCode request failed: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`
1637
- );
2789
+ throw new Error(`OpenCode request failed: ${response.status} ${response.statusText}`);
1638
2790
  }
1639
2791
  function getSessionAgentFromMessage(event) {
1640
2792
  const info = event.properties.info;
@@ -1658,8 +2810,11 @@ var TelegramRemote = async (input) => {
1658
2810
  const stateStore = createStateStore();
1659
2811
  const initialState = await stateStore.read();
1660
2812
  const tokenHash = createHash5("sha256").update(config.botToken).digest("hex").slice(0, 16);
1661
- const lockPath = join7(tmpdir5(), `opencoder-telegram-${tokenHash}.lock`);
1662
- const claimsDir = join7(tmpdir5(), `opencoder-telegram-claims-${tokenHash}`);
2813
+ const configDir = join10(homedir3(), ".config/opencode/telegram-remote");
2814
+ const snapshotStore = createSnapshotStore({ configDir, tokenHash, logger });
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}`);
1663
2818
  const pendingQuestions = createPendingQuestionStore({ tokenHash });
1664
2819
  const pendingPermissions = createPendingPermissionStore({ tokenHash });
1665
2820
  const pendingStartWorks = createPendingStartWorkStore({ tokenHash });
@@ -1689,8 +2844,8 @@ var TelegramRemote = async (input) => {
1689
2844
  throwOnError: true
1690
2845
  });
1691
2846
  };
1692
- const replyToPermission = async (requestID, sessionID, reply, endpoint, serverUrl = input.serverUrl.href) => {
1693
- if (endpoint === "request") {
2847
+ const replyToPermission = async (requestID, sessionID, reply, endpoint2, serverUrl = input.serverUrl.href) => {
2848
+ if (endpoint2 === "request") {
1694
2849
  const path2 = `/permission/${encodeURIComponent(requestID)}/reply`;
1695
2850
  if (serverUrl !== input.serverUrl.href) {
1696
2851
  await postToServer(serverUrl, path2, { reply });
@@ -1774,6 +2929,7 @@ var TelegramRemote = async (input) => {
1774
2929
  pendingQuestions,
1775
2930
  pendingPermissions,
1776
2931
  pendingStartWorks,
2932
+ sessionRegistry,
1777
2933
  replyToQuestion,
1778
2934
  replyToPermission,
1779
2935
  runSessionCommand
@@ -1782,6 +2938,30 @@ var TelegramRemote = async (input) => {
1782
2938
  bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
1783
2939
  bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
1784
2940
  bot.setStartWorkDispatcher(createStartWorkDispatcher(ctx));
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
+ }));
2956
+ bot.setStartWorkCommandDispatcher(createStartWorkCommandDispatcher({
2957
+ snapshotStore,
2958
+ sessionTitleService,
2959
+ client: input.client,
2960
+ serverUrl: input.serverUrl.href,
2961
+ runSessionCommand,
2962
+ logger
2963
+ }));
2964
+ bot.setHelpDispatcher(createHelpDispatcher({ logger }));
1785
2965
  }
1786
2966
  return {
1787
2967
  event: async ({ event }) => {
@@ -1793,13 +2973,25 @@ var TelegramRemote = async (input) => {
1793
2973
  logger.info("session.status received", { statusType: event.properties.status.type });
1794
2974
  return handleSessionStatus(event, ctx);
1795
2975
  case "session.created":
2976
+ ctx.sessionTitleService.setServerUrl(event.properties.info.id, input.serverUrl.href);
1796
2977
  return handleSessionCreated(event, ctx);
1797
2978
  case "session.updated":
2979
+ ctx.sessionTitleService.setServerUrl(event.properties.info.id, input.serverUrl.href);
1798
2980
  return handleSessionUpdated(event, ctx);
1799
2981
  case "message.updated": {
1800
2982
  const messageAgent = getSessionAgentFromMessage(event);
1801
- if (messageAgent)
2983
+ if (messageAgent) {
2984
+ const previousAgent = ctx.sessionTitleService.getSessionAgent(messageAgent.sessionID);
1802
2985
  ctx.sessionTitleService.setSessionAgent(messageAgent.sessionID, messageAgent.agent);
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
+ }
2994
+ }
1803
2995
  return;
1804
2996
  }
1805
2997
  case "permission.updated":
@@ -1807,7 +2999,16 @@ var TelegramRemote = async (input) => {
1807
2999
  default: {
1808
3000
  const stepAgent = getSessionAgentFromNextStep(extEvent);
1809
3001
  if (stepAgent) {
3002
+ const previousAgent = ctx.sessionTitleService.getSessionAgent(stepAgent.sessionID);
1810
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
+ }
1811
3012
  return;
1812
3013
  }
1813
3014
  if (isEventPermissionAsked(extEvent)) {