pi-braid-telegram 0.1.6 → 0.1.7

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.
@@ -2,19 +2,47 @@
2
2
  * Machine-wide poller ownership lock.
3
3
  *
4
4
  * Telegram's getUpdates is a per-bot singleton: only one consumer may long-
5
- * poll at a time. A module-local flag cannot provide this guarantee — it is
5
+ * poll at a time. A module-local flag cannot provide this guarantee: it is
6
6
  * per-process state, so two pi processes could each "own" the poller and
7
- * 409-fight over the stream. This lock file is the machine-wide half: every
8
- * start path acquires it first and refuses while a LIVE foreign pid holds
9
- * it; a stale lock (dead pid, corrupt, or unreadable file) is taken over
10
- * silently. A crashed holder therefore never locks the channel out, and a
11
- * same-pid re-acquire (session reload cycles) always succeeds.
7
+ * 409-fight over the stream. This lock file is the machine-wide half.
8
+ *
9
+ * Liveness is a rule over an identity and a heartbeat, never over the pid
10
+ * alone, because pids are namespace-local: a lock left by a dead container can
11
+ * name a pid this namespace now happens to use (the file reads as held
12
+ * forever), and a lock left by a live process in another namespace names a pid
13
+ * that does not exist here (the file reads as dead and gets displaced, putting
14
+ * two pollers on one bot). So the file records which boot wrote it and when
15
+ * that process last proved it was running; the pid is read as evidence only on
16
+ * the boot that wrote it. A recycled pid on one host is the same class, and the
17
+ * same rule covers it.
12
18
  */
13
19
 
14
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
20
+ import { execFileSync } from "node:child_process";
21
+ import {
22
+ existsSync,
23
+ mkdirSync,
24
+ readFileSync,
25
+ unlinkSync,
26
+ writeFileSync,
27
+ } from "node:fs";
28
+ import { hostname } from "node:os";
15
29
  import { dirname, join } from "node:path";
16
30
  import { braidDir } from "./state-paths.js";
17
31
 
32
+ /** How often a holder refreshes `heartbeatAt` while it owns the lock. */
33
+ export const POLLER_HEARTBEAT_INTERVAL_MS = 10_000;
34
+
35
+ /**
36
+ * Age past which a holder's heartbeat stops counting as evidence that it is
37
+ * polling. Three refreshes fit inside the window, so one late tick does not
38
+ * cost a healthy holder the channel, and a process that stopped refreshing is
39
+ * displaced within half a minute of going quiet.
40
+ */
41
+ export const POLLER_HEARTBEAT_STALE_MS = 30_000;
42
+
43
+ /** Boot identity recorded when neither the kernel id nor sysctl is readable. */
44
+ const BOOT_IDENTITY_FALLBACK = "boot-unknown";
45
+
18
46
  /** Default lock location: beside the other braid-owned telegram state
19
47
  * (~/.pi/agent/braid, BRAID_HOME-overridable). Null when no absolute braid
20
48
  * root resolves: the poller refuses to start rather than lock beside the cwd. */
@@ -23,18 +51,92 @@ export function defaultPollerLockPath(): string | null {
23
51
  return root === null ? null : join(root, "telegram-poller.lock");
24
52
  }
25
53
 
26
- /** Who holds the lock: the pid is the liveness key; startedAt/role/startedBy
27
- * make a refusal message name the holder. */
54
+ /**
55
+ * What a lock file may hold. A pid is the one field every version wrote.
56
+ * `instanceId` and `heartbeatAt` are optional here on purpose: the acquisition
57
+ * rule has to judge a file it cannot fully read (an older braid's lock, a
58
+ * truncated write), and a holder description has to render one.
59
+ */
28
60
  export interface PollerLockInfo {
29
61
  pid: number;
30
62
  startedAt: number;
63
+ instanceId?: string;
64
+ heartbeatAt?: number;
65
+ role?: string;
66
+ startedBy?: string;
67
+ }
68
+
69
+ /** What an acquirer stamps; identity and heartbeat are added by `acquirePollerLock`. */
70
+ export interface PollerLockStamp {
71
+ pid: number;
72
+ startedAt?: number;
73
+ instanceId?: string;
31
74
  role?: string;
32
75
  startedBy?: string;
33
76
  }
34
77
 
78
+ /**
79
+ * Why an acquisition succeeded.
80
+ *
81
+ * - `fresh`: no lock file held the channel;
82
+ * - `unreadable-file`: a file was there and named no holder, so nothing was
83
+ * displaced;
84
+ * - `same-pid-refresh`: this process re-acquired its own lock (session reload);
85
+ * - `stale-same-instance`: the holder shares this instance and is gone: its
86
+ * pid is dead, or its heartbeat is past the staleness window. A live pid with
87
+ * a quiet heartbeat is this case, not a refusal;
88
+ * - `stale-across-namespace`: the holder belongs to another boot (or recorded
89
+ * none) and its heartbeat is past the staleness window.
90
+ */
91
+ export type PollerLockTakeoverReason =
92
+ | "fresh"
93
+ | "unreadable-file"
94
+ | "same-pid-refresh"
95
+ | "stale-same-instance"
96
+ | "stale-across-namespace";
97
+
98
+ /**
99
+ * Why an acquisition refused.
100
+ *
101
+ * - `held-same-instance`: a different live pid on this instance, heartbeat
102
+ * within the window;
103
+ * - `held-across-namespace`: a holder from another boot whose heartbeat is
104
+ * within the window: its pid says nothing here, the heartbeat says it polls;
105
+ * - `stale-unknown`: a holder whose heartbeat cannot be read at all. Refused
106
+ * rather than guessed: without the heartbeat there is no evidence either way,
107
+ * and a wrong guess either steals a live channel or wedges a dead one.
108
+ */
109
+ export type PollerLockRefusalReason =
110
+ | "held-same-instance"
111
+ | "held-across-namespace"
112
+ | "stale-unknown";
113
+
114
+ export type PollerLockReason =
115
+ | PollerLockTakeoverReason
116
+ | PollerLockRefusalReason;
117
+
35
118
  export type AcquirePollerLockResult =
36
- | { ok: true; tookOver: boolean }
37
- | { ok: false; holder: PollerLockInfo };
119
+ | {
120
+ ok: true;
121
+ reason: PollerLockTakeoverReason;
122
+ tookOver: boolean;
123
+ /** The displaced record, when one was readable. */
124
+ previous?: PollerLockInfo;
125
+ }
126
+ | {
127
+ ok: false;
128
+ reason: PollerLockRefusalReason;
129
+ holder: PollerLockInfo;
130
+ /** The file the caller should name in a refusal message. */
131
+ path: string;
132
+ };
133
+
134
+ /** Injectable reads for the acquisition rule (tests supply a fake clock and
135
+ * process table; the live path uses the machine's). */
136
+ export interface AcquirePollerLockOptions {
137
+ pidAlive?: (pid: number) => boolean;
138
+ now?: () => number;
139
+ }
38
140
 
39
141
  /** Read the lock file; absent/corrupt/unreadable reads as "no holder". */
40
142
  export function readPollerLock(path: string): PollerLockInfo | undefined {
@@ -42,14 +144,16 @@ export function readPollerLock(path: string): PollerLockInfo | undefined {
42
144
  const parsed = JSON.parse(readFileSync(path, "utf8")) as PollerLockInfo;
43
145
  if (Number.isFinite(parsed?.pid)) return parsed;
44
146
  } catch {
45
- // fall through to undefined
147
+ // A file that does not parse names no holder.
46
148
  }
47
149
  return undefined;
48
150
  }
49
151
 
50
152
  /**
51
153
  * Live-pid probe. Signal-0 errors with EPERM when the pid exists but is not
52
- * ours to signal (still alive); ESRCH means the pid is gone.
154
+ * ours to signal (still alive); ESRCH means the pid is gone. Meaningful only to
155
+ * the boot that wrote the pid, which is why the acquisition rule gates it on
156
+ * the instance identity.
53
157
  */
54
158
  export function isPidAlive(pid: number): boolean {
55
159
  try {
@@ -60,68 +164,319 @@ export function isPidAlive(pid: number): boolean {
60
164
  }
61
165
  }
62
166
 
167
+ /**
168
+ * The kernel's boot identity: `/proc/sys/kernel/random/boot_id` where the proc
169
+ * filesystem is mounted, `sysctl -n kern.boottime` on darwin, and a recorded
170
+ * fallback when neither is readable. A fallback is deliberate rather than a
171
+ * guess: two boots that both fall back and share a hostname look identical,
172
+ * which the heartbeat then has to cover.
173
+ */
174
+ function readBootIdentity(): string {
175
+ try {
176
+ const proc = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
177
+ if (proc) return proc;
178
+ } catch {
179
+ // Not Linux, or /proc not mounted in this namespace.
180
+ }
181
+ try {
182
+ const sysctl = execFileSync("sysctl", ["-n", "kern.boottime"], {
183
+ encoding: "utf8",
184
+ timeout: 2000,
185
+ stdio: ["ignore", "pipe", "ignore"],
186
+ }).trim();
187
+ if (sysctl) return sysctl;
188
+ } catch {
189
+ // Absent on this platform, or sysctl refused.
190
+ }
191
+ return BOOT_IDENTITY_FALLBACK;
192
+ }
193
+
194
+ let cachedInstanceId: string | undefined;
195
+
196
+ /**
197
+ * Identity of the boot this process runs on: hostname plus the kernel's boot
198
+ * id, so two boots, and a container's boot against its host's, never share
199
+ * one. Memoized: the answer cannot change inside a process.
200
+ */
201
+ export function resolveInstanceId(): string {
202
+ cachedInstanceId ??= `${hostname()}:${readBootIdentity()}`;
203
+ return cachedInstanceId;
204
+ }
205
+
206
+ function writeLock(path: string, info: PollerLockInfo): void {
207
+ mkdirSync(dirname(path), { recursive: true });
208
+ writeFileSync(path, JSON.stringify(info));
209
+ }
210
+
63
211
  // Best-effort release at process exit: the exit hook removes the lock this
64
212
  // process last acquired. A hook that never runs (kill -9, crash) leaves the
65
- // file behind — harmless, because the next acquirer sees a dead pid and
66
- // takes the lock over.
213
+ // file behind, which is harmless: the next acquirer sees a dead or past-window
214
+ // record and takes the lock over.
67
215
  let exitHookInstalled = false;
68
216
  let heldLockPath: string | undefined;
69
217
 
218
+ /**
219
+ * The exit hook's body, exported so a test can run it without exiting.
220
+ * Ownership-checked on purpose: by the time a process exits, the file may name
221
+ * a successor that took the channel over, and unlinking by path would hand the
222
+ * singleton to a third process while the successor is still polling.
223
+ */
224
+ export function releaseHeldLockAtExit(): void {
225
+ if (!heldLockPath) return;
226
+ releasePollerLock(heldLockPath);
227
+ }
228
+
70
229
  function installExitRelease(): void {
71
230
  if (exitHookInstalled) return;
72
231
  exitHookInstalled = true;
73
- process.on("exit", () => {
74
- if (!heldLockPath) return;
75
- try {
76
- unlinkSync(heldLockPath);
77
- } catch {
78
- // best effort — the live-pid check covers a surviving file
79
- }
80
- });
232
+ process.on("exit", releaseHeldLockAtExit);
233
+ }
234
+
235
+ /** Age of a heartbeat, rendered for a human reading a refusal. */
236
+ function describeHeartbeatAge(holder: PollerLockInfo, now: number): string {
237
+ if (!Number.isFinite(holder.heartbeatAt)) return "no heartbeat recorded";
238
+ const ageMs = now - (holder.heartbeatAt as number);
239
+ const seconds = (Math.abs(ageMs) / 1000).toFixed(1);
240
+ const rendered =
241
+ Number(seconds) < 10
242
+ ? `${seconds}s`
243
+ : `${Math.round(Math.abs(ageMs) / 1000)}s`;
244
+ // A heartbeat from the future is a skewed clock on the recording instance,
245
+ // not a fresh holder; saying which way it is wrong keeps the refusal readable.
246
+ return ageMs < 0
247
+ ? `heartbeat ${rendered} in the future`
248
+ : `heartbeat ${rendered} ago`;
249
+ }
250
+
251
+ /** Human-readable holder description for refusal messages. */
252
+ export function describeLockHolder(
253
+ holder: PollerLockInfo,
254
+ now: number = Date.now(),
255
+ ): string {
256
+ const since = Number.isFinite(holder.startedAt)
257
+ ? new Date(holder.startedAt).toISOString()
258
+ : "unknown time";
259
+ const role = holder.role ?? "unknown role";
260
+ const by = holder.startedBy ? `, started by ${holder.startedBy}` : "";
261
+ const instance =
262
+ typeof holder.instanceId === "string" && holder.instanceId
263
+ ? ` on ${holder.instanceId}`
264
+ : " on an instance that recorded no identity";
265
+ return `pid ${holder.pid}${instance} since ${since} (${role}${by}), ${describeHeartbeatAge(holder, now)}`;
266
+ }
267
+
268
+ /**
269
+ * One sentence per verdict: what the rule saw, and which holder a retry will
270
+ * act on. A refusal an operator cannot classify is a support ticket; the four
271
+ * situations (live holder, holder that stopped heartbeating, dead container,
272
+ * garbled file) have to be tellable apart from the message alone.
273
+ */
274
+ export function describeLockReason(
275
+ reason: PollerLockReason,
276
+ path: string,
277
+ ): string {
278
+ switch (reason) {
279
+ case "held-same-instance":
280
+ return "a live process on this instance holds it and its heartbeat is current, so it is polling, and this acquisition refuses until that heartbeat goes stale";
281
+ case "held-across-namespace":
282
+ return "the holder was recorded by another boot and its heartbeat is current, so it is polling (its pid is not evidence here), and this acquisition refuses until that heartbeat goes stale";
283
+ case "stale-unknown":
284
+ return `the holder records no readable heartbeat, so it cannot be judged live or gone, and this acquisition refuses rather than guess; inspect ${path} and remove it if its holder is finished`;
285
+ case "fresh":
286
+ return "no lock file held the channel";
287
+ case "unreadable-file":
288
+ return "the lock file named no readable holder, so nothing was displaced";
289
+ case "same-pid-refresh":
290
+ return "this process already held the lock";
291
+ case "stale-same-instance":
292
+ return "the holder shares this instance and is no longer polling (dead pid, or a heartbeat past the staleness window)";
293
+ case "stale-across-namespace":
294
+ return "the holder was recorded by another boot and its heartbeat is past the staleness window";
295
+ }
296
+ }
297
+
298
+ /** Whether a recorded heartbeat still counts as evidence that its process polls. */
299
+ function heartbeatIsCurrent(holder: PollerLockInfo, now: number): boolean {
300
+ if (!Number.isFinite(holder.heartbeatAt)) return false;
301
+ // A skewed or future-stamped heartbeat counts as current: the alternative is
302
+ // displacing a holder that may be polling, which is the worse failure.
303
+ return now - (holder.heartbeatAt as number) <= POLLER_HEARTBEAT_STALE_MS;
81
304
  }
82
305
 
83
306
  /**
84
- * Take the lock. A live FOREIGN holder refuses (the caller reports it); our
85
- * own pid, a dead pid, or an unreadable file is taken over silently.
307
+ * Take the lock. The rule, in order:
308
+ *
309
+ * 1. no readable holder → acquire;
310
+ * 2. this process's own record → acquire (session reload cycles re-arm);
311
+ * 3. same instance, holder's pid dead → acquire, `stale-same-instance`;
312
+ * 4. same instance, pid alive, heartbeat current → refuse `held-same-instance`;
313
+ * 5. same instance, pid alive, heartbeat past the window → acquire,
314
+ * `stale-same-instance`: a live pid that stopped refreshing is not polling,
315
+ * and trusting it is what turns a stuck poller into a channel that never
316
+ * comes back;
317
+ * 6. same instance, pid alive, no readable heartbeat → refuse `stale-unknown`
318
+ * (no evidence either way, so no guess);
319
+ * 7. another boot or no recorded identity → refuse `held-across-namespace`
320
+ * while the heartbeat is current, acquire `stale-across-namespace` once it
321
+ * is not, and refuse `stale-unknown` when no heartbeat can be read at all.
86
322
  */
87
323
  export function acquirePollerLock(
88
324
  path: string,
89
- info: PollerLockInfo,
90
- pidAlive: (pid: number) => boolean = isPidAlive,
325
+ stamp: PollerLockStamp,
326
+ options: AcquirePollerLockOptions = {},
91
327
  ): AcquirePollerLockResult {
328
+ const pidAlive = options.pidAlive ?? isPidAlive;
329
+ const now = options.now ?? Date.now;
330
+ const at = now();
331
+ const instanceId = stamp.instanceId ?? resolveInstanceId();
92
332
  const existing = readPollerLock(path);
93
- if (existing && existing.pid !== info.pid && pidAlive(existing.pid)) {
94
- return { ok: false, holder: existing };
333
+
334
+ const acquire = (reason: PollerLockTakeoverReason) => {
335
+ writeLock(path, {
336
+ pid: stamp.pid,
337
+ startedAt: stamp.startedAt ?? at,
338
+ instanceId,
339
+ heartbeatAt: at,
340
+ ...(stamp.role === undefined ? {} : { role: stamp.role }),
341
+ ...(stamp.startedBy === undefined ? {} : { startedBy: stamp.startedBy }),
342
+ });
343
+ heldLockPath = path;
344
+ installExitRelease();
345
+ return {
346
+ ok: true as const,
347
+ reason,
348
+ tookOver: existing !== undefined,
349
+ ...(existing === undefined ? {} : { previous: existing }),
350
+ };
351
+ };
352
+
353
+ if (existing === undefined) {
354
+ // Distinguish "nothing there" from "something there that named no holder":
355
+ // the second is an operator-visible takeover reason, not a clean start.
356
+ return acquire(existsSync(path) ? "unreadable-file" : "fresh");
95
357
  }
96
- mkdirSync(dirname(path), { recursive: true });
97
- writeFileSync(path, JSON.stringify(info));
98
- heldLockPath = path;
99
- installExitRelease();
100
- return { ok: true, tookOver: existing !== undefined };
358
+
359
+ if (existing.instanceId === instanceId) {
360
+ // Own pid first: a reload re-arm must re-take its own lock even though its
361
+ // pid is alive by definition.
362
+ if (existing.pid === stamp.pid) return acquire("same-pid-refresh");
363
+ if (!pidAlive(existing.pid)) return acquire("stale-same-instance");
364
+ if (!Number.isFinite(existing.heartbeatAt)) {
365
+ return { ok: false, reason: "stale-unknown", holder: existing, path };
366
+ }
367
+ if (!heartbeatIsCurrent(existing, at))
368
+ return acquire("stale-same-instance");
369
+ return { ok: false, reason: "held-same-instance", holder: existing, path };
370
+ }
371
+
372
+ if (!Number.isFinite(existing.heartbeatAt)) {
373
+ return { ok: false, reason: "stale-unknown", holder: existing, path };
374
+ }
375
+ if (heartbeatIsCurrent(existing, at)) {
376
+ return {
377
+ ok: false,
378
+ reason: "held-across-namespace",
379
+ holder: existing,
380
+ path,
381
+ };
382
+ }
383
+ return acquire("stale-across-namespace");
101
384
  }
102
385
 
103
386
  /**
104
- * Release the lock — only when this process holds it; a foreign lock is
105
- * never touched. Best-effort: reports whether the file was removed.
387
+ * Refresh `heartbeatAt` for a lock this process holds. False when the file is
388
+ * gone or names another holder: a beat never rewrites a successor's record.
389
+ */
390
+ export function refreshPollerHeartbeat(
391
+ path: string,
392
+ options: { now?: () => number; pid?: number; instanceId?: string } = {},
393
+ ): boolean {
394
+ const holder = readPollerLock(path);
395
+ if (!holder) return false;
396
+ const pid = options.pid ?? process.pid;
397
+ const instanceId = options.instanceId ?? resolveInstanceId();
398
+ if (holder.pid !== pid || holder.instanceId !== instanceId) return false;
399
+ writeLock(path, { ...holder, heartbeatAt: (options.now ?? Date.now)() });
400
+ return true;
401
+ }
402
+
403
+ /** One running heartbeat: at most one per process, on one lock file. */
404
+ export interface PollerHeartbeat {
405
+ readonly path: string;
406
+ /** Refresh now; false once the lock is no longer ours. */
407
+ beat(): boolean;
408
+ /** Stop refreshing. Idempotent. */
409
+ stop(): void;
410
+ }
411
+
412
+ let activeHeartbeat:
413
+ | { path: string; timer: ReturnType<typeof setInterval> }
414
+ | undefined;
415
+
416
+ /**
417
+ * Refresh the lock's heartbeat on an interval. Replaces any heartbeat already
418
+ * running in this process: one lock, one beat. The timer is unref'd: it serves
419
+ * the lock, never the event loop, so it cannot keep a process alive after the
420
+ * poller it belongs to is gone.
421
+ */
422
+ export function startPollerHeartbeat(
423
+ path: string,
424
+ options: { intervalMs?: number; now?: () => number } = {},
425
+ ): PollerHeartbeat {
426
+ stopPollerHeartbeat();
427
+ const beat = () => refreshPollerHeartbeat(path, { now: options.now });
428
+ const timer = setInterval(
429
+ beat,
430
+ options.intervalMs ?? POLLER_HEARTBEAT_INTERVAL_MS,
431
+ );
432
+ timer.unref?.();
433
+ const entry = { path, timer };
434
+ activeHeartbeat = entry;
435
+ return {
436
+ path,
437
+ beat,
438
+ stop: () => {
439
+ if (activeHeartbeat === entry) activeHeartbeat = undefined;
440
+ clearInterval(timer);
441
+ },
442
+ };
443
+ }
444
+
445
+ /** Stop the running heartbeat, or the one on `path` when given. */
446
+ export function stopPollerHeartbeat(path?: string): boolean {
447
+ const entry = activeHeartbeat;
448
+ if (!entry) return false;
449
+ if (path !== undefined && entry.path !== path) return false;
450
+ activeHeartbeat = undefined;
451
+ clearInterval(entry.timer);
452
+ return true;
453
+ }
454
+
455
+ /**
456
+ * Release the lock, and only when this process holds it; a foreign lock is never
457
+ * touched. The heartbeat stops here so a released lock cannot keep being
458
+ * stamped fresh. Best-effort: reports whether the file was removed.
106
459
  */
107
460
  export function releasePollerLock(path: string): boolean {
108
461
  const existing = readPollerLock(path);
109
- if (!existing || existing.pid !== process.pid) return false;
462
+ const ours =
463
+ existing !== undefined &&
464
+ existing.pid === process.pid &&
465
+ existing.instanceId === resolveInstanceId();
466
+ // An older record without an identity is still ours when the pid is: this
467
+ // process wrote it before the identity field existed only within one run of
468
+ // a single build, so a pid match is the whole evidence available.
469
+ const legacyOurs =
470
+ existing !== undefined &&
471
+ existing.pid === process.pid &&
472
+ existing.instanceId === undefined;
473
+ stopPollerHeartbeat(path);
474
+ if (!existing || !(ours || legacyOurs)) return false;
110
475
  try {
111
476
  unlinkSync(path);
112
477
  } catch {
113
- // already gone — treat as released
478
+ // already gone: treat as released
114
479
  }
115
480
  if (heldLockPath === path) heldLockPath = undefined;
116
481
  return true;
117
482
  }
118
-
119
- /** Human-readable holder description for refusal messages. */
120
- export function describeLockHolder(holder: PollerLockInfo): string {
121
- const since = Number.isFinite(holder.startedAt)
122
- ? new Date(holder.startedAt).toISOString()
123
- : "unknown time";
124
- const role = holder.role ?? "unknown role";
125
- const by = holder.startedBy ? `, started by ${holder.startedBy}` : "";
126
- return `pid ${holder.pid} since ${since} (${role}${by})`;
127
- }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Session role marker: which seat this pi session occupies in the braid
3
- * topology. The orchestrator seat exports BRAID_SESSION_ROLE=worker into a
4
- * worker's pane before pi starts; the telegram extension reads it at
5
- * registration time and keeps the poller-ownership surfaces seat-only —
6
- * Telegram's getUpdates is a per-bot singleton the orchestrator seat owns,
7
- * so a worker session never registers the poller control or the pull-inbox
8
- * tool and never starts the poller (telegram_send stays available).
3
+ * topology. The orchestrator exports BRAID_SESSION_ROLE=worker into the
4
+ * worker's pane shell before the agent starts; the telegram extension reads it
5
+ * at registration time and returns before registering any surface: a worker
6
+ * reaches the owner through the orchestrator, never through the channel it
7
+ * shares a bot token with. Only the exact value "worker" selects the worker
8
+ * role; unset (or any other value) keeps the seat default, so deployments
9
+ * without the marker are unchanged.
9
10
  */
10
11
 
11
12
  export const ENV_SESSION_ROLE = "BRAID_SESSION_ROLE";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Low-level Telegram Bot API client built on Node 22's global fetch.
2
+ * Low-level Telegram Bot API client built on the global fetch Node ships.
3
3
  *
4
4
  * Zero npm dependencies. Every method takes an injectable fetch (defaults to
5
5
  * `globalThis.fetch`) so tests can substitute a recorded-fixture fake without