jeo-code 0.11.2 → 0.11.3
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 +35 -1
- package/README.ja.md +59 -27
- package/README.ko.md +62 -29
- package/README.md +58 -26
- package/README.zh.md +61 -29
- package/package.json +1 -1
- package/src/agent/config-schema.ts +20 -7
- package/src/agent/debug-session.ts +7 -3
- package/src/agent/engine.ts +46 -102
- package/src/agent/loop-guards.ts +11 -13
- package/src/agent/notify/daemon-control.ts +109 -24
- package/src/agent/notify/discord-api.ts +148 -0
- package/src/agent/notify/discord-daemon.ts +443 -0
- package/src/agent/notify/paths.ts +1 -1
- package/src/agent/notify/session-discovery.ts +16 -0
- package/src/agent/notify/session-endpoint.ts +43 -25
- package/src/agent/notify/slack-api.ts +177 -0
- package/src/agent/notify/slack-daemon.ts +361 -0
- package/src/agent/notify/telegram-daemon.ts +215 -83
- package/src/agent/state.ts +13 -1
- package/src/ai/model-discovery.ts +13 -21
- package/src/autopilot.ts +14 -5
- package/src/cli/runner.ts +4 -4
- package/src/commands/approve.ts +21 -16
- package/src/commands/daemon.ts +5 -3
- package/src/commands/launch/agents-slash.ts +8 -3
- package/src/commands/launch.ts +36 -44
- package/src/commands/notify.ts +215 -83
- package/src/commands/ralplan.ts +2 -1
- package/src/commands/team.ts +17 -12
- package/src/commands/ultragoal.ts +2 -1
|
@@ -42,6 +42,7 @@ import { RateLimitPool } from "./rate-limit-pool";
|
|
|
42
42
|
import { parseInThreadConfigCommand } from "./config-commands";
|
|
43
43
|
import { markdownToTelegramHtml, splitTelegramHtml } from "./telegram-html";
|
|
44
44
|
import type { SubagentRecord } from "../subagent-registry";
|
|
45
|
+
import { parseSessionEndpoint } from "./session-discovery";
|
|
45
46
|
|
|
46
47
|
|
|
47
48
|
/** Initial (and post-success-reset) delay before retrying a failed/non-ok
|
|
@@ -55,11 +56,6 @@ const POLL_ERROR_BACKOFF_MIN_MS = 1_000;
|
|
|
55
56
|
* wait longer than this between retries. */
|
|
56
57
|
const POLL_ERROR_BACKOFF_MAX_MS = 10_000;
|
|
57
58
|
|
|
58
|
-
function sleep(ms: number): Promise<void> {
|
|
59
|
-
const { promise, resolve } = Promise.withResolvers<void>();
|
|
60
|
-
setTimeout(resolve, ms);
|
|
61
|
-
return promise;
|
|
62
|
-
}
|
|
63
59
|
|
|
64
60
|
// ── Pure helpers (unit-testable without network/ws) ────────────────────────────
|
|
65
61
|
|
|
@@ -139,7 +135,6 @@ export const HELP_TEXT =
|
|
|
139
135
|
"/subagents — list running/recent subagents across every connected session\n" +
|
|
140
136
|
"/steer <sessionId> <subagentId> <message> — send a live message into a running subagent\n" +
|
|
141
137
|
"/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
|
|
142
|
-
"/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
|
|
143
138
|
"/help — show this message\n" +
|
|
144
139
|
"(running subagents also carry an inline ⏹ Cancel button — tap it instead of typing /cancel)\n\n" +
|
|
145
140
|
"Inside a session's OWN topic (when per-session topics are enabled): reply with any " +
|
|
@@ -192,6 +187,7 @@ interface SessionConnection {
|
|
|
192
187
|
pid: number;
|
|
193
188
|
ws: WebSocket;
|
|
194
189
|
lastRecords: SubagentRecord[];
|
|
190
|
+
verified?: boolean;
|
|
195
191
|
/** Latest identity, once an `identity_header` frame arrives — used to name/
|
|
196
192
|
* rename the session's forum topic. `undefined` until then (topic still
|
|
197
193
|
* gets created eagerly with a provisional short-id name, gjc parity). */
|
|
@@ -204,6 +200,9 @@ interface SessionConnection {
|
|
|
204
200
|
|
|
205
201
|
export interface TelegramDaemonOptions {
|
|
206
202
|
chatId: string;
|
|
203
|
+
/** Verified non-secret Telegram bot ID; required for default disk topic persistence. */
|
|
204
|
+
botId?: string;
|
|
205
|
+
allowedUserIds?: string[];
|
|
207
206
|
telegram: TelegramApi;
|
|
208
207
|
WebSocketImpl?: typeof WebSocket;
|
|
209
208
|
readdir?: (dir: string) => Promise<string[]>;
|
|
@@ -220,8 +219,8 @@ export interface TelegramDaemonOptions {
|
|
|
220
219
|
/** Auto-create/manage one forum topic PER SESSION instead of the single flat
|
|
221
220
|
* `topicId` above (see the file header doc). Off by default. */
|
|
222
221
|
perSessionTopics?: boolean;
|
|
223
|
-
/** Injectable persistence for the per-session topic map —
|
|
224
|
-
*
|
|
222
|
+
/** Injectable destination-scoped persistence for the per-session topic map —
|
|
223
|
+
* default disk persistence checks both `botId` and `chatId`. */
|
|
225
224
|
loadTopicState?: () => Promise<TopicRegistryState>;
|
|
226
225
|
saveTopicState?: (state: TopicRegistryState) => Promise<void>;
|
|
227
226
|
/** Injectable clock for `RateLimitPool` determinism in tests. */
|
|
@@ -248,13 +247,14 @@ type OutboundPayload =
|
|
|
248
247
|
export class TelegramDaemon {
|
|
249
248
|
private readonly WS: typeof WebSocket;
|
|
250
249
|
readonly sessions = new Map<string, SessionConnection>();
|
|
251
|
-
private readonly pendingAcks = new Map<string, (ok: boolean) => void>();
|
|
250
|
+
private readonly pendingAcks = new Map<string, { conn: SessionConnection; resolve: (ok: boolean) => void; timer: NodeJS.Timeout }>();
|
|
252
251
|
private updateOffset: number | undefined;
|
|
253
252
|
/** Current error-poll retry delay; starts at `POLL_ERROR_BACKOFF_MIN_MS`,
|
|
254
253
|
* doubles (capped at `POLL_ERROR_BACKOFF_MAX_MS`) on each consecutive
|
|
255
254
|
* non-ok/failed `getUpdates`, and resets on the next success. */
|
|
256
255
|
private pollErrorBackoffMs = POLL_ERROR_BACKOFF_MIN_MS;
|
|
257
256
|
private stopped = false;
|
|
257
|
+
private cancelBackoff: (() => void) | undefined;
|
|
258
258
|
private scanTimer: ReturnType<typeof setInterval> | undefined;
|
|
259
259
|
private poolTimer: ReturnType<typeof setInterval> | undefined;
|
|
260
260
|
/** `undefined` when `perSessionTopics` is off — the flat/global `topicId`
|
|
@@ -337,6 +337,7 @@ export class TelegramDaemon {
|
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
async scanSessions(): Promise<void> {
|
|
340
|
+
if (this.stopped) return;
|
|
340
341
|
const readdir = this.opts.readdir ?? (d => fs.readdir(d));
|
|
341
342
|
let files: string[];
|
|
342
343
|
try {
|
|
@@ -347,13 +348,16 @@ export class TelegramDaemon {
|
|
|
347
348
|
const readFile = this.opts.readFile ?? (p => fs.readFile(p, "utf-8"));
|
|
348
349
|
const unlink = this.opts.unlink ?? (p => fs.unlink(p));
|
|
349
350
|
const pidAlive = this.opts.isPidAlive ?? isPidAlive;
|
|
350
|
-
for (const file of files.filter(f => f
|
|
351
|
+
for (const file of files.filter(f => /^[A-Za-z0-9_-]+\.json$/.test(f))) {
|
|
352
|
+
if (this.stopped) return;
|
|
351
353
|
const sessionId = path.basename(file, ".json");
|
|
352
354
|
if (this.sessions.has(sessionId)) continue;
|
|
353
355
|
const filePath = path.join(notifySessionsDir(), file);
|
|
354
356
|
try {
|
|
355
357
|
const raw = await readFile(filePath);
|
|
356
|
-
|
|
358
|
+
if (this.stopped) return;
|
|
359
|
+
const endpoint = parseSessionEndpoint(raw);
|
|
360
|
+
if (!endpoint) continue;
|
|
357
361
|
if (!pidAlive(endpoint.pid)) {
|
|
358
362
|
await unlink(filePath).catch(() => {});
|
|
359
363
|
continue;
|
|
@@ -393,7 +397,7 @@ export class TelegramDaemon {
|
|
|
393
397
|
// instead (still fully functional for subagent-status pushes, just without
|
|
394
398
|
// the per-session surface). Checked once per daemon lifetime and cached —
|
|
395
399
|
// chat type cannot change without re-running `jeo notify setup` anyway.
|
|
396
|
-
if (!(await this.pairedChatIsPrivate())) return this.opts.topicId;
|
|
400
|
+
if (!(await this.pairedChatIsPrivate()) || this.stopped) return this.opts.topicId;
|
|
397
401
|
const provisional = conn.identity
|
|
398
402
|
? `${conn.identity.repo}${conn.identity.branch ? `@${conn.identity.branch}` : ""}`
|
|
399
403
|
: `session ${shortSessionId(conn.sessionId)}`;
|
|
@@ -434,7 +438,7 @@ export class TelegramDaemon {
|
|
|
434
438
|
|
|
435
439
|
private async persistTopics(): Promise<void> {
|
|
436
440
|
if (!this.topics) return;
|
|
437
|
-
const save = this.opts.saveTopicState ?? defaultSaveTopicState;
|
|
441
|
+
const save = this.opts.saveTopicState ?? (state => defaultSaveTopicState(state, this.opts.botId, this.opts.chatId));
|
|
438
442
|
try {
|
|
439
443
|
await save(this.topics.serialize());
|
|
440
444
|
} catch {
|
|
@@ -473,12 +477,24 @@ export class TelegramDaemon {
|
|
|
473
477
|
}
|
|
474
478
|
|
|
475
479
|
async handleSessionMessage(conn: SessionConnection, raw: string): Promise<void> {
|
|
480
|
+
if (this.stopped) return;
|
|
481
|
+
if (Buffer.byteLength(raw) > 1024 * 1024) return;
|
|
476
482
|
let msg: Record<string, unknown>;
|
|
477
483
|
try {
|
|
478
484
|
msg = JSON.parse(raw);
|
|
479
485
|
} catch {
|
|
480
486
|
return;
|
|
481
487
|
}
|
|
488
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
|
|
489
|
+
if (msg.type === "snapshot") {
|
|
490
|
+
if (msg.sessionId !== conn.sessionId || msg.pid !== conn.pid || !Array.isArray(msg.subagents)) {
|
|
491
|
+
conn.verified = false;
|
|
492
|
+
conn.ws.close();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
conn.verified = true;
|
|
496
|
+
}
|
|
497
|
+
if (!conn.verified || (msg.sessionId !== undefined && msg.sessionId !== conn.sessionId)) return;
|
|
482
498
|
if (msg.type === "snapshot" && Array.isArray(msg.subagents)) {
|
|
483
499
|
const next = msg.subagents as SubagentRecord[];
|
|
484
500
|
const events = diffSubagentTransitions(conn.lastRecords, next);
|
|
@@ -511,9 +527,10 @@ export class TelegramDaemon {
|
|
|
511
527
|
return;
|
|
512
528
|
}
|
|
513
529
|
if (msg.type === "ack" && typeof msg.reqId === "string") {
|
|
514
|
-
const
|
|
515
|
-
if (
|
|
516
|
-
|
|
530
|
+
const pending = this.pendingAcks.get(msg.reqId);
|
|
531
|
+
if (pending?.conn === conn) {
|
|
532
|
+
clearTimeout(pending.timer);
|
|
533
|
+
pending.resolve(msg.ok === true);
|
|
517
534
|
this.pendingAcks.delete(msg.reqId);
|
|
518
535
|
}
|
|
519
536
|
return;
|
|
@@ -572,25 +589,32 @@ export class TelegramDaemon {
|
|
|
572
589
|
|
|
573
590
|
private findSession(shortId: string): SessionConnection | undefined {
|
|
574
591
|
const needle = shortId.toLowerCase();
|
|
592
|
+
if (!needle) return undefined;
|
|
593
|
+
let match: SessionConnection | undefined;
|
|
575
594
|
for (const conn of this.sessions.values()) {
|
|
576
|
-
if (shortSessionId(conn.sessionId) === needle || conn.sessionId.toLowerCase().startsWith(needle))
|
|
595
|
+
if (shortSessionId(conn.sessionId).toLowerCase() === needle || conn.sessionId.toLowerCase().startsWith(needle)) {
|
|
596
|
+
if (match) return undefined;
|
|
597
|
+
match = conn;
|
|
598
|
+
}
|
|
577
599
|
}
|
|
578
|
-
return
|
|
600
|
+
return match;
|
|
579
601
|
}
|
|
580
602
|
|
|
581
603
|
private sendRequest(conn: SessionConnection, frame: Record<string, unknown>): Promise<boolean> {
|
|
604
|
+
if (this.stopped || !conn.verified) return Promise.resolve(false);
|
|
582
605
|
const reqId = crypto.randomUUID();
|
|
583
606
|
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
584
|
-
|
|
585
|
-
setTimeout(() => {
|
|
607
|
+
const timer = setTimeout(() => {
|
|
586
608
|
if (this.pendingAcks.has(reqId)) {
|
|
587
609
|
this.pendingAcks.delete(reqId);
|
|
588
610
|
resolve(false);
|
|
589
611
|
}
|
|
590
612
|
}, this.opts.ackTimeoutMs ?? 3_000);
|
|
613
|
+
this.pendingAcks.set(reqId, { conn, resolve, timer });
|
|
591
614
|
try {
|
|
592
615
|
conn.ws.send(JSON.stringify({ ...frame, reqId }));
|
|
593
616
|
} catch {
|
|
617
|
+
clearTimeout(timer);
|
|
594
618
|
this.pendingAcks.delete(reqId);
|
|
595
619
|
resolve(false);
|
|
596
620
|
}
|
|
@@ -639,13 +663,11 @@ export class TelegramDaemon {
|
|
|
639
663
|
* A config command (`/verbose`, `/lean`, `/redact on|off`) is applied to
|
|
640
664
|
* THAT session only; anything else (including a media-only message with no
|
|
641
665
|
* caption) is forwarded as free-text steering, with any attached photo/
|
|
642
|
-
* image-document downloaded first. A
|
|
643
|
-
*
|
|
644
|
-
* confirmation — gjc's own two-stage queued→consumed reaction protocol
|
|
645
|
-
* requires a round-trip ack frame this version does not add). */
|
|
666
|
+
* image-document downloaded first. A reaction confirms endpoint acceptance;
|
|
667
|
+
* remote slash commands other than the presentation settings are rejected. */
|
|
646
668
|
private async handleSessionTopicInbound(sessionId: string, msg: NonNullable<TelegramUpdate["message"]>): Promise<void> {
|
|
647
669
|
const conn = this.sessions.get(sessionId);
|
|
648
|
-
if (!conn) return; //
|
|
670
|
+
if (!conn?.verified) return; // disconnected or not yet authenticated by its snapshot
|
|
649
671
|
const text = msg.text ?? msg.caption ?? "";
|
|
650
672
|
const hasMedia = !!(msg.photo?.length || msg.document);
|
|
651
673
|
const cfg = !hasMedia ? parseInThreadConfigCommand(text) : undefined;
|
|
@@ -653,6 +675,7 @@ export class TelegramDaemon {
|
|
|
653
675
|
conn.ws.send(JSON.stringify({ type: "config_command", sessionId, ...cfg }));
|
|
654
676
|
return;
|
|
655
677
|
}
|
|
678
|
+
if (typeof text !== "string" || text.trimStart().startsWith("/")) return;
|
|
656
679
|
const imagePaths: string[] = [];
|
|
657
680
|
if (msg.photo?.length) {
|
|
658
681
|
const largest = msg.photo[msg.photo.length - 1]!;
|
|
@@ -663,39 +686,42 @@ export class TelegramDaemon {
|
|
|
663
686
|
if (p) imagePaths.push(p);
|
|
664
687
|
}
|
|
665
688
|
if (!text && imagePaths.length === 0) return; // a non-image document with no caption — nothing usable to forward
|
|
666
|
-
|
|
667
|
-
await this.reactToMessage(msg.message_id, "👀");
|
|
689
|
+
const ok = await this.sendRequest(conn, { type: "user_message", sessionId, text, imagePaths: imagePaths.length ? imagePaths : undefined });
|
|
690
|
+
if (ok) await this.reactToMessage(msg.message_id, "👀");
|
|
668
691
|
}
|
|
669
692
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
*
|
|
693
|
+
private authorized(chat: NonNullable<TelegramUpdate["message"]>["chat"], from: TelegramCallbackQuery["from"] | undefined): boolean {
|
|
694
|
+
if (!chat || String(chat.id) !== this.opts.chatId || !from || from.is_bot !== false || !Number.isSafeInteger(from.id)) return false;
|
|
695
|
+
if (this.opts.allowedUserIds !== undefined) return this.opts.allowedUserIds.includes(String(from.id));
|
|
696
|
+
return chat.type === "private" && String(from.id) === this.opts.chatId;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Only an authorized human in the paired chat can control sessions. Groups
|
|
700
|
+
* require an explicit allowlist; legacy private chats require owner identity.
|
|
701
|
+
* Session topics route solely to their owner; all other input must match the
|
|
702
|
+
* configured flat topic, including when the inbound topic is absent. */
|
|
678
703
|
async handleUpdate(update: TelegramUpdate): Promise<void> {
|
|
704
|
+
if (!update || typeof update !== "object") return;
|
|
679
705
|
if (update.callback_query) {
|
|
680
706
|
await this.handleCallbackQuery(update.callback_query);
|
|
681
707
|
return;
|
|
682
708
|
}
|
|
683
709
|
const msg = update.message;
|
|
684
710
|
if (!msg) return;
|
|
685
|
-
if (
|
|
711
|
+
if (!this.authorized(msg.chat, msg.from)) return;
|
|
686
712
|
|
|
687
713
|
const ownerSessionId = this.topics && msg.message_thread_id !== undefined
|
|
688
714
|
? this.topics.sessionForTopic(msg.message_thread_id)
|
|
689
715
|
: undefined;
|
|
690
|
-
if (ownerSessionId) {
|
|
716
|
+
if (ownerSessionId && await this.pairedChatIsPrivate()) {
|
|
691
717
|
await this.handleSessionTopicInbound(ownerSessionId, msg);
|
|
692
718
|
return;
|
|
693
719
|
}
|
|
694
720
|
|
|
695
|
-
if (!msg.text) return;
|
|
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;
|
|
696
723
|
if (
|
|
697
724
|
this.opts.topicId !== undefined &&
|
|
698
|
-
msg.message_thread_id !== undefined &&
|
|
699
725
|
msg.message_thread_id !== this.opts.topicId
|
|
700
726
|
) {
|
|
701
727
|
return;
|
|
@@ -710,23 +736,19 @@ export class TelegramDaemon {
|
|
|
710
736
|
* The tap is always acknowledged (Telegram shows a spinner until
|
|
711
737
|
* `answerCallbackQuery`). */
|
|
712
738
|
async handleCallbackQuery(cb: TelegramCallbackQuery): Promise<void> {
|
|
713
|
-
|
|
714
|
-
if (chatId !== undefined && String(chatId) !== this.opts.chatId) {
|
|
739
|
+
if (!cb.message || !Number.isSafeInteger(cb.message.message_id) || !this.authorized(cb.message.chat, cb.from)) {
|
|
715
740
|
await this.answerCallback(cb.id);
|
|
716
741
|
return;
|
|
717
742
|
}
|
|
718
|
-
const threadId = cb.message
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
threadId !== this.opts.topicId &&
|
|
724
|
-
!inKnownSessionTopic
|
|
725
|
-
) {
|
|
743
|
+
const threadId = cb.message.message_thread_id;
|
|
744
|
+
const ownerSessionId = this.topics && threadId !== undefined && await this.pairedChatIsPrivate()
|
|
745
|
+
? this.topics.sessionForTopic(threadId) : undefined;
|
|
746
|
+
if (!ownerSessionId && ((this.opts.topicId !== undefined && threadId !== this.opts.topicId) ||
|
|
747
|
+
(this.topics && threadId !== undefined && threadId !== this.opts.topicId))) {
|
|
726
748
|
await this.answerCallback(cb.id);
|
|
727
749
|
return;
|
|
728
750
|
}
|
|
729
|
-
const parsed = cb.data ? parseCallbackData(cb.data) : undefined;
|
|
751
|
+
const parsed = typeof cb.data === "string" ? parseCallbackData(cb.data) : undefined;
|
|
730
752
|
if (!parsed) {
|
|
731
753
|
await this.answerCallback(cb.id);
|
|
732
754
|
return;
|
|
@@ -736,6 +758,10 @@ export class TelegramDaemon {
|
|
|
736
758
|
await this.answerCallback(cb.id, `No connected session matches '${parsed.shortId}'.`);
|
|
737
759
|
return;
|
|
738
760
|
}
|
|
761
|
+
if (ownerSessionId && ownerSessionId !== conn.sessionId) {
|
|
762
|
+
await this.answerCallback(cb.id);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
739
765
|
const ok = await this.sendRequest(conn, { type: "cancel", ids: [parsed.subagentId] });
|
|
740
766
|
await this.answerCallback(cb.id, ok ? `Cancelled '${parsed.subagentId}'.` : "Cancel failed.");
|
|
741
767
|
await this.notify(
|
|
@@ -751,9 +777,20 @@ export class TelegramDaemon {
|
|
|
751
777
|
* hot-loop against the API; growing it also avoids sitting at a single
|
|
752
778
|
* fixed multi-second delay for the entire outage. */
|
|
753
779
|
private async pollErrorBackoff(): Promise<void> {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
this.
|
|
780
|
+
if (this.stopped) return;
|
|
781
|
+
const cancelled = Promise.withResolvers<void>();
|
|
782
|
+
this.cancelBackoff = cancelled.resolve;
|
|
783
|
+
let timer: Timer | undefined;
|
|
784
|
+
try {
|
|
785
|
+
const delay = this.opts.sleep
|
|
786
|
+
? this.opts.sleep(this.pollErrorBackoffMs)
|
|
787
|
+
: new Promise<void>(resolve => { timer = setTimeout(resolve, this.pollErrorBackoffMs); });
|
|
788
|
+
await Promise.race([delay, cancelled.promise]);
|
|
789
|
+
if (!this.stopped) this.pollErrorBackoffMs = Math.min(this.pollErrorBackoffMs * 2, POLL_ERROR_BACKOFF_MAX_MS);
|
|
790
|
+
} finally {
|
|
791
|
+
clearTimeout(timer);
|
|
792
|
+
this.cancelBackoff = undefined;
|
|
793
|
+
}
|
|
757
794
|
}
|
|
758
795
|
|
|
759
796
|
private async pollTelegramLoop(): Promise<void> {
|
|
@@ -765,6 +802,7 @@ export class TelegramDaemon {
|
|
|
765
802
|
await this.pollErrorBackoff();
|
|
766
803
|
continue;
|
|
767
804
|
}
|
|
805
|
+
if (this.stopped) break;
|
|
768
806
|
if (!res.ok) {
|
|
769
807
|
// e.g. 409 Conflict (another getUpdates owner) or 401 (revoked token) —
|
|
770
808
|
// without a pause this would hot-loop against the API.
|
|
@@ -776,6 +814,7 @@ export class TelegramDaemon {
|
|
|
776
814
|
// than staying parked at whatever ceiling a prior outage reached.
|
|
777
815
|
this.pollErrorBackoffMs = POLL_ERROR_BACKOFF_MIN_MS;
|
|
778
816
|
for (const update of res.result) {
|
|
817
|
+
if (this.stopped) break;
|
|
779
818
|
this.updateOffset = update.update_id + 1;
|
|
780
819
|
await this.handleUpdate(update);
|
|
781
820
|
}
|
|
@@ -784,8 +823,9 @@ export class TelegramDaemon {
|
|
|
784
823
|
|
|
785
824
|
/** Blocks (long-poll loop) until `stop()` is called. */
|
|
786
825
|
async start(): Promise<void> {
|
|
826
|
+
if (this.stopped) return;
|
|
787
827
|
if (this.topics) {
|
|
788
|
-
const load = this.opts.loadTopicState ?? defaultLoadTopicState;
|
|
828
|
+
const load = this.opts.loadTopicState ?? (() => defaultLoadTopicState(this.opts.botId, this.opts.chatId));
|
|
789
829
|
try {
|
|
790
830
|
this.topics.load(await load());
|
|
791
831
|
} catch {
|
|
@@ -793,7 +833,9 @@ export class TelegramDaemon {
|
|
|
793
833
|
// (every session's first frame re-creates its topic from scratch).
|
|
794
834
|
}
|
|
795
835
|
}
|
|
836
|
+
if (this.stopped) return;
|
|
796
837
|
await this.scanSessions();
|
|
838
|
+
if (this.stopped) return;
|
|
797
839
|
this.scanTimer = setInterval(() => void this.scanSessions(), this.opts.scanIntervalMs ?? 3_000);
|
|
798
840
|
// Catches anything left queued after a submit-time drain because the
|
|
799
841
|
// burst capacity was briefly exhausted (rare at jeo's realistic message
|
|
@@ -805,8 +847,14 @@ export class TelegramDaemon {
|
|
|
805
847
|
|
|
806
848
|
stop(): void {
|
|
807
849
|
this.stopped = true;
|
|
850
|
+
this.cancelBackoff?.();
|
|
808
851
|
clearInterval(this.scanTimer);
|
|
809
852
|
clearInterval(this.poolTimer);
|
|
853
|
+
for (const pending of this.pendingAcks.values()) {
|
|
854
|
+
clearTimeout(pending.timer);
|
|
855
|
+
pending.resolve(false);
|
|
856
|
+
}
|
|
857
|
+
this.pendingAcks.clear();
|
|
810
858
|
for (const conn of this.sessions.values()) {
|
|
811
859
|
try {
|
|
812
860
|
conn.ws.close();
|
|
@@ -817,20 +865,24 @@ export class TelegramDaemon {
|
|
|
817
865
|
}
|
|
818
866
|
|
|
819
867
|
/** Default inbound-attachment temp-file writer: a private (0700-parent,
|
|
820
|
-
*
|
|
868
|
+
* 0600-mode) file under the OS temp dir, named to survive filesystem
|
|
821
869
|
* weirdness (sanitized, length-capped) while staying traceable to its
|
|
822
870
|
* Telegram origin for debugging. */
|
|
823
871
|
async function defaultWriteTempFile(bytes: Uint8Array, suggestedName: string): Promise<string> {
|
|
824
872
|
const safe = (suggestedName.replace(/[^\w.-]+/g, "_").slice(-128) || "file");
|
|
825
|
-
const
|
|
826
|
-
await fs.
|
|
873
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "jeo-telegram-"));
|
|
874
|
+
await fs.chmod(directory, 0o700);
|
|
875
|
+
const target = path.join(directory, safe === "." || safe === ".." ? "file" : safe);
|
|
876
|
+
await fs.writeFile(target, bytes, { mode: 0o600, flag: "wx" });
|
|
827
877
|
return target;
|
|
828
878
|
}
|
|
829
879
|
|
|
830
|
-
async function defaultLoadTopicState(): Promise<TopicRegistryState> {
|
|
880
|
+
async function defaultLoadTopicState(botId: string | undefined, chatId: string): Promise<TopicRegistryState> {
|
|
881
|
+
if (!botId || !/^[1-9]\d*$/.test(botId)) return { topics: {} };
|
|
831
882
|
try {
|
|
832
883
|
const raw = await fs.readFile(notifyTopicsPath(), "utf-8");
|
|
833
|
-
|
|
884
|
+
const state = JSON.parse(raw) as TopicRegistryState & { botId?: string; chatId?: string };
|
|
885
|
+
return state.botId === botId && state.chatId === chatId ? state : { topics: {} };
|
|
834
886
|
} catch {
|
|
835
887
|
return { topics: {} };
|
|
836
888
|
}
|
|
@@ -838,12 +890,13 @@ async function defaultLoadTopicState(): Promise<TopicRegistryState> {
|
|
|
838
890
|
|
|
839
891
|
/** Atomic temp+rename write (same convention as `state.ts`'s config/workflow
|
|
840
892
|
* persistence) — a torn write must never corrupt the topic map. */
|
|
841
|
-
async function defaultSaveTopicState(state: TopicRegistryState): Promise<void> {
|
|
893
|
+
async function defaultSaveTopicState(state: TopicRegistryState, botId: string | undefined, chatId: string): Promise<void> {
|
|
894
|
+
if (!botId || !/^[1-9]\d*$/.test(botId)) return;
|
|
842
895
|
const target = notifyTopicsPath();
|
|
843
896
|
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
844
897
|
const tmp = `${target}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
845
898
|
try {
|
|
846
|
-
await fs.writeFile(tmp, JSON.stringify(state, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
899
|
+
await fs.writeFile(tmp, JSON.stringify({ ...state, botId, chatId }, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
847
900
|
await fs.rename(tmp, target);
|
|
848
901
|
} catch (err) {
|
|
849
902
|
await fs.unlink(tmp).catch(() => {});
|
|
@@ -853,34 +906,113 @@ async function defaultSaveTopicState(state: TopicRegistryState): Promise<void> {
|
|
|
853
906
|
|
|
854
907
|
// ── CLI entrypoint (`jeo notify-daemon-run`, internal) ──────────────────────────
|
|
855
908
|
|
|
856
|
-
|
|
909
|
+
import { DiscordDaemon } from "./discord-daemon";
|
|
910
|
+
import type { DiscordDaemonConfig, DiscordDaemonDependencies } from "./discord-daemon";
|
|
911
|
+
import { DiscordApiError } from "./discord-api";
|
|
912
|
+
import { SlackDaemon } from "./slack-daemon";
|
|
913
|
+
import type { SlackDaemonConfig, SlackDaemonDependencies } from "./slack-daemon";
|
|
914
|
+
import { SlackApi, SlackApiError } from "./slack-api";
|
|
915
|
+
|
|
916
|
+
interface ForegroundTransport {
|
|
917
|
+
start(): Promise<void>;
|
|
918
|
+
stop(): void;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
export interface NotifyForegroundOptions {
|
|
922
|
+
fetchImpl?: typeof fetch;
|
|
923
|
+
signals?: {
|
|
924
|
+
on(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown;
|
|
925
|
+
off(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown;
|
|
926
|
+
};
|
|
927
|
+
createTelegram?: (options: TelegramDaemonOptions) => ForegroundTransport;
|
|
928
|
+
createDiscord?: (config: DiscordDaemonConfig, dependencies: DiscordDaemonDependencies) => ForegroundTransport;
|
|
929
|
+
createSlack?: (config: SlackDaemonConfig, dependencies: SlackDaemonDependencies) => ForegroundTransport;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
export async function runNotifyDaemonForeground(options: NotifyForegroundOptions = {}): Promise<void> {
|
|
857
933
|
const lock = await acquireDaemonLock();
|
|
858
934
|
if (!lock) {
|
|
859
|
-
process.stderr.write("[jeo notify-daemon] another daemon
|
|
935
|
+
process.stderr.write("[jeo notify-daemon] another daemon or pairing process holds the lock; exiting.\n");
|
|
936
|
+
process.exitCode = 1;
|
|
860
937
|
return;
|
|
861
938
|
}
|
|
939
|
+
const transports: { stop(): void }[] = [];
|
|
940
|
+
const polling = new AbortController();
|
|
941
|
+
const shutdown = Promise.withResolvers<void>();
|
|
942
|
+
let stopping = false;
|
|
943
|
+
let telegramRun: Promise<void> | undefined;
|
|
944
|
+
const stop = () => {
|
|
945
|
+
if (stopping) return;
|
|
946
|
+
stopping = true;
|
|
947
|
+
for (const transport of transports) transport.stop();
|
|
948
|
+
polling.abort();
|
|
949
|
+
shutdown.resolve();
|
|
950
|
+
};
|
|
951
|
+
const signals = options.signals ?? process;
|
|
952
|
+
signals.on("SIGTERM", stop);
|
|
953
|
+
signals.on("SIGINT", stop);
|
|
862
954
|
try {
|
|
863
|
-
const
|
|
864
|
-
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
955
|
+
const n = (await readGlobalConfig()).notifications;
|
|
956
|
+
if (!n?.enabled) throw new Error("notifications disabled; run jeo notify setup first");
|
|
957
|
+
const discord = n.discord;
|
|
958
|
+
if (discord?.botToken && discord.channelId && discord.allowedUserIds?.length) {
|
|
959
|
+
const daemon = (options.createDiscord ?? ((config, dependencies) => new DiscordDaemon(config, dependencies)))({ botToken: discord.botToken, channelId: discord.channelId, allowedUserIds: discord.allowedUserIds }, {
|
|
960
|
+
onError: message => process.stderr.write(`[jeo notify-daemon] Discord: ${message}\n`),
|
|
961
|
+
onFatal: () => {
|
|
962
|
+
if (stopping) return;
|
|
963
|
+
process.exitCode = 1;
|
|
964
|
+
stop();
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
transports.push(daemon);
|
|
968
|
+
await daemon.start();
|
|
969
|
+
}
|
|
970
|
+
const slack = n.slack;
|
|
971
|
+
if (!stopping && slack?.botToken && slack.appToken && slack.channelId && slack.allowedUserIds?.length) {
|
|
972
|
+
const daemon = (options.createSlack ?? ((config, dependencies) => new SlackDaemon(config, dependencies)))(slack, {
|
|
973
|
+
api: new SlackApi(slack.botToken, slack.appToken, { fetchImpl: options.fetchImpl }),
|
|
974
|
+
onError: message => process.stderr.write(`[jeo notify-daemon] Slack: ${message}\n`),
|
|
975
|
+
onFatal: () => {
|
|
976
|
+
if (stopping) return;
|
|
977
|
+
process.exitCode = 1;
|
|
978
|
+
stop();
|
|
979
|
+
},
|
|
980
|
+
});
|
|
981
|
+
transports.push(daemon);
|
|
982
|
+
await daemon.start();
|
|
983
|
+
}
|
|
984
|
+
const telegram = n.telegram;
|
|
985
|
+
if (!stopping && telegram?.botToken && telegram.chatId) {
|
|
986
|
+
const api = new TelegramApi(telegram.botToken, ((input, init) => (options.fetchImpl ?? fetch)(input, { ...init, signal: AbortSignal.any([polling.signal, AbortSignal.timeout(35_000)]) })) as typeof fetch);
|
|
987
|
+
const me = await api.getMe();
|
|
988
|
+
const chat = await api.getChat(telegram.chatId);
|
|
989
|
+
if (!me.ok || !me.result?.is_bot || !chat.ok || !chat.result?.type) throw new Error("Telegram requires valid bot credentials and an accessible chat");
|
|
990
|
+
if (chat.result.type !== "private" && !telegram.allowedUserIds?.length) throw new Error("Telegram requires allowedUserIds for group control");
|
|
991
|
+
if (stopping) return;
|
|
992
|
+
const daemon = (options.createTelegram ?? (config => new TelegramDaemon(config)))({ ...telegram, chatId: telegram.chatId, botId: String(me.result.id), telegram: api });
|
|
993
|
+
transports.push(daemon);
|
|
994
|
+
telegramRun = daemon.start().catch(() => {
|
|
995
|
+
if (!stopping) {
|
|
996
|
+
process.stderr.write("[jeo notify-daemon] Telegram transport failed; check credentials and connectivity.\n");
|
|
997
|
+
process.exitCode = 1;
|
|
998
|
+
stop();
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
if (!transports.length) throw new Error("notifications not configured; run jeo notify setup first");
|
|
1003
|
+
if (!stopping) await lock.markReady();
|
|
1004
|
+
await shutdown.promise;
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
if (stopping) return;
|
|
1007
|
+
process.stderr.write(`[jeo notify-daemon] startup failed: ${error instanceof DiscordApiError || error instanceof SlackApiError ? `${error.message}${error.status ? ` (HTTP ${error.status})` : ""}` : error instanceof Error && /^(notifications |Discord requires|Slack requires|Telegram requires)/.test(error.message) ? error.message : "check platform credentials, permissions and connectivity"}.\n`);
|
|
1008
|
+
process.exitCode = 1;
|
|
883
1009
|
} finally {
|
|
1010
|
+
stop();
|
|
1011
|
+
await telegramRun;
|
|
1012
|
+
// A start in flight may finish after a signal; stop those resources as well.
|
|
1013
|
+
for (const transport of transports) transport.stop();
|
|
1014
|
+
signals.off("SIGTERM", stop);
|
|
1015
|
+
signals.off("SIGINT", stop);
|
|
884
1016
|
await lock.release();
|
|
885
1017
|
}
|
|
886
1018
|
}
|
package/src/agent/state.ts
CHANGED
|
@@ -64,7 +64,7 @@ export interface Config {
|
|
|
64
64
|
onComplete?: boolean;
|
|
65
65
|
onAsk?: boolean;
|
|
66
66
|
};
|
|
67
|
-
/** Remote
|
|
67
|
+
/** Remote session visibility/control over Telegram, Discord, and Slack. */
|
|
68
68
|
notifications?: {
|
|
69
69
|
enabled?: boolean;
|
|
70
70
|
/** Session-local default; see `config-schema.ts` for the mutability contract. */
|
|
@@ -74,12 +74,24 @@ export interface Config {
|
|
|
74
74
|
telegram?: {
|
|
75
75
|
botToken?: string;
|
|
76
76
|
chatId?: string;
|
|
77
|
+
allowedUserIds?: string[];
|
|
77
78
|
/** Forum-topic thread id (message_thread_id) for daemon pushes. */
|
|
78
79
|
topicId?: number;
|
|
79
80
|
/** Auto-create/manage one forum topic per interactive session instead of
|
|
80
81
|
* the flat/global `topicId` above. See `TopicRegistry`. */
|
|
81
82
|
perSessionTopics?: boolean;
|
|
82
83
|
};
|
|
84
|
+
discord?: {
|
|
85
|
+
botToken?: string;
|
|
86
|
+
channelId?: string;
|
|
87
|
+
allowedUserIds?: string[];
|
|
88
|
+
};
|
|
89
|
+
slack?: {
|
|
90
|
+
botToken: string;
|
|
91
|
+
appToken: string;
|
|
92
|
+
channelId: string;
|
|
93
|
+
allowedUserIds: string[];
|
|
94
|
+
};
|
|
83
95
|
};
|
|
84
96
|
|
|
85
97
|
thinkingLevel?: "low" | "medium" | "high" | "xhigh";
|
|
@@ -70,13 +70,6 @@ const DEFAULT_LIMIT = 100;
|
|
|
70
70
|
const CODEX_CLIENT_VERSION = "2.0.0";
|
|
71
71
|
export const CODEX_MODELS_URL = `https://chatgpt.com/backend-api/codex/models?client_version=${CODEX_CLIENT_VERSION}`;
|
|
72
72
|
const ANTIGRAVITY_MODELS_URL = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
|
|
73
|
-
const ANTIGRAVITY_MODEL_DENYLIST = new Set([
|
|
74
|
-
"chat_20706",
|
|
75
|
-
"chat_23310",
|
|
76
|
-
"gemini-2.5-flash-thinking",
|
|
77
|
-
"gemini-3-pro-low",
|
|
78
|
-
"gemini-2.5-pro",
|
|
79
|
-
]);
|
|
80
73
|
|
|
81
74
|
function anthropicHeaders(cred: Credential): Record<string, string> {
|
|
82
75
|
if (cred.kind === "oauth") {
|
|
@@ -247,10 +240,11 @@ export function parseModelsBody(provider: ProviderName, body: unknown, opts: { o
|
|
|
247
240
|
/** Array of ids OR an object keyed by deprecated id. */
|
|
248
241
|
deprecatedModelIds?: string[] | Record<string, unknown>;
|
|
249
242
|
};
|
|
250
|
-
const
|
|
243
|
+
const normalizeId = (id: string): string => id.replace(/^models\//, "").replace(/^antigravity\//, "");
|
|
244
|
+
const roleIds = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string").map(normalizeId) : []);
|
|
251
245
|
const deprecated = Array.isArray(payload.deprecatedModelIds)
|
|
252
246
|
? roleIds(payload.deprecatedModelIds)
|
|
253
|
-
: Object.keys(payload.deprecatedModelIds ?? {});
|
|
247
|
+
: Object.keys(payload.deprecatedModelIds ?? {}).map(normalizeId);
|
|
254
248
|
const agentIds = new Set(
|
|
255
249
|
(Array.isArray(payload.agentModelSorts) ? payload.agentModelSorts : [])
|
|
256
250
|
.flatMap(sort => (Array.isArray(sort?.groups) ? sort.groups : []))
|
|
@@ -265,18 +259,16 @@ export function parseModelsBody(provider: ProviderName, body: unknown, opts: { o
|
|
|
265
259
|
...deprecated,
|
|
266
260
|
]);
|
|
267
261
|
const rawModels = payload.models;
|
|
268
|
-
const
|
|
269
|
-
? rawModels.map(
|
|
270
|
-
: Object.entries(rawModels ?? {})
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
.map(id => id
|
|
278
|
-
.filter(Boolean)
|
|
279
|
-
.map(id => id.startsWith("antigravity/") ? id : `antigravity/${id}`);
|
|
262
|
+
const entries = Array.isArray(rawModels)
|
|
263
|
+
? rawModels.map(model => [model?.slug ?? model?.id ?? model?.name ?? "", model] as const)
|
|
264
|
+
: Object.entries(rawModels ?? {});
|
|
265
|
+
return entries
|
|
266
|
+
.map(([id, model]) => [typeof id === "string" ? normalizeId(id) : "", model] as const)
|
|
267
|
+
.filter(([id, model]) =>
|
|
268
|
+
id.length > 0 &&
|
|
269
|
+
model?.isInternal !== true &&
|
|
270
|
+
(agentIds.size > 0 ? agentIds.has(id) : !nonChat.has(id)))
|
|
271
|
+
.map(([id]) => `antigravity/${id}`);
|
|
280
272
|
}
|
|
281
273
|
if (provider === "gemini") {
|
|
282
274
|
// Keep only models the generateContent endpoint can serve (skip embeddings/tts/aqa/etc).
|