jeo-code 0.12.10 → 0.12.11
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/CHANGELOG.md +7 -0
- package/README.ja.md +16 -11
- package/README.ko.md +85 -26
- package/README.md +75 -19
- package/README.zh.md +16 -11
- package/package.json +2 -1
- package/src/agent/ast-edit-tool.ts +3 -1
- package/src/agent/engine.ts +114 -16
- package/src/agent/goal-tool.ts +7 -1
- package/src/agent/goal-verifier.ts +95 -16
- package/src/agent/loop-guards.ts +10 -9
- package/src/agent/lsp-rename-tool.ts +3 -1
- package/src/agent/notify/discord-daemon.ts +3 -1
- package/src/agent/notify/paths.ts +8 -0
- package/src/agent/notify/session-endpoint.ts +23 -4
- package/src/agent/notify/slack-daemon.ts +2 -2
- package/src/agent/notify/telegram-daemon.ts +223 -89
- package/src/agent/notify/topic-registry.ts +118 -15
- package/src/agent/skill-router.ts +31 -5
- package/src/agent/subagent-registry.ts +87 -5
- package/src/agent/subagent-tool.ts +133 -18
- package/src/agent/subagents.ts +7 -5
- package/src/agent/task-tool.ts +85 -14
- package/src/agent/tools.ts +14 -4
- package/src/cli.ts +1 -0
- package/src/commands/launch.ts +162 -15
- package/src/commands/messenger.ts +5 -1
- package/src/commands/notify.ts +5 -6
- package/src/commands/skills.ts +9 -0
- package/src/commands/team.ts +72 -53
- package/src/messenger/config.ts +6 -2
- package/src/messenger/discord-setup.ts +3 -1
- package/src/messenger/discord.ts +5 -1
- package/src/messenger/session-pool.ts +99 -13
- package/src/messenger/types.ts +2 -0
- package/src/skills/catalog.ts +19 -2
|
@@ -87,6 +87,7 @@ export class SessionNotifyEndpoint {
|
|
|
87
87
|
private lastSnapshotJson = "";
|
|
88
88
|
/** `undefined` between turns — subagent requests report an empty registry. */
|
|
89
89
|
private registry: SubagentRegistry | undefined;
|
|
90
|
+
private redact = false;
|
|
90
91
|
readonly sessionId: string;
|
|
91
92
|
private readonly token: string;
|
|
92
93
|
private identityFrame: string | undefined;
|
|
@@ -103,10 +104,20 @@ export class SessionNotifyEndpoint {
|
|
|
103
104
|
* session reconnects to the SAME discovery file / Telegram topic);
|
|
104
105
|
* falls back to a random id for session-less contexts. */
|
|
105
106
|
sessionId: string = crypto.randomUUID(),
|
|
107
|
+
redact = false,
|
|
106
108
|
) {
|
|
107
109
|
if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) throw new Error("Invalid notification session ID");
|
|
108
110
|
this.sessionId = sessionId;
|
|
109
111
|
this.token = crypto.randomUUID();
|
|
112
|
+
this.redact = redact;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Toggle redaction for outbound subagent snapshots in this session. */
|
|
116
|
+
setRedact(enabled: boolean): void {
|
|
117
|
+
if (this.redact === enabled) return;
|
|
118
|
+
this.redact = enabled;
|
|
119
|
+
this.lastSnapshotJson = "";
|
|
120
|
+
this.broadcastIfChanged();
|
|
110
121
|
}
|
|
111
122
|
|
|
112
123
|
/** Attach the registry backing THIS turn's subagents; `list`/`snapshot`
|
|
@@ -126,7 +137,15 @@ export class SessionNotifyEndpoint {
|
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
private snapshot(): { type: "snapshot"; sessionId: string; cwd: string; pid: number; subagents: SubagentRecord[] } {
|
|
129
|
-
|
|
140
|
+
const records = this.registry?.list() ?? [];
|
|
141
|
+
const subagents: SubagentRecord[] = this.redact
|
|
142
|
+
? records.map(record => ({
|
|
143
|
+
...record,
|
|
144
|
+
task: "[redacted]",
|
|
145
|
+
...(record.result === undefined ? {} : { result: "[redacted]" }),
|
|
146
|
+
}))
|
|
147
|
+
: records;
|
|
148
|
+
return { type: "snapshot", sessionId: this.sessionId, cwd: this.cwd, pid: process.pid, subagents };
|
|
130
149
|
}
|
|
131
150
|
|
|
132
151
|
private broadcastIfChanged(): void {
|
|
@@ -301,7 +320,7 @@ export class SessionNotifyEndpoint {
|
|
|
301
320
|
if (msg.type === "user_message") {
|
|
302
321
|
let ok = false;
|
|
303
322
|
const validPaths = msg.imagePaths === undefined || (Array.isArray(msg.imagePaths) && msg.imagePaths.every(x => typeof x === "string"));
|
|
304
|
-
if (typeof msg.text === "string" && !msg.text.trimStart().startsWith("/") && validPaths &&
|
|
323
|
+
if (typeof msg.text === "string" && !msg.text.trimStart().startsWith("/") && !msg.text.trimStart().startsWith("!") && validPaths &&
|
|
305
324
|
(msg.text.trim() || (msg.imagePaths as string[] | undefined)?.length) && this.onUserMessage) {
|
|
306
325
|
try {
|
|
307
326
|
ok = (await this.onUserMessage({ text: msg.text, imagePaths: msg.imagePaths as string[] | undefined })) !== false;
|
|
@@ -426,7 +445,7 @@ export async function startSessionNotifyEndpoint(cwd: string, sessionId: string
|
|
|
426
445
|
try {
|
|
427
446
|
const config = await readGlobalConfig();
|
|
428
447
|
if (!config.notifications?.enabled) return undefined;
|
|
429
|
-
const endpoint = new SessionNotifyEndpoint(cwd, sessionId);
|
|
448
|
+
const endpoint = new SessionNotifyEndpoint(cwd, sessionId, config.notifications?.redact ?? false);
|
|
430
449
|
await endpoint.start();
|
|
431
450
|
return endpoint;
|
|
432
451
|
} catch {
|
|
@@ -456,7 +475,7 @@ export function ensureSessionNotifyEndpoint(registry: SubagentRegistry, cwd: str
|
|
|
456
475
|
try {
|
|
457
476
|
const config = await readGlobalConfig();
|
|
458
477
|
if (!config.notifications?.enabled) return;
|
|
459
|
-
const endpoint = new SessionNotifyEndpoint(cwd);
|
|
478
|
+
const endpoint = new SessionNotifyEndpoint(cwd, undefined, config.notifications?.redact ?? false);
|
|
460
479
|
await endpoint.start();
|
|
461
480
|
endpoint.attachRegistry(registry);
|
|
462
481
|
endpointsByRegistry.set(registry, endpoint);
|
|
@@ -339,7 +339,7 @@ export class SlackDaemon {
|
|
|
339
339
|
const body = command[3]!.trim();
|
|
340
340
|
let local: Record<string, unknown>;
|
|
341
341
|
if (command[1] === "send") {
|
|
342
|
-
if (body.startsWith("/")) { this.emit("Remote slash commands are disabled; send ordinary prompt text.", undefined, threadTs); return; }
|
|
342
|
+
if (body.startsWith("/") || body.startsWith("!")) { this.emit("Remote slash and shell commands are disabled; send ordinary prompt text.", undefined, threadTs); return; }
|
|
343
343
|
local = { type: "user_message", text: body };
|
|
344
344
|
} else {
|
|
345
345
|
const args = command[1] === "steer" ? /^(\S+)\s+([\s\S]+)$/.exec(body) : /^(\S+)$/.exec(body);
|
|
@@ -350,7 +350,7 @@ export class SlackDaemon {
|
|
|
350
350
|
this.emit(ok ? "Session acknowledged command (accepted, not completed)." : "Command not acknowledged (unavailable, rejected or timed out).", session.sessionId, threadTs);
|
|
351
351
|
return;
|
|
352
352
|
}
|
|
353
|
-
if (text.startsWith("/")) { this.emit(HELP, undefined, threadTs); return; }
|
|
353
|
+
if (text.startsWith("/") || text.startsWith("!")) { this.emit(HELP, undefined, threadTs); return; }
|
|
354
354
|
const reply = threadTs ? this.replies.get(threadTs) : undefined;
|
|
355
355
|
if (!addressed && !reply) return;
|
|
356
356
|
const session = reply && (this.deps.now ?? Date.now)() - reply.at < 86_400_000 ? this.findSession(reply.sessionId) : undefined;
|
|
@@ -33,14 +33,16 @@
|
|
|
33
33
|
import * as fs from "node:fs/promises";
|
|
34
34
|
import * as os from "node:os";
|
|
35
35
|
import * as path from "node:path";
|
|
36
|
+
import * as crypto from "node:crypto";
|
|
36
37
|
import { readGlobalConfig } from "../state";
|
|
37
|
-
import { notifySessionsDir, notifyTopicsPath } from "./paths";
|
|
38
|
+
import { notifySessionsDir, notifyTelegramUpdateOffsetPath, notifyTopicsPath } from "./paths";
|
|
38
39
|
import { acquireDaemonLock, isPidAlive } from "./daemon-control";
|
|
39
40
|
import { TelegramApi, type TelegramUpdate, type TelegramCallbackQuery, type InlineKeyboardMarkup, type InlineKeyboardButton } from "./telegram-api";
|
|
40
41
|
import { TopicRegistry, type TopicRegistryState } from "./topic-registry";
|
|
41
42
|
import { RateLimitPool } from "./rate-limit-pool";
|
|
42
43
|
import { parseInThreadConfigCommand } from "./config-commands";
|
|
43
44
|
import { markdownToTelegramHtml, splitTelegramHtml } from "./telegram-html";
|
|
45
|
+
import { attachmentFromImageBytes } from "../../util/file-attachment";
|
|
44
46
|
import type { SubagentRecord } from "../subagent-registry";
|
|
45
47
|
import { parseSessionEndpoint } from "./session-discovery";
|
|
46
48
|
|
|
@@ -55,6 +57,10 @@ const POLL_ERROR_BACKOFF_MIN_MS = 1_000;
|
|
|
55
57
|
* `getUpdates` long-poll owner) or transient network errors must never
|
|
56
58
|
* wait longer than this between retries. */
|
|
57
59
|
const POLL_ERROR_BACKOFF_MAX_MS = 10_000;
|
|
60
|
+
/** Retry failed outbound Bot API requests at most three times after the first
|
|
61
|
+
* attempt; the daemon's shared pool timer drives retries without blocking an
|
|
62
|
+
* accepted inbound action or replaying its side effects. */
|
|
63
|
+
const MAX_OUTBOUND_RETRIES = 3;
|
|
58
64
|
|
|
59
65
|
|
|
60
66
|
// ── Pure helpers (unit-testable without network/ws) ────────────────────────────
|
|
@@ -86,7 +92,7 @@ export function diffSubagentTransitions(prev: SubagentRecord[], next: SubagentRe
|
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
/** One icon per subagent status; a "started" event renders as the running icon. */
|
|
89
|
-
const STATUS_ICON: Record<SubagentRecord["status"], string> = { running: "▶", completed: "✅", failed: "❌", cancelled: "⏹" };
|
|
95
|
+
const STATUS_ICON: Record<SubagentRecord["status"], string> = { running: "▶", paused: "⏸", completed: "✅", failed: "❌", cancelled: "⏹" };
|
|
90
96
|
|
|
91
97
|
function projectName(cwd: string): string {
|
|
92
98
|
return cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd;
|
|
@@ -200,7 +206,7 @@ interface SessionConnection {
|
|
|
200
206
|
|
|
201
207
|
export interface TelegramDaemonOptions {
|
|
202
208
|
chatId: string;
|
|
203
|
-
/** Verified non-secret Telegram bot ID; required for default disk
|
|
209
|
+
/** Verified non-secret Telegram bot ID; required for default disk persistence. */
|
|
204
210
|
botId?: string;
|
|
205
211
|
allowedUserIds?: string[];
|
|
206
212
|
telegram: TelegramApi;
|
|
@@ -222,6 +228,9 @@ export interface TelegramDaemonOptions {
|
|
|
222
228
|
/** Injectable destination-scoped persistence for the per-session topic map —
|
|
223
229
|
* default disk persistence checks both `botId` and `chatId`. */
|
|
224
230
|
loadTopicState?: () => Promise<TopicRegistryState>;
|
|
231
|
+
/** Durable Telegram update cursor hooks, primarily for deterministic tests. */
|
|
232
|
+
loadUpdateOffset?: () => Promise<number | undefined>;
|
|
233
|
+
saveUpdateOffset?: (nextOffset: number) => Promise<void>;
|
|
225
234
|
saveTopicState?: (state: TopicRegistryState) => Promise<void>;
|
|
226
235
|
/** Injectable clock for `RateLimitPool` determinism in tests. */
|
|
227
236
|
now?: () => number;
|
|
@@ -241,14 +250,16 @@ export interface TelegramDaemonOptions {
|
|
|
241
250
|
/** Outbound payload the shared `RateLimitPool` schedules; `flushPool` maps each
|
|
242
251
|
* variant onto the matching Bot API call. */
|
|
243
252
|
type OutboundPayload =
|
|
244
|
-
| { kind: "text"; chatId: string; text: string; topicId?: number; replyMarkup?: InlineKeyboardMarkup; html?: boolean }
|
|
245
|
-
| { kind: "photo"; chatId: string; photo: string; topicId?: number; caption?: string; replyMarkup?: InlineKeyboardMarkup };
|
|
253
|
+
| { kind: "text"; chatId: string; text: string; topicId?: number; replyMarkup?: InlineKeyboardMarkup; html?: boolean; retryChunks?: string[]; retryCount?: number; onDelivered?: (delivered: boolean) => void }
|
|
254
|
+
| { kind: "photo"; chatId: string; photo: string; topicId?: number; caption?: string; replyMarkup?: InlineKeyboardMarkup; retryCount?: number; onDelivered?: (delivered: boolean) => void };
|
|
246
255
|
|
|
247
256
|
export class TelegramDaemon {
|
|
248
257
|
private readonly WS: typeof WebSocket;
|
|
249
258
|
readonly sessions = new Map<string, SessionConnection>();
|
|
250
259
|
private readonly pendingAcks = new Map<string, { conn: SessionConnection; resolve: (ok: boolean) => void; timer: NodeJS.Timeout }>();
|
|
260
|
+
private readonly pendingOutbound = new Set<(delivered: boolean) => void>();
|
|
251
261
|
private updateOffset: number | undefined;
|
|
262
|
+
private daemonStartEpochSeconds = 0;
|
|
252
263
|
/** Current error-poll retry delay; starts at `POLL_ERROR_BACKOFF_MIN_MS`,
|
|
253
264
|
* doubles (capped at `POLL_ERROR_BACKOFF_MAX_MS`) on each consecutive
|
|
254
265
|
* non-ok/failed `getUpdates`, and resets on the next success. */
|
|
@@ -261,6 +272,8 @@ export class TelegramDaemon {
|
|
|
261
272
|
* path never touches this. */
|
|
262
273
|
private readonly topics: TopicRegistry | undefined;
|
|
263
274
|
private readonly pool: RateLimitPool<OutboundPayload>;
|
|
275
|
+
/** Paths written by this daemon for inbound media; only these are eligible for cleanup. */
|
|
276
|
+
private readonly ownedAttachments = new Set<string>();
|
|
264
277
|
|
|
265
278
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
266
279
|
this.WS = opts.WebSocketImpl ?? WebSocket;
|
|
@@ -271,14 +284,33 @@ export class TelegramDaemon {
|
|
|
271
284
|
private async notify(
|
|
272
285
|
text: string,
|
|
273
286
|
replyMarkup?: InlineKeyboardMarkup,
|
|
274
|
-
opts?: { topicId?: number; sessionId?: string; html?: boolean },
|
|
275
|
-
): Promise<
|
|
287
|
+
opts?: { topicId?: number; sessionId?: string; html?: boolean; awaitDelivery?: boolean },
|
|
288
|
+
): Promise<boolean> {
|
|
289
|
+
let delivery: Promise<boolean> | undefined;
|
|
290
|
+
let onDelivered: ((delivered: boolean) => void) | undefined;
|
|
291
|
+
if (opts?.awaitDelivery) {
|
|
292
|
+
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
293
|
+
let settled = false;
|
|
294
|
+
onDelivered = delivered => {
|
|
295
|
+
if (settled) return;
|
|
296
|
+
settled = true;
|
|
297
|
+
this.pendingOutbound.delete(onDelivered!);
|
|
298
|
+
resolve(delivered);
|
|
299
|
+
};
|
|
300
|
+
this.pendingOutbound.add(onDelivered);
|
|
301
|
+
delivery = promise;
|
|
302
|
+
}
|
|
276
303
|
this.pool.submit({
|
|
277
304
|
sessionId: opts?.sessionId ?? "__global__",
|
|
278
305
|
lane: "finalized",
|
|
279
|
-
payload: { kind: "text", chatId: this.opts.chatId, text, topicId: opts?.topicId ?? this.opts.topicId, replyMarkup, html: opts?.html },
|
|
306
|
+
payload: { kind: "text", chatId: this.opts.chatId, text, topicId: opts?.topicId ?? this.opts.topicId, replyMarkup, html: opts?.html, onDelivered },
|
|
280
307
|
});
|
|
281
308
|
await this.flushPool();
|
|
309
|
+
return delivery ? await delivery : true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async notifyReply(text: string, replyMarkup?: InlineKeyboardMarkup): Promise<boolean> {
|
|
313
|
+
return await this.notify(text, replyMarkup, { awaitDelivery: true });
|
|
282
314
|
}
|
|
283
315
|
|
|
284
316
|
private async notifyPhoto(
|
|
@@ -295,11 +327,12 @@ export class TelegramDaemon {
|
|
|
295
327
|
await this.flushPool();
|
|
296
328
|
}
|
|
297
329
|
|
|
298
|
-
private async answerCallback(id: string, text?: string): Promise<
|
|
330
|
+
private async answerCallback(id: string, text?: string): Promise<boolean> {
|
|
299
331
|
try {
|
|
300
332
|
await this.opts.telegram.answerCallbackQuery(id, text !== undefined ? { text } : {});
|
|
333
|
+
return true;
|
|
301
334
|
} catch {
|
|
302
|
-
|
|
335
|
+
return false;
|
|
303
336
|
}
|
|
304
337
|
}
|
|
305
338
|
|
|
@@ -316,26 +349,43 @@ export class TelegramDaemon {
|
|
|
316
349
|
const granted = this.pool.drain();
|
|
317
350
|
for (const item of granted) {
|
|
318
351
|
const p = item.payload;
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
352
|
+
let delivered = true;
|
|
353
|
+
let retryChunks: string[] | undefined;
|
|
354
|
+
if (p.kind === "text") {
|
|
355
|
+
const chunks = p.retryChunks ?? splitTelegramHtml(p.text);
|
|
356
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
357
|
+
try {
|
|
358
|
+
const result = await this.opts.telegram.sendMessage(p.chatId, chunks[i]!, {
|
|
324
359
|
messageThreadId: p.topicId,
|
|
325
360
|
// A reply keyboard belongs on the LAST fragment a split reply ends on.
|
|
326
361
|
replyMarkup: i === chunks.length - 1 ? p.replyMarkup : undefined,
|
|
327
362
|
parseMode: p.html ? "HTML" : undefined,
|
|
328
363
|
});
|
|
364
|
+
if (!result || result.ok !== true) throw new Error("Telegram sendMessage returned ok:false");
|
|
365
|
+
} catch {
|
|
366
|
+
delivered = false;
|
|
367
|
+
retryChunks = chunks.slice(i);
|
|
368
|
+
break;
|
|
329
369
|
}
|
|
330
|
-
} else {
|
|
331
|
-
await this.opts.telegram.sendPhoto(p.chatId, p.photo, { messageThreadId: p.topicId, caption: p.caption, replyMarkup: p.replyMarkup });
|
|
332
370
|
}
|
|
333
|
-
}
|
|
334
|
-
|
|
371
|
+
} else {
|
|
372
|
+
try {
|
|
373
|
+
const result = await this.opts.telegram.sendPhoto(p.chatId, p.photo, { messageThreadId: p.topicId, caption: p.caption, replyMarkup: p.replyMarkup });
|
|
374
|
+
if (!result || result.ok !== true) throw new Error("Telegram sendPhoto returned ok:false");
|
|
375
|
+
} catch {
|
|
376
|
+
delivered = false;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (!delivered && !this.stopped && (p.retryCount ?? 0) < MAX_OUTBOUND_RETRIES) {
|
|
380
|
+
this.pool.submit({
|
|
381
|
+
...item,
|
|
382
|
+
payload: { ...p, retryCount: (p.retryCount ?? 0) + 1, ...(retryChunks ? { retryChunks } : {}) },
|
|
383
|
+
});
|
|
384
|
+
continue;
|
|
335
385
|
}
|
|
386
|
+
p.onDelivered?.(delivered);
|
|
336
387
|
}
|
|
337
388
|
}
|
|
338
|
-
|
|
339
389
|
async scanSessions(): Promise<void> {
|
|
340
390
|
if (this.stopped) return;
|
|
341
391
|
const readdir = this.opts.readdir ?? (d => fs.readdir(d));
|
|
@@ -461,12 +511,22 @@ export class TelegramDaemon {
|
|
|
461
511
|
if (!filePath) return undefined;
|
|
462
512
|
const bytes = await this.opts.telegram.downloadFile(filePath, this.opts.maxAttachmentBytes);
|
|
463
513
|
if (!bytes) return undefined;
|
|
514
|
+
if (!attachmentFromImageBytes(bytes)) return undefined;
|
|
464
515
|
const write = this.opts.writeTempFile ?? defaultWriteTempFile;
|
|
465
|
-
|
|
516
|
+
const writtenPath = await write(bytes, suggestedName);
|
|
517
|
+
this.ownedAttachments.add(writtenPath);
|
|
518
|
+
return writtenPath;
|
|
466
519
|
} catch {
|
|
467
520
|
return undefined;
|
|
468
521
|
}
|
|
469
522
|
}
|
|
523
|
+
private async cleanupAttachments(paths: readonly string[]): Promise<void> {
|
|
524
|
+
const unlink = this.opts.unlink ?? (p => fs.unlink(p));
|
|
525
|
+
for (const filePath of paths) {
|
|
526
|
+
if (!this.ownedAttachments.delete(filePath)) continue;
|
|
527
|
+
await unlink(filePath).catch(() => {});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
470
530
|
|
|
471
531
|
private async reactToMessage(messageId: number, emoji: string): Promise<void> {
|
|
472
532
|
try {
|
|
@@ -621,40 +681,35 @@ export class TelegramDaemon {
|
|
|
621
681
|
return promise;
|
|
622
682
|
}
|
|
623
683
|
|
|
624
|
-
async handleInboundText(text: string): Promise<
|
|
684
|
+
private async handleInboundText(text: string): Promise<boolean> {
|
|
625
685
|
const trimmed = text.trim();
|
|
626
686
|
if (trimmed === "/subagents") {
|
|
627
687
|
const sessions = [...this.sessions.values()].map(c => ({ sessionId: c.sessionId, cwd: c.cwd, records: c.lastRecords }));
|
|
628
|
-
await this.notify(formatSubagentsList(sessions), buildSubagentsKeyboard(sessions));
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
if (trimmed === "/help" || trimmed === "/start") {
|
|
632
|
-
await this.notify(HELP_TEXT);
|
|
633
|
-
return;
|
|
688
|
+
return await this.notify(formatSubagentsList(sessions), buildSubagentsKeyboard(sessions), { awaitDelivery: true });
|
|
634
689
|
}
|
|
690
|
+
if (trimmed === "/help" || trimmed === "/start") return await this.notifyReply(HELP_TEXT);
|
|
635
691
|
const steer = parseSteerCommand(trimmed);
|
|
636
692
|
if (steer) {
|
|
637
693
|
const conn = this.findSession(steer.shortId);
|
|
638
694
|
if (!conn) {
|
|
639
|
-
await this.
|
|
640
|
-
return;
|
|
695
|
+
return await this.notifyReply(`No connected session matches '${steer.shortId}'. Send /subagents to see live sessions.`);
|
|
641
696
|
}
|
|
642
697
|
const ok = await this.sendRequest(conn, { type: "steer", id: steer.subagentId, message: steer.message });
|
|
643
|
-
await this.
|
|
644
|
-
return;
|
|
698
|
+
const delivered = await this.notifyReply(ok ? `Steered '${steer.subagentId}'.` : `Steer failed — '${steer.subagentId}' is unknown or not running.`);
|
|
699
|
+
return ok || delivered;
|
|
645
700
|
}
|
|
646
701
|
const cancel = parseCancelCommand(trimmed);
|
|
647
702
|
if (cancel) {
|
|
648
703
|
const conn = this.findSession(cancel.shortId);
|
|
649
704
|
if (!conn) {
|
|
650
|
-
await this.
|
|
651
|
-
return;
|
|
705
|
+
return await this.notifyReply(`No connected session matches '${cancel.shortId}'. Send /subagents to see live sessions.`);
|
|
652
706
|
}
|
|
653
707
|
const ok = await this.sendRequest(conn, { type: "cancel", ids: [cancel.subagentId] });
|
|
654
|
-
await this.
|
|
655
|
-
return;
|
|
708
|
+
const delivered = await this.notifyReply(ok ? `Cancelled '${cancel.subagentId}'.` : `Cancel failed — '${cancel.subagentId}' is unknown or already finished.`);
|
|
709
|
+
return ok || delivered;
|
|
656
710
|
}
|
|
657
|
-
if (trimmed.startsWith("/")) await this.
|
|
711
|
+
if (trimmed.startsWith("/") || trimmed.startsWith("!")) return await this.notifyReply(`Unrecognized command. ${HELP_TEXT}`);
|
|
712
|
+
return true;
|
|
658
713
|
}
|
|
659
714
|
|
|
660
715
|
/** Inbound routed to a KNOWN session's OWN forum topic (per-session-topics
|
|
@@ -665,17 +720,23 @@ export class TelegramDaemon {
|
|
|
665
720
|
* caption) is forwarded as free-text steering, with any attached photo/
|
|
666
721
|
* image-document downloaded first. A reaction confirms endpoint acceptance;
|
|
667
722
|
* remote slash commands other than the presentation settings are rejected. */
|
|
668
|
-
private async handleSessionTopicInbound(sessionId: string, msg: NonNullable<TelegramUpdate["message"]>): Promise<
|
|
723
|
+
private async handleSessionTopicInbound(sessionId: string, msg: NonNullable<TelegramUpdate["message"]>): Promise<boolean> {
|
|
669
724
|
const conn = this.sessions.get(sessionId);
|
|
670
|
-
if (!conn?.verified) return; // disconnected or not yet authenticated by its snapshot
|
|
725
|
+
if (!conn?.verified) return true; // disconnected or not yet authenticated by its snapshot
|
|
671
726
|
const text = msg.text ?? msg.caption ?? "";
|
|
672
727
|
const hasMedia = !!(msg.photo?.length || msg.document);
|
|
673
728
|
const cfg = !hasMedia ? parseInThreadConfigCommand(text) : undefined;
|
|
674
729
|
if (cfg) {
|
|
675
|
-
|
|
676
|
-
|
|
730
|
+
try {
|
|
731
|
+
const readyState = (conn.ws as WebSocket & { readyState?: number }).readyState;
|
|
732
|
+
if (typeof readyState === "number" && readyState !== 1) return true;
|
|
733
|
+
conn.ws.send(JSON.stringify({ type: "config_command", sessionId, ...cfg }));
|
|
734
|
+
} catch {
|
|
735
|
+
// The session may disconnect between the readiness check and send; checkpoint this update and continue polling.
|
|
736
|
+
}
|
|
737
|
+
return true;
|
|
677
738
|
}
|
|
678
|
-
if (typeof text !== "string" || text.trimStart().startsWith("/")) return;
|
|
739
|
+
if (typeof text !== "string" || text.trimStart().startsWith("/") || text.trimStart().startsWith("!")) return true;
|
|
679
740
|
const imagePaths: string[] = [];
|
|
680
741
|
if (msg.photo?.length) {
|
|
681
742
|
const largest = msg.photo[msg.photo.length - 1]!;
|
|
@@ -685,9 +746,15 @@ export class TelegramDaemon {
|
|
|
685
746
|
const p = await this.downloadAttachment(msg.document.file_id, msg.document.file_name ?? `document-${msg.document.file_id}`);
|
|
686
747
|
if (p) imagePaths.push(p);
|
|
687
748
|
}
|
|
688
|
-
if (!text && imagePaths.length === 0) return; // a non-image document with no caption — nothing usable to forward
|
|
689
|
-
|
|
749
|
+
if (!text && imagePaths.length === 0) return true; // a non-image document with no caption — nothing usable to forward
|
|
750
|
+
let ok = false;
|
|
751
|
+
try {
|
|
752
|
+
ok = await this.sendRequest(conn, { type: "user_message", sessionId, text, imagePaths: imagePaths.length ? imagePaths : undefined });
|
|
753
|
+
} finally {
|
|
754
|
+
await this.cleanupAttachments(imagePaths);
|
|
755
|
+
}
|
|
690
756
|
if (ok) await this.reactToMessage(msg.message_id, "👀");
|
|
757
|
+
return ok;
|
|
691
758
|
}
|
|
692
759
|
|
|
693
760
|
private authorized(chat: NonNullable<TelegramUpdate["message"]>["chat"], from: TelegramCallbackQuery["from"] | undefined): boolean {
|
|
@@ -699,34 +766,25 @@ export class TelegramDaemon {
|
|
|
699
766
|
/** Only an authorized human in the paired chat can control sessions. Groups
|
|
700
767
|
* require an explicit allowlist; legacy private chats require owner identity.
|
|
701
768
|
* Session topics route solely to their owner; all other input must match the
|
|
702
|
-
* configured flat topic, including when
|
|
703
|
-
async handleUpdate(update: TelegramUpdate): Promise<
|
|
704
|
-
if (!update || typeof update !== "object") return;
|
|
705
|
-
if (update.callback_query)
|
|
706
|
-
await this.handleCallbackQuery(update.callback_query);
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
769
|
+
* configured flat topic, including when inbound topic is absent. */
|
|
770
|
+
async handleUpdate(update: TelegramUpdate): Promise<boolean> {
|
|
771
|
+
if (!update || typeof update !== "object") return true;
|
|
772
|
+
if (update.callback_query) return await this.handleCallbackQuery(update.callback_query);
|
|
709
773
|
const msg = update.message;
|
|
710
|
-
if (!msg) return;
|
|
711
|
-
if (!this.authorized(msg.chat, msg.from)) return;
|
|
774
|
+
if (!msg) return true;
|
|
775
|
+
if (!this.authorized(msg.chat, msg.from)) return true;
|
|
712
776
|
|
|
713
777
|
const ownerSessionId = this.topics && msg.message_thread_id !== undefined
|
|
714
778
|
? this.topics.sessionForTopic(msg.message_thread_id)
|
|
715
779
|
: undefined;
|
|
716
780
|
if (ownerSessionId && await this.pairedChatIsPrivate()) {
|
|
717
|
-
await this.handleSessionTopicInbound(ownerSessionId, msg);
|
|
718
|
-
return;
|
|
781
|
+
return await this.handleSessionTopicInbound(ownerSessionId, msg);
|
|
719
782
|
}
|
|
720
783
|
|
|
721
|
-
if (typeof msg.text !== "string" || !msg.text) return;
|
|
722
|
-
if (this.topics && msg.message_thread_id !== undefined && msg.message_thread_id !== this.opts.topicId) return;
|
|
723
|
-
if (
|
|
724
|
-
|
|
725
|
-
msg.message_thread_id !== this.opts.topicId
|
|
726
|
-
) {
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
await this.handleInboundText(msg.text);
|
|
784
|
+
if (typeof msg.text !== "string" || !msg.text) return true;
|
|
785
|
+
if (this.topics && msg.message_thread_id !== undefined && msg.message_thread_id !== this.opts.topicId) return true;
|
|
786
|
+
if (this.opts.topicId !== undefined && msg.message_thread_id !== this.opts.topicId) return true;
|
|
787
|
+
return await this.handleInboundText(msg.text);
|
|
730
788
|
}
|
|
731
789
|
|
|
732
790
|
/** Handle an inline-button tap. Same chat trust boundary as text; the topic
|
|
@@ -735,10 +793,10 @@ export class TelegramDaemon {
|
|
|
735
793
|
* regardless of the flat `topicId`), not just the configured flat topic.
|
|
736
794
|
* The tap is always acknowledged (Telegram shows a spinner until
|
|
737
795
|
* `answerCallbackQuery`). */
|
|
738
|
-
async handleCallbackQuery(cb: TelegramCallbackQuery): Promise<
|
|
796
|
+
async handleCallbackQuery(cb: TelegramCallbackQuery): Promise<boolean> {
|
|
739
797
|
if (!cb.message || !Number.isSafeInteger(cb.message.message_id) || !this.authorized(cb.message.chat, cb.from)) {
|
|
740
798
|
await this.answerCallback(cb.id);
|
|
741
|
-
return;
|
|
799
|
+
return true;
|
|
742
800
|
}
|
|
743
801
|
const threadId = cb.message.message_thread_id;
|
|
744
802
|
const ownerSessionId = this.topics && threadId !== undefined && await this.pairedChatIsPrivate()
|
|
@@ -746,29 +804,29 @@ export class TelegramDaemon {
|
|
|
746
804
|
if (!ownerSessionId && ((this.opts.topicId !== undefined && threadId !== this.opts.topicId) ||
|
|
747
805
|
(this.topics && threadId !== undefined && threadId !== this.opts.topicId))) {
|
|
748
806
|
await this.answerCallback(cb.id);
|
|
749
|
-
return;
|
|
807
|
+
return true;
|
|
750
808
|
}
|
|
751
809
|
const parsed = typeof cb.data === "string" ? parseCallbackData(cb.data) : undefined;
|
|
752
810
|
if (!parsed) {
|
|
753
811
|
await this.answerCallback(cb.id);
|
|
754
|
-
return;
|
|
812
|
+
return true;
|
|
755
813
|
}
|
|
756
814
|
const conn = this.findSession(parsed.shortId);
|
|
757
815
|
if (!conn) {
|
|
758
816
|
await this.answerCallback(cb.id, `No connected session matches '${parsed.shortId}'.`);
|
|
759
|
-
return;
|
|
817
|
+
return true;
|
|
760
818
|
}
|
|
761
819
|
if (ownerSessionId && ownerSessionId !== conn.sessionId) {
|
|
762
820
|
await this.answerCallback(cb.id);
|
|
763
|
-
return;
|
|
821
|
+
return true;
|
|
764
822
|
}
|
|
765
823
|
const ok = await this.sendRequest(conn, { type: "cancel", ids: [parsed.subagentId] });
|
|
766
824
|
await this.answerCallback(cb.id, ok ? `Cancelled '${parsed.subagentId}'.` : "Cancel failed.");
|
|
767
|
-
await this.notify(
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
825
|
+
const notificationDelivered = await this.notify(ok
|
|
826
|
+
? `⏹ Cancelled '${parsed.subagentId}' via button.`
|
|
827
|
+
: `Cancel failed — '${parsed.subagentId}' is unknown or already finished.`, undefined, { awaitDelivery: true });
|
|
828
|
+
// Do not replay a cancel that already took effect; otherwise persist it only after terminal feedback.
|
|
829
|
+
return ok || notificationDelivered;
|
|
772
830
|
}
|
|
773
831
|
|
|
774
832
|
/** Sleeps for the current backoff delay, then grows it (capped) for the
|
|
@@ -793,6 +851,7 @@ export class TelegramDaemon {
|
|
|
793
851
|
}
|
|
794
852
|
}
|
|
795
853
|
|
|
854
|
+
private wasCursorLoadedWithoutOffset = false;
|
|
796
855
|
private async pollTelegramLoop(): Promise<void> {
|
|
797
856
|
while (!this.stopped) {
|
|
798
857
|
let res;
|
|
@@ -804,30 +863,56 @@ export class TelegramDaemon {
|
|
|
804
863
|
}
|
|
805
864
|
if (this.stopped) break;
|
|
806
865
|
if (!res.ok) {
|
|
807
|
-
// e.g. 409 Conflict (another getUpdates owner) or 401 (revoked token) —
|
|
808
|
-
// without a pause this would hot-loop against the API.
|
|
809
866
|
await this.pollErrorBackoff();
|
|
810
867
|
continue;
|
|
811
868
|
}
|
|
812
|
-
// A successful round-trip clears any accumulated backoff — the next
|
|
813
|
-
// failure (if any) starts fresh from POLL_ERROR_BACKOFF_MIN_MS rather
|
|
814
|
-
// than staying parked at whatever ceiling a prior outage reached.
|
|
815
869
|
this.pollErrorBackoffMs = POLL_ERROR_BACKOFF_MIN_MS;
|
|
870
|
+
// Keep filtering pre-start ordinary messages across poll batches: an earlier dispatch failure can leave old queued updates behind.
|
|
871
|
+
// Callback queries are exempt because their attached message date is not the callback tap time.
|
|
872
|
+
const checkFreshness = this.wasCursorLoadedWithoutOffset;
|
|
873
|
+
let dispatchFailed = false;
|
|
816
874
|
for (const update of res.result) {
|
|
817
875
|
if (this.stopped) break;
|
|
818
|
-
|
|
819
|
-
|
|
876
|
+
if (!Number.isSafeInteger(update?.update_id) || update.update_id < 0 || update.update_id >= Number.MAX_SAFE_INTEGER) {
|
|
877
|
+
throw new Error("Telegram update has an invalid update_id");
|
|
878
|
+
}
|
|
879
|
+
if (this.updateOffset !== undefined && update.update_id < this.updateOffset) continue;
|
|
880
|
+
|
|
881
|
+
const nextOffset = update.update_id + 1;
|
|
882
|
+
// A callback is a user action; its attached message date is not the tap time.
|
|
883
|
+
if (checkFreshness && update.message &&
|
|
884
|
+
(!Number.isSafeInteger(update.message.date) || update.message.date < this.daemonStartEpochSeconds)) {
|
|
885
|
+
const save = this.opts.saveUpdateOffset ?? (() => defaultSaveUpdateOffset(nextOffset, this.opts.botId, this.opts.chatId));
|
|
886
|
+
await save(nextOffset);
|
|
887
|
+
this.updateOffset = nextOffset;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
if (!await this.handleUpdate(update)) {
|
|
891
|
+
dispatchFailed = true;
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
const save = this.opts.saveUpdateOffset ?? (() => defaultSaveUpdateOffset(nextOffset, this.opts.botId, this.opts.chatId));
|
|
895
|
+
await save(nextOffset);
|
|
896
|
+
this.updateOffset = nextOffset;
|
|
820
897
|
}
|
|
898
|
+
if (dispatchFailed) await this.pollErrorBackoff();
|
|
821
899
|
}
|
|
822
900
|
}
|
|
823
901
|
|
|
824
902
|
/** Blocks (long-poll loop) until `stop()` is called. */
|
|
825
903
|
async start(): Promise<void> {
|
|
826
904
|
if (this.stopped) return;
|
|
905
|
+
this.daemonStartEpochSeconds = Math.floor(Date.now() / 1000);
|
|
906
|
+
const load = this.opts.loadUpdateOffset ?? (() => defaultLoadUpdateOffset(this.opts.botId, this.opts.chatId));
|
|
907
|
+
this.updateOffset = await load();
|
|
908
|
+
if (this.updateOffset !== undefined && (!Number.isSafeInteger(this.updateOffset) || this.updateOffset < 0)) {
|
|
909
|
+
throw new Error("Stored Telegram update offset is invalid");
|
|
910
|
+
}
|
|
911
|
+
this.wasCursorLoadedWithoutOffset = this.updateOffset === undefined;
|
|
827
912
|
if (this.topics) {
|
|
828
|
-
const
|
|
913
|
+
const loadTopics = this.opts.loadTopicState ?? (() => defaultLoadTopicState(this.opts.botId, this.opts.chatId));
|
|
829
914
|
try {
|
|
830
|
-
this.topics.load(await
|
|
915
|
+
this.topics.load(await loadTopics());
|
|
831
916
|
} catch {
|
|
832
917
|
// best-effort — a missing/corrupt topic-state file just starts empty
|
|
833
918
|
// (every session's first frame re-creates its topic from scratch).
|
|
@@ -837,24 +922,28 @@ export class TelegramDaemon {
|
|
|
837
922
|
await this.scanSessions();
|
|
838
923
|
if (this.stopped) return;
|
|
839
924
|
this.scanTimer = setInterval(() => void this.scanSessions(), this.opts.scanIntervalMs ?? 3_000);
|
|
840
|
-
// Catches anything left queued after a submit-time drain because the
|
|
841
|
-
// burst capacity was briefly exhausted (rare at jeo's realistic message
|
|
842
|
-
// volume, but a large simultaneous fan-out can hit it) — without this,
|
|
843
|
-
// a queued-but-never-retried item would silently never send.
|
|
844
925
|
this.poolTimer = setInterval(() => void this.flushPool(), 250);
|
|
845
|
-
|
|
926
|
+
try {
|
|
927
|
+
await this.pollTelegramLoop();
|
|
928
|
+
} catch (error) {
|
|
929
|
+
this.stop();
|
|
930
|
+
throw error;
|
|
931
|
+
}
|
|
846
932
|
}
|
|
847
933
|
|
|
848
934
|
stop(): void {
|
|
849
935
|
this.stopped = true;
|
|
850
936
|
this.cancelBackoff?.();
|
|
851
937
|
clearInterval(this.scanTimer);
|
|
938
|
+
for (const resolve of this.pendingOutbound) resolve(false);
|
|
939
|
+
this.pendingOutbound.clear();
|
|
852
940
|
clearInterval(this.poolTimer);
|
|
853
941
|
for (const pending of this.pendingAcks.values()) {
|
|
854
942
|
clearTimeout(pending.timer);
|
|
855
943
|
pending.resolve(false);
|
|
856
944
|
}
|
|
857
945
|
this.pendingAcks.clear();
|
|
946
|
+
void this.cleanupAttachments([...this.ownedAttachments]);
|
|
858
947
|
for (const conn of this.sessions.values()) {
|
|
859
948
|
try {
|
|
860
949
|
conn.ws.close();
|
|
@@ -864,6 +953,51 @@ export class TelegramDaemon {
|
|
|
864
953
|
}
|
|
865
954
|
}
|
|
866
955
|
|
|
956
|
+
async function defaultLoadUpdateOffset(botId: string | undefined, chatId: string): Promise<number | undefined> {
|
|
957
|
+
if (!botId) return undefined;
|
|
958
|
+
if (!/^[1-9]\d*$/.test(botId) || !chatId) return undefined;
|
|
959
|
+
const target = notifyTelegramUpdateOffsetPath(botId, chatId);
|
|
960
|
+
try {
|
|
961
|
+
const raw = await fs.readFile(target, "utf-8");
|
|
962
|
+
const state = JSON.parse(raw) as { botId?: unknown; chatId?: unknown; nextOffset?: unknown };
|
|
963
|
+
if (state.botId !== botId || state.chatId !== chatId || !Number.isSafeInteger(state.nextOffset) ||
|
|
964
|
+
(state.nextOffset as number) < 0) throw new Error("Stored Telegram update cursor is corrupt or has the wrong destination");
|
|
965
|
+
return state.nextOffset as number;
|
|
966
|
+
} catch (error) {
|
|
967
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
968
|
+
throw error;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function defaultSaveUpdateOffset(nextOffset: number, botId: string | undefined, chatId: string): Promise<void> {
|
|
973
|
+
if (!botId) return;
|
|
974
|
+
if (!/^[1-9]\d*$/.test(botId) || !chatId) return;
|
|
975
|
+
if (!Number.isSafeInteger(nextOffset) || nextOffset < 0) throw new Error("Telegram update offset is invalid");
|
|
976
|
+
const target = notifyTelegramUpdateOffsetPath(botId, chatId);
|
|
977
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
978
|
+
await fs.chmod(path.dirname(target), 0o700);
|
|
979
|
+
const tmp = `${target}.${crypto.randomUUID()}.tmp`;
|
|
980
|
+
let file: fs.FileHandle | undefined;
|
|
981
|
+
try {
|
|
982
|
+
file = await fs.open(tmp, "wx", 0o600);
|
|
983
|
+
await file.writeFile(JSON.stringify({ botId, chatId, nextOffset }), "utf-8");
|
|
984
|
+
await file.sync();
|
|
985
|
+
await file.close();
|
|
986
|
+
file = undefined;
|
|
987
|
+
await fs.rename(tmp, target);
|
|
988
|
+
const directory = await fs.open(path.dirname(target), "r");
|
|
989
|
+
try {
|
|
990
|
+
await directory.sync();
|
|
991
|
+
} finally {
|
|
992
|
+
await directory.close();
|
|
993
|
+
}
|
|
994
|
+
} catch (error) {
|
|
995
|
+
await file?.close().catch(() => {});
|
|
996
|
+
await fs.unlink(tmp).catch(() => {});
|
|
997
|
+
throw error;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
867
1001
|
/** Default inbound-attachment temp-file writer: a private (0700-parent,
|
|
868
1002
|
* 0600-mode) file under the OS temp dir, named to survive filesystem
|
|
869
1003
|
* weirdness (sanitized, length-capped) while staying traceable to its
|