codeep 3.0.0 → 3.1.0

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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Starting work from the phone.
3
+ *
4
+ * Approval lets the phone answer a question the agent asked. This lets the
5
+ * phone ask one — which is a different thing, and a larger one. A chat that can
6
+ * only say Run, Skip or Cancel is bounded by what the agent already decided to
7
+ * do; a chat that can send a prompt is a keyboard attached to this machine.
8
+ * That is why it is off by default and has its own switch rather than riding on
9
+ * the approval one.
10
+ */
11
+ import { sharedUpdates } from './telegramUpdates.js';
12
+ /** Longest prompt accepted. Past this it is a paste, not an instruction. */
13
+ export const MAX_PROMPT_LENGTH = 2000;
14
+ /**
15
+ * How old a message may be and still run.
16
+ *
17
+ * Telegram keeps undelivered updates for 24 hours. Without this, a message
18
+ * typed at midnight to a machine that was switched off would be handed to the
19
+ * agent the moment the CLI next started — hours later, in a repository that has
20
+ * moved on, with nobody watching. A prompt is an instruction for now.
21
+ */
22
+ export const MAX_MESSAGE_AGE_MS = 5 * 60_000;
23
+ /**
24
+ * Whether a message update came from the one chat allowed to drive this agent.
25
+ *
26
+ * A bot's username is discoverable, so without this anyone who found it could
27
+ * type into somebody else's terminal. Mirrors the approval check deliberately:
28
+ * both gate the same machine, and they should not be able to disagree.
29
+ */
30
+ export function messageFromOwner(message, chatID) {
31
+ if (!chatID)
32
+ return false;
33
+ const chat = message?.chat;
34
+ const id = chat?.id;
35
+ if (typeof id === 'number')
36
+ return String(id) === chatID;
37
+ if (typeof id === 'string')
38
+ return id === chatID;
39
+ return false;
40
+ }
41
+ /**
42
+ * Turn a Telegram message into a prompt, or say why it is not one.
43
+ *
44
+ * Returns a reason rather than null throughout: every rejection here has a
45
+ * different thing to tell the sender, and a bot that goes quiet is
46
+ * indistinguishable from one that is switched off.
47
+ */
48
+ export function extractPrompt(message, chatID, now = Date.now()) {
49
+ if (!messageFromOwner(message, chatID))
50
+ return { ok: false, reason: 'not-from-owner' };
51
+ const text = message?.text;
52
+ // A photo, sticker or voice note has no `text` at all.
53
+ if (typeof text !== 'string')
54
+ return { ok: false, reason: 'not-text' };
55
+ const trimmed = text.trim();
56
+ if (trimmed.length === 0)
57
+ return { ok: false, reason: 'empty' };
58
+ if (trimmed.length > MAX_PROMPT_LENGTH)
59
+ return { ok: false, reason: 'too-long' };
60
+ // `/start` and friends are addressed to the bot, not to the agent.
61
+ if (trimmed.startsWith('/'))
62
+ return { ok: false, reason: 'command' };
63
+ const date = message?.date;
64
+ if (typeof date === 'number' && now - date * 1000 > MAX_MESSAGE_AGE_MS) {
65
+ return { ok: false, reason: 'stale' };
66
+ }
67
+ return { ok: true, text: trimmed };
68
+ }
69
+ /** What to say back, so silence never has to be interpreted. */
70
+ export function describeRejection(reason) {
71
+ switch (reason) {
72
+ // Answering a stranger confirms the bot is live and attached to something
73
+ // worth probing. They get nothing.
74
+ case 'not-from-owner': return null;
75
+ case 'not-text': return 'I can only take text — send the instruction as a message.';
76
+ case 'empty': return null;
77
+ case 'too-long': return `That is longer than ${MAX_PROMPT_LENGTH} characters. Send a shorter instruction, or paste the text into the terminal.`;
78
+ case 'stale': return 'That message is older than five minutes, so I have not run it. Send it again if you still want it.';
79
+ case 'command': return 'Send an instruction in plain words — I do not take slash commands here.';
80
+ }
81
+ }
82
+ /**
83
+ * The one instruction waiting for the agent to be free.
84
+ *
85
+ * One slot, and a newer message replaces an older one. A queue that grows is a
86
+ * queue that runs things you have forgotten asking for: send two corrections
87
+ * while a run is busy and you meant the second, not both in order. Replacing
88
+ * says so out loud rather than quietly dropping either.
89
+ */
90
+ export class InboxQueue {
91
+ pending = null;
92
+ /** @returns whether this displaced an instruction that had not run yet. */
93
+ offer(text) {
94
+ const replaced = this.pending !== null;
95
+ this.pending = text;
96
+ return { replaced };
97
+ }
98
+ /** Hand over what is waiting, and empty the slot. */
99
+ take() {
100
+ const out = this.pending;
101
+ this.pending = null;
102
+ return out;
103
+ }
104
+ get waiting() {
105
+ return this.pending !== null;
106
+ }
107
+ clear() {
108
+ this.pending = null;
109
+ }
110
+ }
111
+ /** Confirmation for a prompt that will run later, so the sender can stop wondering. */
112
+ export function describeQueued(replaced) {
113
+ return replaced
114
+ ? 'Queued — replaces the one you sent before it. It runs when the current task finishes.'
115
+ : 'Queued — it runs when the current task finishes.';
116
+ }
117
+ /** Confirmation for a prompt that starts immediately. */
118
+ export function describeStarted(text) {
119
+ const short = text.length > 60 ? `${text.slice(0, 57)}…` : text;
120
+ return `Started — ${short}`;
121
+ }
122
+ // ─── Whose run is it ──────────────────────────────────────────────────────────
123
+ /**
124
+ * Whether the run now finishing was started from the phone.
125
+ *
126
+ * It decides whether the agent's answer travels. Start a run at the terminal
127
+ * and the answer is already on the screen you are sitting at; sending it to
128
+ * Telegram would put a file's contents, a command line, or a secret inside an
129
+ * error message into a chat that syncs to Telegram's servers, for nothing. Ask
130
+ * from the phone and the answer is the entire point — you are not at the desk,
131
+ * and "finished, 33K tokens" answers nothing you asked.
132
+ *
133
+ * Module-level because the inbox and the notice sit either side of the run and
134
+ * share no object: the inbox is wired up once in main.ts, the notice fires deep
135
+ * inside executeAgentTask.
136
+ */
137
+ let startedFromPhone = false;
138
+ export function markRunFromPhone() {
139
+ startedFromPhone = true;
140
+ }
141
+ /** True once per run that came from the phone, then false again. */
142
+ export function takeRunFromPhone() {
143
+ const was = startedFromPhone;
144
+ startedFromPhone = false;
145
+ return was;
146
+ }
147
+ /**
148
+ * Listen for instructions from the phone until the returned function is called.
149
+ *
150
+ * The queue is drained by `drain`, which the host calls when a run ends —
151
+ * rather than polled from here — so that "the agent is free" is decided in one
152
+ * place instead of two that can disagree.
153
+ */
154
+ export function attachTelegramInbox(credentials, host) {
155
+ const queue = new InboxQueue();
156
+ const unsubscribe = sharedUpdates(credentials.botToken).subscribe('message', message => {
157
+ const result = extractPrompt(message, credentials.chatID);
158
+ if (!result.ok) {
159
+ const explanation = describeRejection(result.reason);
160
+ if (explanation)
161
+ host.reply(explanation);
162
+ return;
163
+ }
164
+ if (host.isBusy()) {
165
+ host.reply(describeQueued(queue.offer(result.text).replaced));
166
+ return;
167
+ }
168
+ host.reply(describeStarted(result.text));
169
+ markRunFromPhone();
170
+ host.submit(result.text);
171
+ });
172
+ return {
173
+ stop: () => { unsubscribe(); queue.clear(); },
174
+ drain: () => {
175
+ // Re-check busy: a run can start from the terminal between the last one
176
+ // ending and this firing, and two agents on one workspace is not a thing
177
+ // this queue gets to cause.
178
+ if (host.isBusy())
179
+ return;
180
+ const next = queue.take();
181
+ if (next === null)
182
+ return;
183
+ host.reply(describeStarted(next));
184
+ markRunFromPhone();
185
+ host.submit(next);
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,76 @@
1
+ import type { TelegramCredentials } from './telegramApproval';
2
+ /**
3
+ * Runs shorter than this are not worth a notification.
4
+ *
5
+ * The problem this solves is walking away — starting something substantial,
6
+ * making coffee, and not knowing it finished. A `git status` that returned in
7
+ * two seconds was never going to outlast your attention, and a phone that
8
+ * buzzes for every one of those gets muted within a day, taking the
9
+ * notifications that mattered with it.
10
+ */
11
+ export declare const NOTIFY_AFTER_MS = 60000;
12
+ export declare function shouldNotify(elapsedMs: number, enabled: boolean): boolean;
13
+ /** `92s` / `4m 12s` / `1h 03m` — short enough to read on a lock screen. */
14
+ export declare function formatDuration(ms: number): string;
15
+ /**
16
+ * Telegram refuses a message over 4096 characters outright.
17
+ *
18
+ * Kept well under: the rest of the summary shares the message, and a reply
19
+ * that fills a phone screen twice over is not read on a phone anyway.
20
+ */
21
+ export declare const MAX_ANSWER_LENGTH = 3000;
22
+ /**
23
+ * Markdown out, plain words in.
24
+ *
25
+ * The agent writes for a terminal that renders markdown, so its answer arrives
26
+ * on a phone as `**140 datoteka**` — asterisks and backticks read as noise
27
+ * exactly where the answer should be easiest to read.
28
+ *
29
+ * Stripped rather than handed to Telegram as `parse_mode`, which would be the
30
+ * obvious fix and the wrong one: Telegram rejects a whole message whose markup
31
+ * is unbalanced, and an agent's answer is arbitrary text. One stray asterisk
32
+ * and the notice does not arrive at all. Ugly beats missing, and this is
33
+ * neither.
34
+ *
35
+ * Deliberately conservative. Only paired markers are touched, and single
36
+ * underscores are left alone entirely — `execute_command` and `snake_case`
37
+ * appear in these answers constantly, and mangling an identifier to italicise
38
+ * nothing is worse than leaving a marker visible.
39
+ */
40
+ export declare function stripMarkdown(text: string): string;
41
+ export interface RunSummary {
42
+ /** What the run was called — the session display name, not the full prompt. */
43
+ task: string;
44
+ elapsedMs: number;
45
+ /** Set when the run ended on an error, so the message can say so. */
46
+ failure?: string;
47
+ tokens?: number;
48
+ /** Pay-per-use dollars. Omitted on a flat-fee plan, where any figure is invented. */
49
+ costUsd?: number;
50
+ /**
51
+ * The agent's own reply, for a run that was started from the phone.
52
+ *
53
+ * Omitted for a run started at the terminal, where the answer is already on
54
+ * the screen the person is sitting at and sending it would put file contents
55
+ * or a command line into a chat for nothing.
56
+ */
57
+ answer?: string;
58
+ }
59
+ /**
60
+ * The notification text.
61
+ *
62
+ * Carries the agent's answer only when the run was started from the phone. A
63
+ * finished run can end with anything in it — a file it read, a command it ran,
64
+ * a secret inside an error — and this goes to a chat that syncs to Telegram's
65
+ * servers, so it travels only where it was actually asked for. Start a run at
66
+ * the terminal and this says there is a result to come back to, and no more.
67
+ */
68
+ export declare function composeRunSummary(summary: RunSummary): string;
69
+ /**
70
+ * Send it, and say nothing if it fails.
71
+ *
72
+ * A notification that could not be delivered is not worth interrupting the
73
+ * terminal for — the run already finished and its result is on screen. Returns
74
+ * whether it went, so a caller that does care can look.
75
+ */
76
+ export declare function sendTelegramNotice(credentials: TelegramCredentials, text: string): Promise<boolean>;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Telling you the run is done, on the phone that already answers its questions.
3
+ *
4
+ * Separate from TelegramApproval on purpose: that class exists to ask a
5
+ * question and wait for one of three answers, and none of its machinery — the
6
+ * keyboard, the token, the outstanding-message bookkeeping — means anything
7
+ * for a message nobody replies to.
8
+ */
9
+ const API = 'https://api.telegram.org/bot';
10
+ const REQUEST_TIMEOUT_MS = 10_000;
11
+ /**
12
+ * Runs shorter than this are not worth a notification.
13
+ *
14
+ * The problem this solves is walking away — starting something substantial,
15
+ * making coffee, and not knowing it finished. A `git status` that returned in
16
+ * two seconds was never going to outlast your attention, and a phone that
17
+ * buzzes for every one of those gets muted within a day, taking the
18
+ * notifications that mattered with it.
19
+ */
20
+ export const NOTIFY_AFTER_MS = 60_000;
21
+ export function shouldNotify(elapsedMs, enabled) {
22
+ return enabled && elapsedMs >= NOTIFY_AFTER_MS;
23
+ }
24
+ /** `92s` / `4m 12s` / `1h 03m` — short enough to read on a lock screen. */
25
+ export function formatDuration(ms) {
26
+ const seconds = Math.round(ms / 1000);
27
+ if (seconds < 90)
28
+ return `${seconds}s`;
29
+ const minutes = Math.floor(seconds / 60);
30
+ if (minutes < 60)
31
+ return `${minutes}m ${String(seconds % 60).padStart(2, '0')}s`;
32
+ return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, '0')}m`;
33
+ }
34
+ /**
35
+ * Telegram refuses a message over 4096 characters outright.
36
+ *
37
+ * Kept well under: the rest of the summary shares the message, and a reply
38
+ * that fills a phone screen twice over is not read on a phone anyway.
39
+ */
40
+ export const MAX_ANSWER_LENGTH = 3000;
41
+ /**
42
+ * Markdown out, plain words in.
43
+ *
44
+ * The agent writes for a terminal that renders markdown, so its answer arrives
45
+ * on a phone as `**140 datoteka**` — asterisks and backticks read as noise
46
+ * exactly where the answer should be easiest to read.
47
+ *
48
+ * Stripped rather than handed to Telegram as `parse_mode`, which would be the
49
+ * obvious fix and the wrong one: Telegram rejects a whole message whose markup
50
+ * is unbalanced, and an agent's answer is arbitrary text. One stray asterisk
51
+ * and the notice does not arrive at all. Ugly beats missing, and this is
52
+ * neither.
53
+ *
54
+ * Deliberately conservative. Only paired markers are touched, and single
55
+ * underscores are left alone entirely — `execute_command` and `snake_case`
56
+ * appear in these answers constantly, and mangling an identifier to italicise
57
+ * nothing is worse than leaving a marker visible.
58
+ */
59
+ export function stripMarkdown(text) {
60
+ return text
61
+ // Fenced blocks: keep the code, drop the fence and any language tag.
62
+ .replace(/```[a-zA-Z0-9-]*\n?([\s\S]*?)```/g, '$1')
63
+ // Headings, which a phone shows as literal hashes.
64
+ .replace(/^#{1,6}[ \t]+/gm, '')
65
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
66
+ .replace(/__([^_]+)__/g, '$1')
67
+ // Single backticks only — a lone one is left alone rather than eating the
68
+ // rest of the answer looking for a partner.
69
+ .replace(/`([^`\n]+)`/g, '$1')
70
+ .trim();
71
+ }
72
+ /**
73
+ * The notification text.
74
+ *
75
+ * Carries the agent's answer only when the run was started from the phone. A
76
+ * finished run can end with anything in it — a file it read, a command it ran,
77
+ * a secret inside an error — and this goes to a chat that syncs to Telegram's
78
+ * servers, so it travels only where it was actually asked for. Start a run at
79
+ * the terminal and this says there is a result to come back to, and no more.
80
+ */
81
+ export function composeRunSummary(summary) {
82
+ const head = summary.failure ? '⚠️ Codeep stopped' : '✅ Codeep finished';
83
+ const lines = [`${head} — ${summary.task}`, `took ${formatDuration(summary.elapsedMs)}`];
84
+ if (summary.failure)
85
+ lines.push(summary.failure);
86
+ const cost = [];
87
+ if (typeof summary.tokens === 'number' && summary.tokens > 0) {
88
+ cost.push(`${Math.round(summary.tokens / 1000)}K tokens`);
89
+ }
90
+ if (typeof summary.costUsd === 'number' && summary.costUsd > 0) {
91
+ cost.push(`$${summary.costUsd.toFixed(summary.costUsd < 0.01 ? 4 : 2)}`);
92
+ }
93
+ if (cost.length > 0)
94
+ lines.push(cost.join(' · '));
95
+ const answer = summary.answer ? stripMarkdown(summary.answer) : undefined;
96
+ if (answer) {
97
+ lines.push('');
98
+ lines.push(answer.length > MAX_ANSWER_LENGTH
99
+ // Say it was cut rather than ending mid-sentence and looking finished.
100
+ ? `${answer.slice(0, MAX_ANSWER_LENGTH)}\n\n[…cut — the full answer is in the terminal]`
101
+ : answer);
102
+ }
103
+ return lines.join('\n');
104
+ }
105
+ /**
106
+ * Send it, and say nothing if it fails.
107
+ *
108
+ * A notification that could not be delivered is not worth interrupting the
109
+ * terminal for — the run already finished and its result is on screen. Returns
110
+ * whether it went, so a caller that does care can look.
111
+ */
112
+ export async function sendTelegramNotice(credentials, text) {
113
+ try {
114
+ const response = await fetch(`${API}${credentials.botToken}/sendMessage`, {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({
118
+ chat_id: credentials.chatID,
119
+ text,
120
+ // No parse_mode: a task name is arbitrary user text and Telegram
121
+ // rejects the whole message on unbalanced Markdown, which would turn a
122
+ // stray underscore in a branch name into a silently dropped notice.
123
+ disable_notification: false,
124
+ }),
125
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
126
+ });
127
+ return response.ok;
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * One long poll, one offset, both kinds of update.
3
+ *
4
+ * Telegram's getUpdates has a single cursor per bot. `offset` confirms every
5
+ * update older than itself **regardless of `allowed_updates`** — that parameter
6
+ * only filters what comes back in the response, not what the call acknowledges.
7
+ * So two pollers on one bot do not coexist: each advances the cursor past
8
+ * updates the other never saw, and both start losing traffic silently. An
9
+ * approval tapped on the phone would simply not register, with nothing anywhere
10
+ * to say why.
11
+ *
12
+ * Hence this. Everything that wants updates subscribes here, the loop asks for
13
+ * every type any subscriber could want, and dispatch happens locally where
14
+ * losing one is impossible.
15
+ */
16
+ export interface TelegramCredentials {
17
+ /** From @BotFather. A credential — belongs in the keychain, never in config. */
18
+ botToken: string;
19
+ /** The single chat allowed to answer. Anything else is ignored. */
20
+ chatID: string;
21
+ }
22
+ export type UpdateKind = 'callback_query' | 'message';
23
+ export type UpdateHandler = (payload: unknown) => void | Promise<void>;
24
+ /**
25
+ * Told when a poll fails, and when one succeeds after failing.
26
+ *
27
+ * Without this the loop was completely silent: a webhook left configured on the
28
+ * bot answers 409 to every getUpdates, a revoked token answers 401, and both
29
+ * looked exactly like a phone nobody had messaged. Diagnosing it meant reading
30
+ * the source.
31
+ */
32
+ export type PollObserver = (event: {
33
+ ok: boolean;
34
+ detail: string;
35
+ }) => void;
36
+ /**
37
+ * Where the cursor goes after a batch.
38
+ *
39
+ * Never backwards: a retry that returns an older batch, or a response with ids
40
+ * this build does not understand, must not re-deliver what was already handled.
41
+ */
42
+ export declare function nextOffset(current: number, updates: {
43
+ update_id?: unknown;
44
+ }[]): number;
45
+ /** Every kind this loop asks for, so one cursor can serve every subscriber. */
46
+ export declare const POLLED_KINDS: readonly UpdateKind[];
47
+ export declare class TelegramUpdates {
48
+ private readonly botToken;
49
+ private readonly handlers;
50
+ private offset;
51
+ private running;
52
+ private readonly idlePauseMs;
53
+ private observer;
54
+ /** Only the first failure of a streak is reported, then the recovery. */
55
+ private failing;
56
+ constructor(botToken: string, idlePauseMs?: number);
57
+ /** Watch the health of the poll itself, separately from its payload. */
58
+ observe(observer: PollObserver | null): void;
59
+ private report;
60
+ /**
61
+ * Listen for one kind of update. Returns the function that stops listening.
62
+ *
63
+ * The loop runs while anyone is listening and stops when the last subscriber
64
+ * leaves, so a CLI with the inbox switched off never opens a connection.
65
+ */
66
+ subscribe(kind: UpdateKind, handler: UpdateHandler): () => void;
67
+ private subscriberCount;
68
+ private loop;
69
+ private getUpdates;
70
+ }
71
+ export declare function sharedUpdates(botToken: string): TelegramUpdates;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * One long poll, one offset, both kinds of update.
3
+ *
4
+ * Telegram's getUpdates has a single cursor per bot. `offset` confirms every
5
+ * update older than itself **regardless of `allowed_updates`** — that parameter
6
+ * only filters what comes back in the response, not what the call acknowledges.
7
+ * So two pollers on one bot do not coexist: each advances the cursor past
8
+ * updates the other never saw, and both start losing traffic silently. An
9
+ * approval tapped on the phone would simply not register, with nothing anywhere
10
+ * to say why.
11
+ *
12
+ * Hence this. Everything that wants updates subscribes here, the loop asks for
13
+ * every type any subscriber could want, and dispatch happens locally where
14
+ * losing one is impossible.
15
+ */
16
+ const API = 'https://api.telegram.org';
17
+ /** Server-side long-poll window. The request blocks for up to this long. */
18
+ const POLL_SECONDS = 25;
19
+ /** Local ceiling, comfortably past the server's own. */
20
+ const REQUEST_TIMEOUT_MS = (POLL_SECONDS + 10) * 1000;
21
+ /**
22
+ * Pause after a poll that brought nothing back.
23
+ *
24
+ * Telegram's long poll already blocks server-side for POLL_SECONDS, so in
25
+ * normal running this never fires. It exists for the case where the request
26
+ * returns immediately — a network failure, or a server that ignored the
27
+ * timeout — which without it turns this into a loop that hammers the API as
28
+ * fast as the connection allows.
29
+ */
30
+ const IDLE_PAUSE_MS = 1000;
31
+ /**
32
+ * Where the cursor goes after a batch.
33
+ *
34
+ * Never backwards: a retry that returns an older batch, or a response with ids
35
+ * this build does not understand, must not re-deliver what was already handled.
36
+ */
37
+ export function nextOffset(current, updates) {
38
+ let out = current;
39
+ for (const update of updates) {
40
+ if (typeof update.update_id === 'number')
41
+ out = Math.max(out, update.update_id + 1);
42
+ }
43
+ return out;
44
+ }
45
+ /** Every kind this loop asks for, so one cursor can serve every subscriber. */
46
+ export const POLLED_KINDS = ['callback_query', 'message'];
47
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
48
+ export class TelegramUpdates {
49
+ botToken;
50
+ handlers = new Map();
51
+ offset = 0;
52
+ running = false;
53
+ idlePauseMs;
54
+ observer = null;
55
+ /** Only the first failure of a streak is reported, then the recovery. */
56
+ failing = false;
57
+ constructor(botToken, idlePauseMs = IDLE_PAUSE_MS) {
58
+ this.botToken = botToken;
59
+ this.idlePauseMs = idlePauseMs;
60
+ }
61
+ /** Watch the health of the poll itself, separately from its payload. */
62
+ observe(observer) {
63
+ this.observer = observer;
64
+ }
65
+ report(ok, detail) {
66
+ if (ok === !this.failing)
67
+ return; // nothing changed; stay quiet
68
+ this.failing = !ok;
69
+ this.observer?.({ ok, detail });
70
+ }
71
+ /**
72
+ * Listen for one kind of update. Returns the function that stops listening.
73
+ *
74
+ * The loop runs while anyone is listening and stops when the last subscriber
75
+ * leaves, so a CLI with the inbox switched off never opens a connection.
76
+ */
77
+ subscribe(kind, handler) {
78
+ let set = this.handlers.get(kind);
79
+ if (!set) {
80
+ set = new Set();
81
+ this.handlers.set(kind, set);
82
+ }
83
+ set.add(handler);
84
+ if (!this.running)
85
+ void this.loop();
86
+ return () => {
87
+ set.delete(handler);
88
+ if (this.subscriberCount() === 0)
89
+ this.running = false;
90
+ };
91
+ }
92
+ subscriberCount() {
93
+ let total = 0;
94
+ for (const set of this.handlers.values())
95
+ total += set.size;
96
+ return total;
97
+ }
98
+ async loop() {
99
+ if (this.running)
100
+ return;
101
+ this.running = true;
102
+ while (this.running && this.subscriberCount() > 0) {
103
+ const json = await this.getUpdates();
104
+ if (!this.running)
105
+ break;
106
+ const updates = Array.isArray(json?.result) ? json.result : [];
107
+ // Advance before dispatching. A handler that throws must not make the
108
+ // loop re-read the same update forever.
109
+ this.offset = nextOffset(this.offset, updates);
110
+ for (const update of updates) {
111
+ for (const kind of POLLED_KINDS) {
112
+ const payload = update[kind];
113
+ if (payload === undefined)
114
+ continue;
115
+ for (const handler of this.handlers.get(kind) ?? []) {
116
+ try {
117
+ await handler(payload);
118
+ }
119
+ catch {
120
+ // One subscriber's failure is not the others' problem, and is
121
+ // certainly not a reason to stop reading the bot.
122
+ }
123
+ }
124
+ }
125
+ }
126
+ // Covers an empty batch as well as a failed request: both mean the loop
127
+ // would otherwise come straight back with nothing to do.
128
+ if (updates.length === 0)
129
+ await sleep(this.idlePauseMs);
130
+ }
131
+ this.running = false;
132
+ }
133
+ async getUpdates() {
134
+ try {
135
+ const response = await fetch(`${API}/bot${this.botToken}/getUpdates`, {
136
+ method: 'POST',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify({
139
+ offset: this.offset,
140
+ timeout: POLL_SECONDS,
141
+ allowed_updates: POLLED_KINDS,
142
+ }),
143
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
144
+ });
145
+ const json = await response.json().catch(() => null);
146
+ if (!response.ok || json?.ok === false) {
147
+ const description = typeof json?.description === 'string' ? json.description : `HTTP ${response.status}`;
148
+ this.report(false, description);
149
+ return null;
150
+ }
151
+ this.report(true, 'reading updates again');
152
+ return json;
153
+ }
154
+ catch (error) {
155
+ this.report(false, error?.message || 'could not reach Telegram');
156
+ return null;
157
+ }
158
+ }
159
+ }
160
+ /**
161
+ * The one poller per bot token.
162
+ *
163
+ * Approvals are constructed per dangerous tool call and the inbox lives for the
164
+ * whole session; both must reach the same cursor, so the instance is keyed by
165
+ * token rather than owned by either.
166
+ */
167
+ const shared = new Map();
168
+ export function sharedUpdates(botToken) {
169
+ let instance = shared.get(botToken);
170
+ if (!instance) {
171
+ instance = new TelegramUpdates(botToken);
172
+ shared.set(botToken, instance);
173
+ }
174
+ return instance;
175
+ }