@coinseeker/opencode-telegram-plugin 1.1.0 → 1.1.2
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 +4 -2
- package/dist/telegram-remote.js +562 -242
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,14 +15,16 @@ Configure the npm package in `~/.config/opencode/opencode.json`:
|
|
|
15
15
|
|
|
16
16
|
```json
|
|
17
17
|
{
|
|
18
|
-
"plugin": ["@coinseeker/opencode-telegram-plugin@1.1.
|
|
18
|
+
"plugin": ["@coinseeker/opencode-telegram-plugin@1.1.2"]
|
|
19
19
|
}
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Current stable version: `@coinseeker/opencode-telegram-plugin@1.1.
|
|
22
|
+
Current stable version: `@coinseeker/opencode-telegram-plugin@1.1.2`.
|
|
23
23
|
|
|
24
24
|
Restart OpenCode after editing the config. OpenCode resolves npm package plugins on startup.
|
|
25
25
|
|
|
26
|
+
To update an existing install, replace the previous pinned package entry with `@coinseeker/opencode-telegram-plugin@1.1.2`, keep the rest of the `plugin` array unchanged, and restart OpenCode.
|
|
27
|
+
|
|
26
28
|
## Configure Telegram
|
|
27
29
|
|
|
28
30
|
Create `~/.config/opencode/telegram-remote/.env`:
|
package/dist/telegram-remote.js
CHANGED
|
@@ -40,9 +40,10 @@ function pendingQuestionText(questions, questionIndex) {
|
|
|
40
40
|
|
|
41
41
|
// src/bot.ts
|
|
42
42
|
function createTelegramBot(opts) {
|
|
43
|
-
const { config, stateStore, logger
|
|
43
|
+
const { config, stateStore, logger } = opts;
|
|
44
44
|
const bot = new Bot(config.botToken);
|
|
45
45
|
let activeChatId = opts.initialChatId;
|
|
46
|
+
let pollingActive = false;
|
|
46
47
|
let questionDispatcher;
|
|
47
48
|
let permissionDispatcher;
|
|
48
49
|
let startWorkDispatcher;
|
|
@@ -51,103 +52,101 @@ function createTelegramBot(opts) {
|
|
|
51
52
|
let startWorkCommandDispatcher;
|
|
52
53
|
let helpDispatcher;
|
|
53
54
|
let managerObj;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
`\u2705 Chat connected!
|
|
55
|
+
bot.use(async (ctx, next) => {
|
|
56
|
+
const userId = ctx.from?.id;
|
|
57
|
+
if (!userId || !config.allowedUserIds.includes(userId)) {
|
|
58
|
+
logger.warn("unauthorized access attempt", { userId });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (ctx.chat?.type !== "private") return;
|
|
62
|
+
if (ctx.chat?.id) {
|
|
63
|
+
const newChatId = ctx.chat.id;
|
|
64
|
+
if (activeChatId !== newChatId) {
|
|
65
|
+
activeChatId = newChatId;
|
|
66
|
+
await stateStore.write({ chatId: newChatId, discoveredBy: process.pid });
|
|
67
|
+
logger.info("chat_id discovered", { chatId: newChatId });
|
|
68
|
+
await ctx.reply(
|
|
69
|
+
`\u2705 Chat connected!
|
|
70
70
|
|
|
71
71
|
Your chat_id: ${newChatId}
|
|
72
72
|
|
|
73
73
|
This chat is now active for OpenCode notifications.`
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
await next();
|
|
78
|
-
});
|
|
79
|
-
bot.catch((err) => {
|
|
80
|
-
const e = err.error;
|
|
81
|
-
if (e instanceof GrammyError && e.error_code === 409) {
|
|
82
|
-
logger.info("polling conflict (409) - another process took over", {
|
|
83
|
-
description: e.description
|
|
84
|
-
});
|
|
85
|
-
} else {
|
|
86
|
-
logger.error("bot error", { error: String(e) });
|
|
74
|
+
);
|
|
87
75
|
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
76
|
+
}
|
|
77
|
+
await next();
|
|
78
|
+
});
|
|
79
|
+
bot.catch((err) => {
|
|
80
|
+
const e = err.error;
|
|
81
|
+
if (e instanceof GrammyError && e.error_code === 409) {
|
|
82
|
+
logger.info("polling conflict (409) - another process took over", {
|
|
83
|
+
description: e.description
|
|
84
|
+
});
|
|
85
|
+
} else {
|
|
86
|
+
logger.error("bot error", { error: String(e) });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
bot.callbackQuery(/^q:([^:]+):(\d+):(\d+|c|d)$/, async (ctx) => {
|
|
90
|
+
await ctx.answerCallbackQuery();
|
|
91
|
+
const data = ctx.callbackQuery.data;
|
|
92
|
+
const messageId = ctx.callbackQuery.message?.message_id;
|
|
93
|
+
const chatId = ctx.chat?.id;
|
|
94
|
+
const userId = ctx.from?.id;
|
|
95
|
+
if (!questionDispatcher || messageId === void 0 || chatId === void 0 || userId === void 0)
|
|
96
|
+
return;
|
|
97
|
+
await questionDispatcher.handleCallbackQuery(data, messageId, chatId, userId);
|
|
98
|
+
});
|
|
99
|
+
bot.callbackQuery(/^p:([^:]+):(o|a|r)$/, async (ctx) => {
|
|
100
|
+
await ctx.answerCallbackQuery();
|
|
101
|
+
const data = ctx.callbackQuery.data;
|
|
102
|
+
const messageId = ctx.callbackQuery.message?.message_id;
|
|
103
|
+
if (!permissionDispatcher || messageId === void 0) return;
|
|
104
|
+
await permissionDispatcher.handleCallbackQuery(data, messageId);
|
|
105
|
+
});
|
|
106
|
+
bot.callbackQuery(/^sw:([^:]+)$/, async (ctx) => {
|
|
107
|
+
await ctx.answerCallbackQuery();
|
|
108
|
+
const data = ctx.callbackQuery.data;
|
|
109
|
+
const messageId = ctx.callbackQuery.message?.message_id;
|
|
110
|
+
if (!startWorkDispatcher || messageId === void 0) return;
|
|
111
|
+
await startWorkDispatcher.handleCallbackQuery(data, messageId);
|
|
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
|
+
});
|
|
143
|
+
bot.on("message:text", async (ctx) => {
|
|
144
|
+
const replyToMessageId = ctx.message.reply_to_message?.message_id;
|
|
145
|
+
const chatId = ctx.chat.id;
|
|
146
|
+
const userId = ctx.from?.id;
|
|
147
|
+
if (!questionDispatcher || replyToMessageId === void 0 || userId === void 0) return;
|
|
148
|
+
await questionDispatcher.handleTextReply(ctx.message.text, chatId, userId, replyToMessageId);
|
|
149
|
+
});
|
|
151
150
|
const requireChatId = async (action) => {
|
|
152
151
|
if (activeChatId) return activeChatId;
|
|
153
152
|
const state = await stateStore.read();
|
|
@@ -159,10 +158,8 @@ This chat is now active for OpenCode notifications.`
|
|
|
159
158
|
};
|
|
160
159
|
managerObj = {
|
|
161
160
|
async start() {
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
161
|
+
if (pollingActive) return;
|
|
162
|
+
pollingActive = true;
|
|
166
163
|
try {
|
|
167
164
|
await bot.api.setMyCommands([
|
|
168
165
|
{ command: "sessions", description: "\uD65C\uC131 \uC138\uC158 \uBAA9\uB85D (top 20)" },
|
|
@@ -173,22 +170,30 @@ This chat is now active for OpenCode notifications.`
|
|
|
173
170
|
} catch (err) {
|
|
174
171
|
logger.warn("setMyCommands failed", { error: String(err) });
|
|
175
172
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
173
|
+
try {
|
|
174
|
+
await bot.start({
|
|
175
|
+
drop_pending_updates: true,
|
|
176
|
+
onStart: () => {
|
|
177
|
+
logger.info("polling started");
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
pollingActive = false;
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
182
184
|
},
|
|
183
185
|
async stop() {
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
186
|
+
if (!pollingActive) return;
|
|
187
|
+
pollingActive = false;
|
|
188
|
+
try {
|
|
189
|
+
await bot.stop();
|
|
190
|
+
} catch (err) {
|
|
191
|
+
logger.warn("bot.stop() error", { error: String(err) });
|
|
190
192
|
}
|
|
191
193
|
},
|
|
194
|
+
isPolling() {
|
|
195
|
+
return pollingActive;
|
|
196
|
+
},
|
|
192
197
|
async sendMessage(text, options) {
|
|
193
198
|
const chatId = await requireChatId("sendMessage");
|
|
194
199
|
const result = await bot.api.sendMessage(chatId, text, options);
|
|
@@ -364,7 +369,7 @@ async function claimOnce(opts) {
|
|
|
364
369
|
|
|
365
370
|
// src/lib/pending-permissions.ts
|
|
366
371
|
import { createHash as createHash2 } from "crypto";
|
|
367
|
-
import { mkdir as mkdir2,
|
|
372
|
+
import { mkdir as mkdir2, readdir as readdir2, readFile, rename, unlink as unlink2, writeFile } from "fs/promises";
|
|
368
373
|
import { tmpdir } from "os";
|
|
369
374
|
import { dirname, join as join2 } from "path";
|
|
370
375
|
function hasCode2(err, code) {
|
|
@@ -375,13 +380,17 @@ function pendingFilePath(dir, shortHash) {
|
|
|
375
380
|
}
|
|
376
381
|
function parsePending(text) {
|
|
377
382
|
const parsed = JSON.parse(text);
|
|
378
|
-
if (typeof parsed.requestID !== "string")
|
|
379
|
-
|
|
383
|
+
if (typeof parsed.requestID !== "string")
|
|
384
|
+
throw new Error("Invalid pending permission: requestID");
|
|
385
|
+
if (typeof parsed.sessionID !== "string")
|
|
386
|
+
throw new Error("Invalid pending permission: sessionID");
|
|
380
387
|
if (typeof parsed.title !== "string") throw new Error("Invalid pending permission: title");
|
|
381
|
-
if (typeof parsed.permission !== "string")
|
|
388
|
+
if (typeof parsed.permission !== "string")
|
|
389
|
+
throw new Error("Invalid pending permission: permission");
|
|
382
390
|
if (!Array.isArray(parsed.patterns)) throw new Error("Invalid pending permission: patterns");
|
|
383
391
|
if (!Array.isArray(parsed.always)) throw new Error("Invalid pending permission: always");
|
|
384
|
-
if (parsed.endpoint !== "request" && parsed.endpoint !== "session")
|
|
392
|
+
if (parsed.endpoint !== "request" && parsed.endpoint !== "session")
|
|
393
|
+
throw new Error("Invalid pending permission: endpoint");
|
|
385
394
|
return parsed;
|
|
386
395
|
}
|
|
387
396
|
async function listPendingFiles(dir) {
|
|
@@ -422,11 +431,13 @@ function createPendingPermissionStore(opts) {
|
|
|
422
431
|
if (!(err instanceof Error) || !hasCode2(err, "ENOENT")) throw err;
|
|
423
432
|
}
|
|
424
433
|
},
|
|
425
|
-
async findByRequestID(requestID) {
|
|
434
|
+
async findByRequestID(requestID, sessionID, serverUrl) {
|
|
426
435
|
for (const fileName of await listPendingFiles(dir)) {
|
|
427
436
|
const shortHash = shortHashFromFileName(fileName);
|
|
428
437
|
const data = await this.loadPending(shortHash);
|
|
429
|
-
if (data?.requestID === requestID)
|
|
438
|
+
if (data?.requestID === requestID && (sessionID === void 0 || data.sessionID === sessionID) && (serverUrl === void 0 || data.serverUrl === serverUrl)) {
|
|
439
|
+
return { shortHash, data };
|
|
440
|
+
}
|
|
430
441
|
}
|
|
431
442
|
return void 0;
|
|
432
443
|
},
|
|
@@ -444,8 +455,9 @@ function createPendingPermissionStore(opts) {
|
|
|
444
455
|
}
|
|
445
456
|
};
|
|
446
457
|
}
|
|
447
|
-
function createPermissionShortHash(requestID) {
|
|
448
|
-
|
|
458
|
+
function createPermissionShortHash(requestID, sessionID, endpoint2, serverUrl) {
|
|
459
|
+
const source = sessionID === void 0 ? requestID : `${serverUrl ?? ""}:${endpoint2 ?? ""}:${sessionID}:${requestID}`;
|
|
460
|
+
return createHash2("sha256").update(source).digest("base64url").slice(0, 10);
|
|
449
461
|
}
|
|
450
462
|
|
|
451
463
|
// src/events/permission-updated.ts
|
|
@@ -467,7 +479,8 @@ function isEventPermissionAsked(event) {
|
|
|
467
479
|
}
|
|
468
480
|
function buildCallbackData(shortHash, reply) {
|
|
469
481
|
const data = `p:${shortHash}:${reply}`;
|
|
470
|
-
if (Buffer.byteLength(data, "utf8") > 64)
|
|
482
|
+
if (Buffer.byteLength(data, "utf8") > 64)
|
|
483
|
+
throw new Error("Telegram callback_data exceeds 64 bytes");
|
|
471
484
|
return data;
|
|
472
485
|
}
|
|
473
486
|
function normalizeUpdated(permission) {
|
|
@@ -479,8 +492,7 @@ function normalizeUpdated(permission) {
|
|
|
479
492
|
permission: permission.type,
|
|
480
493
|
patterns: pattern,
|
|
481
494
|
always: [],
|
|
482
|
-
endpoint: "session"
|
|
483
|
-
claimKey: `permission.updated:${permission.id}`
|
|
495
|
+
endpoint: "session"
|
|
484
496
|
};
|
|
485
497
|
}
|
|
486
498
|
function normalizeAsked(permission) {
|
|
@@ -491,8 +503,7 @@ function normalizeAsked(permission) {
|
|
|
491
503
|
permission: permission.permission,
|
|
492
504
|
patterns: permission.patterns,
|
|
493
505
|
always: permission.always,
|
|
494
|
-
endpoint: "request"
|
|
495
|
-
claimKey: `permission.asked:${permission.id}`
|
|
506
|
+
endpoint: "request"
|
|
496
507
|
};
|
|
497
508
|
}
|
|
498
509
|
function permissionMessage(permission, sessionTitle) {
|
|
@@ -527,9 +538,15 @@ function replyLabel(reply) {
|
|
|
527
538
|
return "Rejected";
|
|
528
539
|
}
|
|
529
540
|
async function handleNormalizedPermission(permission, ctx) {
|
|
530
|
-
const
|
|
541
|
+
const permissionKey = `${ctx.serverUrl.href}:${permission.sessionID}:${permission.requestID}`;
|
|
542
|
+
const claimed = await claimOnce({ claimsDir: ctx.claimsDir, key: `permission:${permissionKey}` });
|
|
531
543
|
if (!claimed) return;
|
|
532
|
-
const shortHash = createPermissionShortHash(
|
|
544
|
+
const shortHash = createPermissionShortHash(
|
|
545
|
+
permission.requestID,
|
|
546
|
+
permission.sessionID,
|
|
547
|
+
permission.endpoint,
|
|
548
|
+
ctx.serverUrl.href
|
|
549
|
+
);
|
|
533
550
|
const sentAt = Date.now();
|
|
534
551
|
const rawSessionTitle = ctx.sessionTitleService.getSessionTitle(permission.sessionID);
|
|
535
552
|
const sessionTitle = rawSessionTitle === null ? void 0 : rawSessionTitle;
|
|
@@ -555,11 +572,6 @@ async function handleNormalizedPermission(permission, ctx) {
|
|
|
555
572
|
ctx.logger.error("failed to send permission notification", { error: String(err) });
|
|
556
573
|
}
|
|
557
574
|
}
|
|
558
|
-
async function expirePending(ctx, shortHash, pending, messageId) {
|
|
559
|
-
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Permission request expired");
|
|
560
|
-
await ctx.pendingPermissions.deletePending(shortHash);
|
|
561
|
-
ctx.logger.info("pending permission expired", { requestID: pending.requestID });
|
|
562
|
-
}
|
|
563
575
|
async function handlePermissionUpdated(event, ctx) {
|
|
564
576
|
await handleNormalizedPermission(normalizeUpdated(event.properties), ctx);
|
|
565
577
|
}
|
|
@@ -583,7 +595,11 @@ function externalReplyLabel(value) {
|
|
|
583
595
|
async function handlePermissionReplied(event, ctx) {
|
|
584
596
|
const requestID = event.properties.requestID ?? event.properties.permissionID;
|
|
585
597
|
if (!requestID) return;
|
|
586
|
-
const found = await ctx.pendingPermissions.findByRequestID(
|
|
598
|
+
const found = await ctx.pendingPermissions.findByRequestID(
|
|
599
|
+
requestID,
|
|
600
|
+
event.properties.sessionID,
|
|
601
|
+
ctx.serverUrl.href
|
|
602
|
+
);
|
|
587
603
|
if (!found) return;
|
|
588
604
|
const label = externalReplyLabel(event.properties.reply ?? event.properties.response);
|
|
589
605
|
try {
|
|
@@ -619,10 +635,6 @@ function createPermissionDispatcher(ctx) {
|
|
|
619
635
|
await ctx.bot.editMessageRemoveKeyboard(messageId, "This permission request has expired.");
|
|
620
636
|
return;
|
|
621
637
|
}
|
|
622
|
-
if (pending.expiresAt < Date.now()) {
|
|
623
|
-
await expirePending(ctx, shortHash, pending, messageId);
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
638
|
try {
|
|
627
639
|
await ctx.replyToPermission(
|
|
628
640
|
pending.requestID,
|
|
@@ -631,13 +643,26 @@ function createPermissionDispatcher(ctx) {
|
|
|
631
643
|
pending.endpoint,
|
|
632
644
|
pending.serverUrl
|
|
633
645
|
);
|
|
634
|
-
await ctx.bot.editMessageRemoveKeyboard(
|
|
646
|
+
await ctx.bot.editMessageRemoveKeyboard(
|
|
647
|
+
messageId,
|
|
648
|
+
`\u2705 Permission ${replyLabel(reply)}
|
|
635
649
|
|
|
636
|
-
${pending.permission}: ${pending.title}`
|
|
637
|
-
|
|
650
|
+
${pending.permission}: ${pending.title}`
|
|
651
|
+
);
|
|
652
|
+
ctx.logger.info("permission reply sent", {
|
|
653
|
+
requestID: pending.requestID,
|
|
654
|
+
sessionID: pending.sessionID,
|
|
655
|
+
reply
|
|
656
|
+
});
|
|
638
657
|
} catch (err) {
|
|
639
|
-
await ctx.bot.editMessageRemoveKeyboard(
|
|
640
|
-
|
|
658
|
+
await ctx.bot.editMessageRemoveKeyboard(
|
|
659
|
+
messageId,
|
|
660
|
+
"\u26A0\uFE0F Failed to send permission reply to opencode"
|
|
661
|
+
);
|
|
662
|
+
ctx.logger.error("failed to send permission reply", {
|
|
663
|
+
error: String(err),
|
|
664
|
+
requestID: pending.requestID
|
|
665
|
+
});
|
|
641
666
|
} finally {
|
|
642
667
|
await ctx.pendingPermissions.deletePending(shortHash);
|
|
643
668
|
}
|
|
@@ -647,7 +672,7 @@ ${pending.permission}: ${pending.title}`);
|
|
|
647
672
|
|
|
648
673
|
// src/lib/pending-questions.ts
|
|
649
674
|
import { createHash as createHash3 } from "crypto";
|
|
650
|
-
import { mkdir as mkdir3,
|
|
675
|
+
import { mkdir as mkdir3, readdir as readdir3, readFile as readFile2, rename as rename2, unlink as unlink3, writeFile as writeFile2 } from "fs/promises";
|
|
651
676
|
import { tmpdir as tmpdir2 } from "os";
|
|
652
677
|
import { dirname as dirname2, join as join3 } from "path";
|
|
653
678
|
function hasCode3(err, code) {
|
|
@@ -661,8 +686,10 @@ function parsePending2(text) {
|
|
|
661
686
|
if (typeof parsed.requestID !== "string") throw new Error("Invalid pending question: requestID");
|
|
662
687
|
if (typeof parsed.sessionID !== "string") throw new Error("Invalid pending question: sessionID");
|
|
663
688
|
if (!Array.isArray(parsed.questions)) throw new Error("Invalid pending question: questions");
|
|
664
|
-
if (!Array.isArray(parsed.telegramMessageIds))
|
|
665
|
-
|
|
689
|
+
if (!Array.isArray(parsed.telegramMessageIds))
|
|
690
|
+
throw new Error("Invalid pending question: telegramMessageIds");
|
|
691
|
+
if (!Array.isArray(parsed.answersInProgress))
|
|
692
|
+
throw new Error("Invalid pending question: answersInProgress");
|
|
666
693
|
parsed.answersInProgress = parsed.answersInProgress.map((answer) => answer ?? null);
|
|
667
694
|
return parsed;
|
|
668
695
|
}
|
|
@@ -716,11 +743,13 @@ function createPendingQuestionStore(opts) {
|
|
|
716
743
|
}
|
|
717
744
|
return expired;
|
|
718
745
|
},
|
|
719
|
-
async findByRequestID(requestID) {
|
|
746
|
+
async findByRequestID(requestID, sessionID, serverUrl) {
|
|
720
747
|
for (const fileName of await listPendingFiles2(dir)) {
|
|
721
748
|
const shortHash = shortHashFromFileName2(fileName);
|
|
722
749
|
const data = await this.loadPending(shortHash);
|
|
723
|
-
if (data?.requestID === requestID)
|
|
750
|
+
if (data?.requestID === requestID && (sessionID === void 0 || data.sessionID === sessionID) && (serverUrl === void 0 || data.serverUrl === serverUrl)) {
|
|
751
|
+
return { shortHash, data };
|
|
752
|
+
}
|
|
724
753
|
}
|
|
725
754
|
return void 0;
|
|
726
755
|
},
|
|
@@ -729,14 +758,16 @@ function createPendingQuestionStore(opts) {
|
|
|
729
758
|
const shortHash = shortHashFromFileName2(fileName);
|
|
730
759
|
const data = await this.loadPending(shortHash);
|
|
731
760
|
const awaiting = data?.awaitingCustomFor;
|
|
732
|
-
if (awaiting && awaiting.chatId === chatId && awaiting.userId === userId)
|
|
761
|
+
if (awaiting && awaiting.chatId === chatId && awaiting.userId === userId)
|
|
762
|
+
return { shortHash, data };
|
|
733
763
|
}
|
|
734
764
|
return void 0;
|
|
735
765
|
}
|
|
736
766
|
};
|
|
737
767
|
}
|
|
738
|
-
function createQuestionShortHash(requestID) {
|
|
739
|
-
|
|
768
|
+
function createQuestionShortHash(requestID, sessionID, serverUrl) {
|
|
769
|
+
const source = sessionID === void 0 ? requestID : `${serverUrl ?? ""}:${sessionID}:${requestID}`;
|
|
770
|
+
return createHash3("sha256").update(source).digest("base64url").slice(0, 10);
|
|
740
771
|
}
|
|
741
772
|
|
|
742
773
|
// src/events/question-asked.ts
|
|
@@ -855,21 +886,16 @@ ${answerSummary(pending.questions, answers)}`
|
|
|
855
886
|
await ctx.pendingQuestions.deletePending(shortHash);
|
|
856
887
|
}
|
|
857
888
|
}
|
|
858
|
-
async function expirePending2(ctx, shortHash, pending, messageId) {
|
|
859
|
-
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 Question expired");
|
|
860
|
-
await ctx.pendingQuestions.deletePending(shortHash);
|
|
861
|
-
ctx.logger.info("pending question expired", { requestID: pending.requestID });
|
|
862
|
-
}
|
|
863
889
|
async function handleQuestionAsked(event, ctx) {
|
|
864
890
|
const request = event.properties;
|
|
865
891
|
if (request.questions.length === 0) return;
|
|
866
892
|
const claimed = await claimOnce({
|
|
867
893
|
claimsDir: ctx.claimsDir,
|
|
868
|
-
key: `question.
|
|
894
|
+
key: `question:${ctx.serverUrl.href}:${request.sessionID}:${request.id}`,
|
|
869
895
|
ttlMs: 5e3
|
|
870
896
|
});
|
|
871
897
|
if (!claimed) return;
|
|
872
|
-
const shortHash = createQuestionShortHash(request.id);
|
|
898
|
+
const shortHash = createQuestionShortHash(request.id, request.sessionID, ctx.serverUrl.href);
|
|
873
899
|
const firstQuestion = request.questions[0];
|
|
874
900
|
const sentAt = Date.now();
|
|
875
901
|
const pending = {
|
|
@@ -919,10 +945,6 @@ function createQuestionDispatcher(ctx) {
|
|
|
919
945
|
await ctx.bot.editMessageRemoveKeyboard(messageId, "This question has expired.");
|
|
920
946
|
return;
|
|
921
947
|
}
|
|
922
|
-
if (pending.expiresAt < Date.now()) {
|
|
923
|
-
await expirePending2(ctx, shortHash, pending, messageId);
|
|
924
|
-
return;
|
|
925
|
-
}
|
|
926
948
|
pending.expiresAt = Date.now() + QUESTION_EXPIRY_MS;
|
|
927
949
|
const question = pending.questions[questionIndex];
|
|
928
950
|
if (!question) return;
|
|
@@ -977,10 +999,6 @@ function createQuestionDispatcher(ctx) {
|
|
|
977
999
|
if (!match) return;
|
|
978
1000
|
const awaiting = match.data.awaitingCustomFor;
|
|
979
1001
|
if (!awaiting || awaiting.promptMessageId !== replyToMessageId) return;
|
|
980
|
-
if (match.data.expiresAt < Date.now()) {
|
|
981
|
-
await expirePending2(ctx, match.shortHash, match.data, match.data.telegramMessageIds[0]);
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
1002
|
match.data.expiresAt = Date.now() + QUESTION_EXPIRY_MS;
|
|
985
1003
|
const question = match.data.questions[awaiting.questionIndex];
|
|
986
1004
|
if (question?.multiple === true) {
|
|
@@ -1004,16 +1022,31 @@ function createQuestionDispatcher(ctx) {
|
|
|
1004
1022
|
function isEventQuestionReplied(event) {
|
|
1005
1023
|
if (event.type !== "question.replied") return false;
|
|
1006
1024
|
const props = event.properties;
|
|
1007
|
-
return Boolean(
|
|
1025
|
+
return Boolean(
|
|
1026
|
+
props && typeof props.requestID === "string" && typeof props.sessionID === "string"
|
|
1027
|
+
);
|
|
1008
1028
|
}
|
|
1009
1029
|
async function handleQuestionReplied(event, ctx) {
|
|
1010
|
-
const found = await ctx.pendingQuestions.findByRequestID(
|
|
1011
|
-
|
|
1030
|
+
const found = await ctx.pendingQuestions.findByRequestID(
|
|
1031
|
+
event.properties.requestID,
|
|
1032
|
+
event.properties.sessionID,
|
|
1033
|
+
ctx.serverUrl.href
|
|
1034
|
+
);
|
|
1035
|
+
if (!found) {
|
|
1036
|
+
ctx.logger.info("question.replied no pending match", {
|
|
1037
|
+
requestID: event.properties.requestID,
|
|
1038
|
+
sessionID: event.properties.sessionID
|
|
1039
|
+
});
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1012
1042
|
const messageId = found.data.telegramMessageIds[0];
|
|
1013
1043
|
try {
|
|
1014
1044
|
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u2705 Already answered in opencode.");
|
|
1015
1045
|
} catch (err) {
|
|
1016
|
-
ctx.logger.error("failed to edit externally answered question", {
|
|
1046
|
+
ctx.logger.error("failed to edit externally answered question", {
|
|
1047
|
+
error: String(err),
|
|
1048
|
+
requestID: event.properties.requestID
|
|
1049
|
+
});
|
|
1017
1050
|
} finally {
|
|
1018
1051
|
await ctx.pendingQuestions.deletePending(found.shortHash);
|
|
1019
1052
|
}
|
|
@@ -1476,7 +1509,7 @@ function createPendingStartWork(sessionID, title, serverUrl, telegramMessageId)
|
|
|
1476
1509
|
function startWorkShortHash(sessionID) {
|
|
1477
1510
|
return createStartWorkShortHash(sessionID);
|
|
1478
1511
|
}
|
|
1479
|
-
async function
|
|
1512
|
+
async function expirePending(ctx, shortHash, pending, messageId) {
|
|
1480
1513
|
await ctx.bot.editMessageRemoveKeyboard(messageId, "\u23F1 /start-work request expired");
|
|
1481
1514
|
await ctx.pendingStartWorks.deletePending(shortHash);
|
|
1482
1515
|
ctx.logger.info("pending start-work expired", { sessionID: pending.sessionID });
|
|
@@ -1493,7 +1526,7 @@ function createStartWorkDispatcher(ctx) {
|
|
|
1493
1526
|
return;
|
|
1494
1527
|
}
|
|
1495
1528
|
if (pending.expiresAt < Date.now()) {
|
|
1496
|
-
await
|
|
1529
|
+
await expirePending(ctx, shortHash, pending, messageId);
|
|
1497
1530
|
return;
|
|
1498
1531
|
}
|
|
1499
1532
|
try {
|
|
@@ -1527,7 +1560,7 @@ var ROOT_IDLE_RECHECK_DELAY_MS = 2500;
|
|
|
1527
1560
|
var DEFERRED_PARENT_CONFIRM_DELAY_MS = 2500;
|
|
1528
1561
|
var deferredConfirmTimers = /* @__PURE__ */ new Map();
|
|
1529
1562
|
function sleep(ms) {
|
|
1530
|
-
return new Promise((
|
|
1563
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1531
1564
|
}
|
|
1532
1565
|
function agentFinishedMessage(title, agent) {
|
|
1533
1566
|
const base = title ? `Agent has finished: ${title}` : "Agent has finished.";
|
|
@@ -1880,48 +1913,170 @@ ${body}
|
|
|
1880
1913
|
|
|
1881
1914
|
// src/lib/plan-readiness.ts
|
|
1882
1915
|
import { access, readFile as readFile5, readdir as readdir6, stat as stat2 } from "fs/promises";
|
|
1883
|
-
import { join as join6 } from "path";
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1916
|
+
import { basename, isAbsolute, join as join6, relative, resolve } from "path";
|
|
1917
|
+
function asRecord2(value) {
|
|
1918
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
1919
|
+
return value;
|
|
1920
|
+
}
|
|
1921
|
+
function stringArray(value) {
|
|
1922
|
+
if (!Array.isArray(value)) return [];
|
|
1923
|
+
return value.filter((item) => typeof item === "string");
|
|
1924
|
+
}
|
|
1925
|
+
function optionalString(value) {
|
|
1926
|
+
return typeof value === "string" ? value : void 0;
|
|
1927
|
+
}
|
|
1928
|
+
function normalizeBoulderWork(value) {
|
|
1929
|
+
const record = asRecord2(value);
|
|
1930
|
+
if (!record || typeof record.active_plan !== "string") return void 0;
|
|
1931
|
+
const work = {
|
|
1932
|
+
activePlan: record.active_plan,
|
|
1933
|
+
sessionIds: stringArray(record.session_ids)
|
|
1934
|
+
};
|
|
1935
|
+
const planName = optionalString(record.plan_name);
|
|
1936
|
+
if (planName !== void 0) work.planName = planName;
|
|
1937
|
+
const status = optionalString(record.status);
|
|
1938
|
+
if (status !== void 0) work.status = status;
|
|
1939
|
+
const startedAt = optionalString(record.started_at);
|
|
1940
|
+
if (startedAt !== void 0) work.startedAt = startedAt;
|
|
1941
|
+
const updatedAt = optionalString(record.updated_at);
|
|
1942
|
+
if (updatedAt !== void 0) work.updatedAt = updatedAt;
|
|
1943
|
+
const worktreePath = optionalString(record.worktree_path);
|
|
1944
|
+
if (worktreePath !== void 0) work.worktreePath = worktreePath;
|
|
1945
|
+
return work;
|
|
1946
|
+
}
|
|
1947
|
+
function normalizeBoulderState(value) {
|
|
1948
|
+
const record = asRecord2(value);
|
|
1949
|
+
if (!record) return void 0;
|
|
1950
|
+
const state = { sessionIds: stringArray(record.session_ids) };
|
|
1951
|
+
const activePlan = optionalString(record.active_plan);
|
|
1952
|
+
if (activePlan !== void 0) state.activePlan = activePlan;
|
|
1953
|
+
const planName = optionalString(record.plan_name);
|
|
1954
|
+
if (planName !== void 0) state.planName = planName;
|
|
1955
|
+
const status = optionalString(record.status);
|
|
1956
|
+
if (status !== void 0) state.status = status;
|
|
1957
|
+
const startedAt = optionalString(record.started_at);
|
|
1958
|
+
if (startedAt !== void 0) state.startedAt = startedAt;
|
|
1959
|
+
const updatedAt = optionalString(record.updated_at);
|
|
1960
|
+
if (updatedAt !== void 0) state.updatedAt = updatedAt;
|
|
1961
|
+
const worktreePath = optionalString(record.worktree_path);
|
|
1962
|
+
if (worktreePath !== void 0) state.worktreePath = worktreePath;
|
|
1963
|
+
const activeWorkId = optionalString(record.active_work_id);
|
|
1964
|
+
if (activeWorkId !== void 0) state.activeWorkId = activeWorkId;
|
|
1965
|
+
const worksRecord = asRecord2(record.works);
|
|
1966
|
+
if (worksRecord) {
|
|
1967
|
+
const works = {};
|
|
1968
|
+
for (const [workId, rawWork] of Object.entries(worksRecord)) {
|
|
1969
|
+
const work = normalizeBoulderWork(rawWork);
|
|
1970
|
+
if (work) works[workId] = work;
|
|
1971
|
+
}
|
|
1972
|
+
if (Object.keys(works).length > 0) state.works = works;
|
|
1973
|
+
}
|
|
1974
|
+
return state;
|
|
1975
|
+
}
|
|
1976
|
+
async function readBoulderState(boulderPath) {
|
|
1977
|
+
let text;
|
|
1889
1978
|
try {
|
|
1890
|
-
await
|
|
1979
|
+
text = await readFile5(boulderPath, "utf8");
|
|
1980
|
+
} catch (err) {
|
|
1981
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
1982
|
+
return { exists: false };
|
|
1983
|
+
}
|
|
1984
|
+
return { exists: true };
|
|
1985
|
+
}
|
|
1986
|
+
try {
|
|
1987
|
+
return { exists: true, state: normalizeBoulderState(JSON.parse(text)) };
|
|
1891
1988
|
} catch {
|
|
1892
|
-
return {
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1989
|
+
return { exists: true };
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
function mirrorWorkFromState(state) {
|
|
1993
|
+
if (!state.activePlan) return void 0;
|
|
1994
|
+
const work = {
|
|
1995
|
+
activePlan: state.activePlan,
|
|
1996
|
+
sessionIds: state.sessionIds
|
|
1997
|
+
};
|
|
1998
|
+
if (state.planName !== void 0) work.planName = state.planName;
|
|
1999
|
+
if (state.status !== void 0) work.status = state.status;
|
|
2000
|
+
if (state.startedAt !== void 0) work.startedAt = state.startedAt;
|
|
2001
|
+
if (state.updatedAt !== void 0) work.updatedAt = state.updatedAt;
|
|
2002
|
+
if (state.worktreePath !== void 0) work.worktreePath = state.worktreePath;
|
|
2003
|
+
return work;
|
|
2004
|
+
}
|
|
2005
|
+
function boulderWorks(state) {
|
|
2006
|
+
if (state.works) return Object.values(state.works);
|
|
2007
|
+
const mirrorWork = mirrorWorkFromState(state);
|
|
2008
|
+
return mirrorWork ? [mirrorWork] : [];
|
|
2009
|
+
}
|
|
2010
|
+
function parseIsoToMs(value) {
|
|
2011
|
+
if (!value) return 0;
|
|
2012
|
+
const ms = Date.parse(value);
|
|
2013
|
+
return Number.isNaN(ms) ? 0 : ms;
|
|
2014
|
+
}
|
|
2015
|
+
function findBoulderWorkForSession(state, sessionId) {
|
|
2016
|
+
const works = boulderWorks(state).filter((work) => work.sessionIds.includes(sessionId)).sort(
|
|
2017
|
+
(left, right) => parseIsoToMs(right.updatedAt ?? right.startedAt) - parseIsoToMs(left.updatedAt ?? left.startedAt)
|
|
2018
|
+
);
|
|
2019
|
+
if (works[0]) return works[0];
|
|
2020
|
+
const mirrorWork = mirrorWorkFromState(state);
|
|
2021
|
+
if (mirrorWork && state.sessionIds.includes(sessionId)) return mirrorWork;
|
|
2022
|
+
return void 0;
|
|
2023
|
+
}
|
|
2024
|
+
function isActiveBoulderWork(work) {
|
|
2025
|
+
return work.status !== "completed" && work.status !== "abandoned";
|
|
2026
|
+
}
|
|
2027
|
+
function resolveTrackedPath(baseDirectory, trackedPath) {
|
|
2028
|
+
return isAbsolute(trackedPath) ? resolve(trackedPath) : resolve(baseDirectory, trackedPath);
|
|
2029
|
+
}
|
|
2030
|
+
async function resolveBoulderPlanPath(projectRoot, work) {
|
|
2031
|
+
const absolutePlanPath = resolveTrackedPath(projectRoot, work.activePlan);
|
|
2032
|
+
const worktreePath = work.worktreePath?.trim();
|
|
2033
|
+
if (!worktreePath) return absolutePlanPath;
|
|
2034
|
+
const relativePlanPath = relative(resolve(projectRoot), absolutePlanPath);
|
|
2035
|
+
if (relativePlanPath.length === 0 || relativePlanPath.startsWith("..") || isAbsolute(relativePlanPath)) {
|
|
2036
|
+
return absolutePlanPath;
|
|
1897
2037
|
}
|
|
2038
|
+
const worktreePlanPath = resolve(resolveTrackedPath(projectRoot, worktreePath), relativePlanPath);
|
|
1898
2039
|
try {
|
|
1899
|
-
await access(
|
|
1900
|
-
return
|
|
1901
|
-
ready: false,
|
|
1902
|
-
reason: "boulder-active",
|
|
1903
|
-
detail: `${boulderPath} exists`
|
|
1904
|
-
};
|
|
2040
|
+
await access(worktreePlanPath);
|
|
2041
|
+
return worktreePlanPath;
|
|
1905
2042
|
} catch {
|
|
2043
|
+
return absolutePlanPath;
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
function planNameFromPath(planPath) {
|
|
2047
|
+
return basename(planPath, ".md");
|
|
2048
|
+
}
|
|
2049
|
+
function normalizePlanToken(value) {
|
|
2050
|
+
return value.normalize("NFKD").toLowerCase().replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9가-힣]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2051
|
+
}
|
|
2052
|
+
function selectPlanByHint(candidates, planHint) {
|
|
2053
|
+
if (!planHint) return void 0;
|
|
2054
|
+
const normalizedHint = normalizePlanToken(planHint);
|
|
2055
|
+
if (!normalizedHint) return void 0;
|
|
2056
|
+
return candidates.find((candidate) => {
|
|
2057
|
+
const planName = candidate.name.replace(/\.md$/, "");
|
|
2058
|
+
return normalizePlanToken(planName) === normalizedHint;
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
function resolvePlanPathHint(projectRoot, planPath) {
|
|
2062
|
+
if (!planPath) return void 0;
|
|
2063
|
+
const resolvedPath = isAbsolute(planPath) ? resolve(planPath) : resolve(projectRoot, planPath);
|
|
2064
|
+
const plansRoot = resolve(projectRoot, ".omo", "plans");
|
|
2065
|
+
const relativePlanPath = relative(plansRoot, resolvedPath);
|
|
2066
|
+
if (!resolvedPath.endsWith(".md") || relativePlanPath.length === 0 || relativePlanPath.startsWith("..") || isAbsolute(relativePlanPath)) {
|
|
2067
|
+
return void 0;
|
|
1906
2068
|
}
|
|
2069
|
+
return resolvedPath;
|
|
2070
|
+
}
|
|
2071
|
+
async function getPlanFiles(plansDir) {
|
|
1907
2072
|
let planFiles = [];
|
|
1908
2073
|
try {
|
|
1909
2074
|
const entries = await readdir6(plansDir);
|
|
1910
2075
|
planFiles = entries.filter((e) => e.endsWith(".md"));
|
|
1911
2076
|
} catch {
|
|
1912
|
-
return
|
|
1913
|
-
ready: false,
|
|
1914
|
-
reason: "no-plans",
|
|
1915
|
-
detail: `${plansDir} not found or empty`
|
|
1916
|
-
};
|
|
1917
|
-
}
|
|
1918
|
-
if (planFiles.length === 0) {
|
|
1919
|
-
return {
|
|
1920
|
-
ready: false,
|
|
1921
|
-
reason: "no-plans",
|
|
1922
|
-
detail: `No .md files in ${plansDir}`
|
|
1923
|
-
};
|
|
2077
|
+
return void 0;
|
|
1924
2078
|
}
|
|
2079
|
+
if (planFiles.length === 0) return [];
|
|
1925
2080
|
const stats = await Promise.all(
|
|
1926
2081
|
planFiles.map(async (f) => {
|
|
1927
2082
|
const full = join6(plansDir, f);
|
|
@@ -1929,9 +2084,20 @@ async function checkPlanReadiness(args) {
|
|
|
1929
2084
|
return { path: full, name: f, mtime: s.mtime.getTime() };
|
|
1930
2085
|
})
|
|
1931
2086
|
);
|
|
1932
|
-
stats.sort((a, b) => b.mtime - a.mtime);
|
|
1933
|
-
|
|
1934
|
-
|
|
2087
|
+
return stats.sort((a, b) => b.mtime - a.mtime);
|
|
2088
|
+
}
|
|
2089
|
+
async function readPlanProgress(planPath, planName, boulderActive = false) {
|
|
2090
|
+
let content;
|
|
2091
|
+
try {
|
|
2092
|
+
content = await readFile5(planPath, "utf8");
|
|
2093
|
+
} catch {
|
|
2094
|
+
return {
|
|
2095
|
+
ready: false,
|
|
2096
|
+
reason: "no-plans",
|
|
2097
|
+
detail: `${planPath} not found`,
|
|
2098
|
+
...boulderActive ? { boulderActive } : {}
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
1935
2101
|
const totalMatches = content.match(/^- \[[ xX]\]/gm) ?? [];
|
|
1936
2102
|
const completedMatches = content.match(/^- \[[xX]\]/gm) ?? [];
|
|
1937
2103
|
const total = totalMatches.length;
|
|
@@ -1940,24 +2106,99 @@ async function checkPlanReadiness(args) {
|
|
|
1940
2106
|
return {
|
|
1941
2107
|
ready: false,
|
|
1942
2108
|
reason: "plan-empty",
|
|
1943
|
-
detail: `${
|
|
2109
|
+
detail: `${planName}: no checkboxes found`,
|
|
2110
|
+
...boulderActive ? { boulderActive } : {}
|
|
1944
2111
|
};
|
|
1945
2112
|
}
|
|
1946
2113
|
if (completed >= total) {
|
|
1947
2114
|
return {
|
|
1948
2115
|
ready: false,
|
|
1949
2116
|
reason: "all-plans-complete",
|
|
1950
|
-
detail: `${
|
|
2117
|
+
detail: `${planName}: ${completed}/${total} complete`,
|
|
2118
|
+
...boulderActive ? { boulderActive } : {}
|
|
1951
2119
|
};
|
|
1952
2120
|
}
|
|
1953
2121
|
return {
|
|
1954
2122
|
ready: true,
|
|
1955
|
-
planPath
|
|
1956
|
-
planName
|
|
2123
|
+
planPath,
|
|
2124
|
+
planName,
|
|
1957
2125
|
total,
|
|
1958
|
-
completed
|
|
2126
|
+
completed,
|
|
2127
|
+
...boulderActive ? { boulderActive } : {}
|
|
1959
2128
|
};
|
|
1960
2129
|
}
|
|
2130
|
+
async function checkPlanReadiness(args) {
|
|
2131
|
+
const { projectRoot, sessionId } = args;
|
|
2132
|
+
const allowLatestFallback = args.allowLatestFallback ?? sessionId === void 0;
|
|
2133
|
+
const omoDir = join6(projectRoot, ".omo");
|
|
2134
|
+
const plansDir = join6(omoDir, "plans");
|
|
2135
|
+
const boulderPath = join6(omoDir, "boulder.json");
|
|
2136
|
+
try {
|
|
2137
|
+
await access(omoDir);
|
|
2138
|
+
} catch {
|
|
2139
|
+
return {
|
|
2140
|
+
ready: false,
|
|
2141
|
+
reason: "no-omo-dir",
|
|
2142
|
+
detail: `${omoDir} does not exist`
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
const boulder = await readBoulderState(boulderPath);
|
|
2146
|
+
const projectBoulderActive = boulder.exists;
|
|
2147
|
+
if (boulder.exists && sessionId === void 0) {
|
|
2148
|
+
return {
|
|
2149
|
+
ready: false,
|
|
2150
|
+
reason: "boulder-active",
|
|
2151
|
+
detail: `${boulderPath} exists`,
|
|
2152
|
+
boulderActive: true
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
if (boulder.state && sessionId !== void 0) {
|
|
2156
|
+
const work = findBoulderWorkForSession(boulder.state, sessionId);
|
|
2157
|
+
if (work) {
|
|
2158
|
+
const planPath = await resolveBoulderPlanPath(projectRoot, work);
|
|
2159
|
+
return readPlanProgress(
|
|
2160
|
+
planPath,
|
|
2161
|
+
work.planName ?? planNameFromPath(planPath),
|
|
2162
|
+
isActiveBoulderWork(work)
|
|
2163
|
+
);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
const explicitPlanPath = resolvePlanPathHint(projectRoot, args.planPath);
|
|
2167
|
+
if (explicitPlanPath) {
|
|
2168
|
+
return readPlanProgress(explicitPlanPath, planNameFromPath(explicitPlanPath), projectBoulderActive);
|
|
2169
|
+
}
|
|
2170
|
+
const stats = await getPlanFiles(plansDir);
|
|
2171
|
+
if (stats === void 0) {
|
|
2172
|
+
return {
|
|
2173
|
+
ready: false,
|
|
2174
|
+
reason: "no-plans",
|
|
2175
|
+
detail: `${plansDir} not found or empty`,
|
|
2176
|
+
...projectBoulderActive ? { boulderActive: true } : {}
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
if (stats.length === 0) {
|
|
2180
|
+
return {
|
|
2181
|
+
ready: false,
|
|
2182
|
+
reason: "no-plans",
|
|
2183
|
+
detail: `No .md files in ${plansDir}`,
|
|
2184
|
+
...projectBoulderActive ? { boulderActive: true } : {}
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
const hinted = selectPlanByHint(stats, args.planHint);
|
|
2188
|
+
if (hinted) {
|
|
2189
|
+
return readPlanProgress(hinted.path, hinted.name.replace(/\.md$/, ""), projectBoulderActive);
|
|
2190
|
+
}
|
|
2191
|
+
if (!allowLatestFallback) {
|
|
2192
|
+
return {
|
|
2193
|
+
ready: false,
|
|
2194
|
+
reason: "no-session-plan",
|
|
2195
|
+
detail: `No plan associated with session ${sessionId ?? "missing"}`,
|
|
2196
|
+
...projectBoulderActive ? { boulderActive: true } : {}
|
|
2197
|
+
};
|
|
2198
|
+
}
|
|
2199
|
+
const latest = stats[0];
|
|
2200
|
+
return readPlanProgress(latest.path, latest.name.replace(/\.md$/, ""), projectBoulderActive);
|
|
2201
|
+
}
|
|
1961
2202
|
async function recheckSessionIdle(client, sessionId) {
|
|
1962
2203
|
const result = await client.session.status();
|
|
1963
2204
|
const statuses = result.data ?? {};
|
|
@@ -2019,6 +2260,8 @@ function planReadinessKorean(result) {
|
|
|
2019
2260
|
}
|
|
2020
2261
|
case "boulder-active":
|
|
2021
2262
|
return "boulder \uD65C\uC131";
|
|
2263
|
+
case "no-session-plan":
|
|
2264
|
+
return "\uC138\uC158 \uC5F0\uACB0 plan \uC5C6\uC74C";
|
|
2022
2265
|
}
|
|
2023
2266
|
}
|
|
2024
2267
|
function planLine(result) {
|
|
@@ -2028,14 +2271,14 @@ function planLine(result) {
|
|
|
2028
2271
|
return `<b>\uD50C\uB79C \uC0C1\uD0DC</b>: ${planReadinessKorean(result)}`;
|
|
2029
2272
|
}
|
|
2030
2273
|
function boulderLine(result) {
|
|
2031
|
-
const active = !result.ready && result.reason === "boulder-active";
|
|
2274
|
+
const active = result.boulderActive === true || !result.ready && result.reason === "boulder-active";
|
|
2032
2275
|
return active ? "<b>Boulder</b>: \uD65C\uC131" : "<b>Boulder</b>: \uC5C6\uC74C";
|
|
2033
2276
|
}
|
|
2034
2277
|
function createStatusDispatcher(deps) {
|
|
2035
2278
|
return async ({ chatId, bot, args }) => {
|
|
2036
2279
|
const rawN = args[0];
|
|
2037
2280
|
if (rawN === void 0 || rawN === "") {
|
|
2038
|
-
await bot.sendMessage("\uC0AC\uC6A9\uBC95: /status
|
|
2281
|
+
await bot.sendMessage("\uC0AC\uC6A9\uBC95: /status <\uBC88\uD638>. \uBA3C\uC800 /sessions \uB85C \uBAA9\uB85D \uD655\uC778", {
|
|
2039
2282
|
parse_mode: "HTML"
|
|
2040
2283
|
});
|
|
2041
2284
|
return;
|
|
@@ -2077,7 +2320,11 @@ function createStatusDispatcher(deps) {
|
|
|
2077
2320
|
let messages = [];
|
|
2078
2321
|
if (sourceServerUrl && useRemoteServer) {
|
|
2079
2322
|
try {
|
|
2080
|
-
const getResult = await getRemoteSession(
|
|
2323
|
+
const getResult = await getRemoteSession(
|
|
2324
|
+
sourceServerUrl,
|
|
2325
|
+
entry.sessionId,
|
|
2326
|
+
deps.opencodeFetch
|
|
2327
|
+
);
|
|
2081
2328
|
session = getResult.data;
|
|
2082
2329
|
responseStatus = getResult.response.status;
|
|
2083
2330
|
if (!session || responseStatus === 404) {
|
|
@@ -2096,7 +2343,11 @@ function createStatusDispatcher(deps) {
|
|
|
2096
2343
|
await bot.sendMessage("\uC138\uC158 \uC0C1\uD0DC\uB97C \uBD88\uB7EC\uC624\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. /sessions \uC7AC\uC2E4\uD589 \uD544\uC694", {
|
|
2097
2344
|
parse_mode: "HTML"
|
|
2098
2345
|
});
|
|
2099
|
-
deps.logger.error("status remote lookup failed", {
|
|
2346
|
+
deps.logger.error("status remote lookup failed", {
|
|
2347
|
+
chatId,
|
|
2348
|
+
sessionId: entry.sessionId,
|
|
2349
|
+
error: String(err)
|
|
2350
|
+
});
|
|
2100
2351
|
return;
|
|
2101
2352
|
}
|
|
2102
2353
|
} else {
|
|
@@ -2121,11 +2372,18 @@ function createStatusDispatcher(deps) {
|
|
|
2121
2372
|
return;
|
|
2122
2373
|
}
|
|
2123
2374
|
const projectRoot = resolveProjectRoot(session);
|
|
2124
|
-
const
|
|
2375
|
+
const rawTitle = session.title ?? entry.title;
|
|
2376
|
+
const rawAgent = entry.agent ?? session.agent;
|
|
2377
|
+
const planReady = await checkPlanReadiness({
|
|
2378
|
+
projectRoot,
|
|
2379
|
+
sessionId: entry.sessionId,
|
|
2380
|
+
planHint: rawTitle,
|
|
2381
|
+
allowLatestFallback: rawAgent === "plan"
|
|
2382
|
+
});
|
|
2125
2383
|
const userSnippet = buildSnippet(findLastByRole(messages, "user"));
|
|
2126
2384
|
const assistantSnippet = buildSnippet(findLastByRole(messages, "assistant"));
|
|
2127
|
-
const title = escapeHtml(
|
|
2128
|
-
const agent =
|
|
2385
|
+
const title = escapeHtml(rawTitle ?? "");
|
|
2386
|
+
const agent = rawAgent ? escapeHtml(rawAgent) : "?";
|
|
2129
2387
|
const text = [
|
|
2130
2388
|
`<b>\uC138\uC158 #${n}</b>: ${title}`,
|
|
2131
2389
|
`\uC5D0\uC774\uC804\uD2B8: ${agent}`,
|
|
@@ -2167,6 +2425,8 @@ function readinessMessage(reason) {
|
|
|
2167
2425
|
return "plan \uC758 \uBAA8\uB4E0 task \uAC00 \uC644\uB8CC\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uC0C8 plan \uC791\uC131 \uD544\uC694";
|
|
2168
2426
|
case "boulder-active":
|
|
2169
2427
|
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";
|
|
2428
|
+
case "no-session-plan":
|
|
2429
|
+
return "\uD574\uB2F9 \uC138\uC158\uACFC \uC5F0\uACB0\uB41C plan \uC774 \uC5C6\uC2B5\uB2C8\uB2E4";
|
|
2170
2430
|
}
|
|
2171
2431
|
}
|
|
2172
2432
|
function isSessionNotFoundError(err) {
|
|
@@ -2331,7 +2591,7 @@ function loadPluginEnv(opts) {
|
|
|
2331
2591
|
}
|
|
2332
2592
|
|
|
2333
2593
|
// src/lib/lock.ts
|
|
2334
|
-
import { open as open2, readFile as readFile6, stat as stat3, unlink as unlink6 } from "fs/promises";
|
|
2594
|
+
import { open as open2, readFile as readFile6, stat as stat3, unlink as unlink6, utimes } from "fs/promises";
|
|
2335
2595
|
import { hostname } from "os";
|
|
2336
2596
|
var DEFAULT_TTL_MS2 = 5 * 60 * 1e3;
|
|
2337
2597
|
function hasCode6(err, code) {
|
|
@@ -2377,6 +2637,18 @@ async function createLock(lockPath, pid) {
|
|
|
2377
2637
|
await unlink6(lockPath);
|
|
2378
2638
|
} catch {
|
|
2379
2639
|
}
|
|
2640
|
+
},
|
|
2641
|
+
async refresh() {
|
|
2642
|
+
if (released) return false;
|
|
2643
|
+
try {
|
|
2644
|
+
const data2 = parseLockData(await readFile6(lockPath, "utf8"));
|
|
2645
|
+
if (!data2 || data2.pid !== pid || data2.hostname !== hostname()) return false;
|
|
2646
|
+
const now = /* @__PURE__ */ new Date();
|
|
2647
|
+
await utimes(lockPath, now, now);
|
|
2648
|
+
return true;
|
|
2649
|
+
} catch {
|
|
2650
|
+
return false;
|
|
2651
|
+
}
|
|
2380
2652
|
}
|
|
2381
2653
|
};
|
|
2382
2654
|
}
|
|
@@ -2420,7 +2692,11 @@ async function acquireLock(opts) {
|
|
|
2420
2692
|
try {
|
|
2421
2693
|
await unlink6(opts.lockPath);
|
|
2422
2694
|
} catch {
|
|
2423
|
-
return {
|
|
2695
|
+
return {
|
|
2696
|
+
acquired: false,
|
|
2697
|
+
reason: "failed to remove stale lock",
|
|
2698
|
+
ownerPid: existing.ownerPid
|
|
2699
|
+
};
|
|
2424
2700
|
}
|
|
2425
2701
|
}
|
|
2426
2702
|
}
|
|
@@ -2865,11 +3141,14 @@ var TelegramRemote = async (input) => {
|
|
|
2865
3141
|
const pendingPermissions = createPendingPermissionStore({ tokenHash });
|
|
2866
3142
|
const pendingStartWorks = createPendingStartWorkStore({ tokenHash });
|
|
2867
3143
|
const lockResult = await acquireLock({ lockPath });
|
|
2868
|
-
const
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
3144
|
+
const leadership = { isLeader: false };
|
|
3145
|
+
if (lockResult.acquired) {
|
|
3146
|
+
leadership.isLeader = true;
|
|
3147
|
+
leadership.handle = lockResult.handle;
|
|
3148
|
+
logger.info("lock acquired - leader mode");
|
|
3149
|
+
} else {
|
|
3150
|
+
logger.info("lock held by other - pass-through mode", { reason: lockResult.reason });
|
|
3151
|
+
}
|
|
2873
3152
|
logger.info("server url", {
|
|
2874
3153
|
url: input.serverUrl.toString(),
|
|
2875
3154
|
href: input.serverUrl.href,
|
|
@@ -2934,21 +3213,57 @@ var TelegramRemote = async (input) => {
|
|
|
2934
3213
|
config,
|
|
2935
3214
|
stateStore,
|
|
2936
3215
|
logger,
|
|
2937
|
-
initialChatId: initialState.chatId ?? config.chatId
|
|
2938
|
-
polling: isLeader
|
|
3216
|
+
initialChatId: initialState.chatId ?? config.chatId
|
|
2939
3217
|
});
|
|
2940
|
-
|
|
2941
|
-
bot.start().catch((err) => {
|
|
3218
|
+
const startLeaderPolling = () => {
|
|
3219
|
+
bot.start().catch(async (err) => {
|
|
2942
3220
|
logger.error("bot polling stopped", { error: String(err) });
|
|
3221
|
+
leadership.isLeader = false;
|
|
3222
|
+
if (leadership.handle) {
|
|
3223
|
+
await leadership.handle.release();
|
|
3224
|
+
leadership.handle = void 0;
|
|
3225
|
+
}
|
|
2943
3226
|
});
|
|
2944
|
-
}
|
|
3227
|
+
};
|
|
3228
|
+
let electionRunning = false;
|
|
3229
|
+
const runElection = async () => {
|
|
3230
|
+
if (electionRunning) return;
|
|
3231
|
+
electionRunning = true;
|
|
3232
|
+
try {
|
|
3233
|
+
if (leadership.isLeader && leadership.handle) {
|
|
3234
|
+
if (await leadership.handle.refresh()) return;
|
|
3235
|
+
leadership.isLeader = false;
|
|
3236
|
+
leadership.handle = void 0;
|
|
3237
|
+
await bot.stop();
|
|
3238
|
+
logger.info("leadership lost - demoted to pass-through");
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
if (bot.isPolling()) return;
|
|
3242
|
+
const result = await acquireLock({ lockPath });
|
|
3243
|
+
if (result.acquired) {
|
|
3244
|
+
leadership.isLeader = true;
|
|
3245
|
+
leadership.handle = result.handle;
|
|
3246
|
+
logger.info("leadership acquired - promoting to leader");
|
|
3247
|
+
startLeaderPolling();
|
|
3248
|
+
}
|
|
3249
|
+
} catch (err) {
|
|
3250
|
+
logger.warn("election cycle failed", { error: String(err) });
|
|
3251
|
+
} finally {
|
|
3252
|
+
electionRunning = false;
|
|
3253
|
+
}
|
|
3254
|
+
};
|
|
3255
|
+
const electionTimer = setInterval(() => {
|
|
3256
|
+
void runElection();
|
|
3257
|
+
}, 3e4);
|
|
3258
|
+
if (typeof electionTimer.unref === "function") electionTimer.unref();
|
|
2945
3259
|
const cleanup = async () => {
|
|
3260
|
+
clearInterval(electionTimer);
|
|
2946
3261
|
try {
|
|
2947
3262
|
await bot.stop();
|
|
2948
3263
|
} catch {
|
|
2949
3264
|
}
|
|
2950
|
-
if (
|
|
2951
|
-
await
|
|
3265
|
+
if (leadership.handle) {
|
|
3266
|
+
await leadership.handle.release();
|
|
2952
3267
|
}
|
|
2953
3268
|
await logger.close();
|
|
2954
3269
|
};
|
|
@@ -2980,34 +3295,41 @@ var TelegramRemote = async (input) => {
|
|
|
2980
3295
|
replyToPermission,
|
|
2981
3296
|
runSessionCommand
|
|
2982
3297
|
};
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
3298
|
+
bot.setQuestionDispatcher(createQuestionDispatcher(ctx));
|
|
3299
|
+
bot.setPermissionDispatcher(createPermissionDispatcher(ctx));
|
|
3300
|
+
bot.setStartWorkDispatcher(createStartWorkDispatcher(ctx));
|
|
3301
|
+
bot.setSessionsDispatcher(
|
|
3302
|
+
createSessionsDispatcher({
|
|
2988
3303
|
client: input.client,
|
|
2989
3304
|
sessionTitleService,
|
|
2990
3305
|
sessionRegistry,
|
|
2991
3306
|
snapshotStore,
|
|
2992
3307
|
serverUrl: input.serverUrl.href,
|
|
2993
3308
|
logger
|
|
2994
|
-
})
|
|
2995
|
-
|
|
3309
|
+
})
|
|
3310
|
+
);
|
|
3311
|
+
bot.setStatusDispatcher(
|
|
3312
|
+
createStatusDispatcher({
|
|
2996
3313
|
snapshotStore,
|
|
2997
3314
|
sessionTitleService,
|
|
2998
3315
|
client: input.client,
|
|
2999
3316
|
logger,
|
|
3000
3317
|
serverUrl: input.serverUrl.href
|
|
3001
|
-
})
|
|
3002
|
-
|
|
3318
|
+
})
|
|
3319
|
+
);
|
|
3320
|
+
bot.setStartWorkCommandDispatcher(
|
|
3321
|
+
createStartWorkCommandDispatcher({
|
|
3003
3322
|
snapshotStore,
|
|
3004
3323
|
sessionTitleService,
|
|
3005
3324
|
client: input.client,
|
|
3006
3325
|
serverUrl: input.serverUrl.href,
|
|
3007
3326
|
runSessionCommand,
|
|
3008
3327
|
logger
|
|
3009
|
-
})
|
|
3010
|
-
|
|
3328
|
+
})
|
|
3329
|
+
);
|
|
3330
|
+
bot.setHelpDispatcher(createHelpDispatcher({ logger }));
|
|
3331
|
+
if (leadership.isLeader) {
|
|
3332
|
+
startLeaderPolling();
|
|
3011
3333
|
}
|
|
3012
3334
|
return {
|
|
3013
3335
|
event: async ({ event }) => {
|
|
@@ -3058,14 +3380,12 @@ var TelegramRemote = async (input) => {
|
|
|
3058
3380
|
return;
|
|
3059
3381
|
}
|
|
3060
3382
|
if (isEventPermissionAsked(extEvent)) {
|
|
3061
|
-
if (!isLeader) return;
|
|
3062
3383
|
return handlePermissionAsked(extEvent, ctx);
|
|
3063
3384
|
}
|
|
3064
3385
|
if (isEventSessionError(extEvent)) {
|
|
3065
3386
|
return handleSessionError(extEvent, ctx);
|
|
3066
3387
|
}
|
|
3067
3388
|
if (isEventQuestionAsked(extEvent)) {
|
|
3068
|
-
if (!isLeader) return;
|
|
3069
3389
|
return handleQuestionAsked(extEvent, ctx);
|
|
3070
3390
|
}
|
|
3071
3391
|
if (isEventQuestionReplied(extEvent)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coinseeker/opencode-telegram-plugin",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
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",
|