pi-braid-telegram 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/build-info.json +4 -4
- package/package.json +1 -1
- package/src/index.ts +36 -4
- package/src/lib/poller-lock.ts +131 -17
- package/src/lib/telegram-settings.ts +68 -0
package/README.md
CHANGED
|
@@ -53,12 +53,13 @@ Every `BRAID_TG_*` name has a `TELEGRAM_*` alias; the prefixed name wins when bo
|
|
|
53
53
|
|
|
54
54
|
`braid.telegram.*` keys in `~/.pi/agent/settings.json`:
|
|
55
55
|
|
|
56
|
-
| Key
|
|
57
|
-
|
|
|
58
|
-
| `braid.telegram.sendMaxChars`
|
|
59
|
-
| `braid.telegram.notifyMaxChars`
|
|
60
|
-
| `braid.telegram.
|
|
61
|
-
| `braid.telegram.
|
|
56
|
+
| Key | Default | Effect |
|
|
57
|
+
| -------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
58
|
+
| `braid.telegram.sendMaxChars` | `4096` | Cap for plain `telegram_send` text; re-read per send |
|
|
59
|
+
| `braid.telegram.notifyMaxChars` | `1200` | Cap for the turn-complete update; re-read per notification |
|
|
60
|
+
| `braid.telegram.pollerAutoStart` | off | Working directories that start the inbound poller by themselves: `true` everywhere, an absolute-cwd array those only, exact match; read at load |
|
|
61
|
+
| `braid.telegram.tts` | edge-tts | Voice source, rate and voices; read at extension load |
|
|
62
|
+
| `braid.telegram.pauseStateFile` | unset | Where the pause control keeps its state; read at load |
|
|
62
63
|
|
|
63
64
|
## Limits
|
|
64
65
|
|
package/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-braid-telegram",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"sha": "
|
|
5
|
-
"shaShort": "
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"sha": "ced405d5d39735e56ba7b2079c95b75371153e99",
|
|
5
|
+
"shaShort": "ced405d5",
|
|
6
6
|
"branch": "detached",
|
|
7
7
|
"dirty": false,
|
|
8
|
-
"packedAt": "2026-11-
|
|
8
|
+
"packedAt": "2026-11-10T18:51:54.828Z"
|
|
9
9
|
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -94,6 +94,7 @@ import {
|
|
|
94
94
|
type SttConfig,
|
|
95
95
|
} from "./lib/transcribe.js";
|
|
96
96
|
import { resolveSttKeyCandidates } from "./lib/stt-keys.js";
|
|
97
|
+
import { pollerAutoStartFor } from "./lib/telegram-settings.js";
|
|
97
98
|
import { normalizeAudioBytes } from "./lib/audio-normalize.js";
|
|
98
99
|
import { populateVoiceTts } from "./lib/tts.js";
|
|
99
100
|
import { runTelegramSend } from "./lib/tool-logic.js";
|
|
@@ -1705,6 +1706,29 @@ export default async function telegramExtension(
|
|
|
1705
1706
|
pi.registerCommand("telegram", {
|
|
1706
1707
|
description:
|
|
1707
1708
|
"Telegram channel control: /telegram enable | /telegram send <text>",
|
|
1709
|
+
getArgumentCompletions: (prefix: string) => {
|
|
1710
|
+
const typed = (prefix ?? "").toLowerCase();
|
|
1711
|
+
const verbs = [
|
|
1712
|
+
{
|
|
1713
|
+
value: "enable",
|
|
1714
|
+
label: "enable",
|
|
1715
|
+
description: "Start the background poller",
|
|
1716
|
+
},
|
|
1717
|
+
{
|
|
1718
|
+
value: "disable",
|
|
1719
|
+
label: "disable",
|
|
1720
|
+
description: "Stop the background poller",
|
|
1721
|
+
},
|
|
1722
|
+
{ value: "send", label: "send", description: "Send text to the owner" },
|
|
1723
|
+
{
|
|
1724
|
+
value: "status",
|
|
1725
|
+
label: "status",
|
|
1726
|
+
description: "Show channel and poller state",
|
|
1727
|
+
},
|
|
1728
|
+
];
|
|
1729
|
+
const matches = verbs.filter((verb) => verb.value.startsWith(typed));
|
|
1730
|
+
return matches.length > 0 ? matches : null;
|
|
1731
|
+
},
|
|
1708
1732
|
handler: async (args: string, ctx: any) => {
|
|
1709
1733
|
const trimmed = args.trim();
|
|
1710
1734
|
|
|
@@ -1818,7 +1842,13 @@ export default async function telegramExtension(
|
|
|
1818
1842
|
// it is unset AND BRAID_TG_POLL_ENABLED is set. BRAID_TG_POLL_ENABLED opts
|
|
1819
1843
|
// in explicitly.
|
|
1820
1844
|
const pollDisabled = Boolean(env(ENV_POLL_DISABLED));
|
|
1821
|
-
|
|
1845
|
+
// Two ways in: the env var, or a settings entry naming this working directory
|
|
1846
|
+
// (`braid.telegram.pollerAutoStart`, read through the same uncached reader the
|
|
1847
|
+
// caps use, so an edit applies without a restart). The settings path is the
|
|
1848
|
+
// one that survives a hand-started session after a reboot, which is the case
|
|
1849
|
+
// that left the owner with no channel at all.
|
|
1850
|
+
const pollByCwd = pollerAutoStartFor(process.cwd());
|
|
1851
|
+
const pollEnabled = Boolean(env(ENV_POLL_ENABLED)) || pollByCwd;
|
|
1822
1852
|
|
|
1823
1853
|
if (pollDisabled) {
|
|
1824
1854
|
log(
|
|
@@ -1838,20 +1868,22 @@ export default async function telegramExtension(
|
|
|
1838
1868
|
stt,
|
|
1839
1869
|
onOwnerMessage: deliverOwnerMessage,
|
|
1840
1870
|
onCallbackQuery: handleCallbackQuery,
|
|
1841
|
-
startedBy:
|
|
1871
|
+
startedBy: env(ENV_POLL_ENABLED)
|
|
1872
|
+
? `${ENV_POLL_ENABLED}=1`
|
|
1873
|
+
: "settings:pollerAutoStart",
|
|
1842
1874
|
onStart: () => void placeControlKeyboardOnce(),
|
|
1843
1875
|
pidAlive: deps?.pidAlive,
|
|
1844
1876
|
});
|
|
1845
1877
|
if (!outcome.started && outcome.reason !== "already-running") {
|
|
1846
1878
|
log(
|
|
1847
1879
|
outcome.reason === "lock-held" ? "warn" : "info",
|
|
1848
|
-
`telegram poller not auto-started (${ENV_POLL_ENABLED}=1): ${describePollerStartRefusal(outcome)}`,
|
|
1880
|
+
`telegram poller not auto-started (${env(ENV_POLL_ENABLED) ? `${ENV_POLL_ENABLED}=1` : "settings:pollerAutoStart"}): ${describePollerStartRefusal(outcome)}`,
|
|
1849
1881
|
);
|
|
1850
1882
|
}
|
|
1851
1883
|
} else {
|
|
1852
1884
|
log(
|
|
1853
1885
|
"info",
|
|
1854
|
-
`telegram background poller default-off (set ${ENV_POLL_ENABLED}=1
|
|
1886
|
+
`telegram background poller default-off (set ${ENV_POLL_ENABLED}=1, name this cwd in braid.telegram.pollerAutoStart, or use /telegram enable or the telegram_poller tool)`,
|
|
1855
1887
|
);
|
|
1856
1888
|
}
|
|
1857
1889
|
|
package/src/lib/poller-lock.ts
CHANGED
|
@@ -40,7 +40,15 @@ export const POLLER_HEARTBEAT_INTERVAL_MS = 10_000;
|
|
|
40
40
|
*/
|
|
41
41
|
export const POLLER_HEARTBEAT_STALE_MS = 30_000;
|
|
42
42
|
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Slack between the moment a pre-identity holder wrote its lock and the start
|
|
45
|
+
* time of the process now answering to the same pid. A pid can be recycled, so a
|
|
46
|
+
* live pid alone cannot prove that the recorded holder is still there; a process
|
|
47
|
+
* that started well after the record was written is someone else.
|
|
48
|
+
*/
|
|
49
|
+
const LEGACY_PID_REUSE_SLACK_MS = 60_000;
|
|
50
|
+
|
|
51
|
+
/** Boot identity recorded when the kernel offers no readable name at all. */
|
|
44
52
|
const BOOT_IDENTITY_FALLBACK = "boot-unknown";
|
|
45
53
|
|
|
46
54
|
/** Default lock location: beside the other braid-owned telegram state
|
|
@@ -93,7 +101,9 @@ export type PollerLockTakeoverReason =
|
|
|
93
101
|
| "unreadable-file"
|
|
94
102
|
| "same-pid-refresh"
|
|
95
103
|
| "stale-same-instance"
|
|
96
|
-
| "stale-across-namespace"
|
|
104
|
+
| "stale-across-namespace"
|
|
105
|
+
| "stale-legacy"
|
|
106
|
+
| "stale-legacy-recycled";
|
|
97
107
|
|
|
98
108
|
/**
|
|
99
109
|
* Why an acquisition refused.
|
|
@@ -109,6 +119,7 @@ export type PollerLockTakeoverReason =
|
|
|
109
119
|
export type PollerLockRefusalReason =
|
|
110
120
|
| "held-same-instance"
|
|
111
121
|
| "held-across-namespace"
|
|
122
|
+
| "held-legacy"
|
|
112
123
|
| "stale-unknown";
|
|
113
124
|
|
|
114
125
|
export type PollerLockReason =
|
|
@@ -136,6 +147,9 @@ export type AcquirePollerLockResult =
|
|
|
136
147
|
export interface AcquirePollerLockOptions {
|
|
137
148
|
pidAlive?: (pid: number) => boolean;
|
|
138
149
|
now?: () => number;
|
|
150
|
+
/** Start time of a live pid, in epoch ms. Injected so the legacy arm can be
|
|
151
|
+
* tested without depending on the machine's process table. */
|
|
152
|
+
pidStartedAt?: (pid: number) => number | undefined;
|
|
139
153
|
}
|
|
140
154
|
|
|
141
155
|
/** Read the lock file; absent/corrupt/unreadable reads as "no holder". */
|
|
@@ -164,30 +178,78 @@ export function isPidAlive(pid: number): boolean {
|
|
|
164
178
|
}
|
|
165
179
|
}
|
|
166
180
|
|
|
167
|
-
/**
|
|
168
|
-
*
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
181
|
+
/** Injectable kernel probes for the boot identity. Each returns the raw text
|
|
182
|
+
* its source offers, or undefined when that source is unreadable here. */
|
|
183
|
+
export interface BootIdentityProbes {
|
|
184
|
+
/** Contents of `/proc/sys/kernel/random/boot_id`, where procfs is mounted. */
|
|
185
|
+
readProcBootId?: () => string | undefined;
|
|
186
|
+
/** `sysctl -n <name>` on this host. */
|
|
187
|
+
readSysctl?: (name: string) => string | undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The Linux kernel's own name for the boot. Absent where procfs is not mounted
|
|
191
|
+
* in this namespace. */
|
|
192
|
+
function readProcBootIdFile(): string | undefined {
|
|
175
193
|
try {
|
|
176
|
-
|
|
177
|
-
if (proc) return proc;
|
|
194
|
+
return readFileSync("/proc/sys/kernel/random/boot_id", "utf8");
|
|
178
195
|
} catch {
|
|
179
|
-
|
|
196
|
+
return undefined;
|
|
180
197
|
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One `sysctl -n <name>` read: undefined when the name does not exist on this
|
|
201
|
+
* platform or sysctl refuses. */
|
|
202
|
+
function runSysctl(name: string): string | undefined {
|
|
181
203
|
try {
|
|
182
|
-
|
|
204
|
+
return execFileSync("sysctl", ["-n", name], {
|
|
183
205
|
encoding: "utf8",
|
|
184
206
|
timeout: 2000,
|
|
185
207
|
stdio: ["ignore", "pipe", "ignore"],
|
|
186
|
-
})
|
|
187
|
-
if (sysctl) return sysctl;
|
|
208
|
+
});
|
|
188
209
|
} catch {
|
|
189
|
-
|
|
210
|
+
return undefined;
|
|
190
211
|
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A probe's answer names a boot only when it carries text: an empty read falls
|
|
215
|
+
* through to the next source. */
|
|
216
|
+
function nonEmpty(value: string | undefined): string | undefined {
|
|
217
|
+
const trimmed = value?.trim();
|
|
218
|
+
return trimmed ? trimmed : undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The kernel's boot identity, in the order the kernel can be trusted to keep it.
|
|
223
|
+
* `/proc/sys/kernel/random/boot_id` is written once per boot and is free where
|
|
224
|
+
* procfs exists; `kern.bootsessionuuid` is darwin's name for the same fact.
|
|
225
|
+
* `kern.boottime` is the wall clock minus uptime, so a clock step moves it and
|
|
226
|
+
* two processes on one boot then compute different identities: it stays only as
|
|
227
|
+
* the last resort, a documented degradation rather than the darwin default. The
|
|
228
|
+
* recorded fallback covers a kernel that offers none of the three; two boots
|
|
229
|
+
* that both fall back and share a hostname look identical, which the heartbeat
|
|
230
|
+
* then has to cover.
|
|
231
|
+
*
|
|
232
|
+
* Probes are injected so a fixture can answer for two readings of one boot and
|
|
233
|
+
* observe which source the identity took. The live path passes none and reads
|
|
234
|
+
* this machine.
|
|
235
|
+
*/
|
|
236
|
+
export function readBootIdentity(probes: BootIdentityProbes = {}): string {
|
|
237
|
+
const readProc = probes.readProcBootId ?? readProcBootIdFile;
|
|
238
|
+
const sysctl = probes.readSysctl ?? runSysctl;
|
|
239
|
+
const probe = (read: () => string | undefined): string | undefined => {
|
|
240
|
+
try {
|
|
241
|
+
return nonEmpty(read());
|
|
242
|
+
} catch {
|
|
243
|
+
// A probe that throws did not read its source.
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const kernelId = probe(readProc);
|
|
248
|
+
if (kernelId) return kernelId;
|
|
249
|
+
const sessionUuid = probe(() => sysctl("kern.bootsessionuuid"));
|
|
250
|
+
if (sessionUuid) return sessionUuid;
|
|
251
|
+
const bootTime = probe(() => sysctl("kern.boottime"));
|
|
252
|
+
if (bootTime) return bootTime;
|
|
191
253
|
return BOOT_IDENTITY_FALLBACK;
|
|
192
254
|
}
|
|
193
255
|
|
|
@@ -292,6 +354,12 @@ export function describeLockReason(
|
|
|
292
354
|
return "the holder shares this instance and is no longer polling (dead pid, or a heartbeat past the staleness window)";
|
|
293
355
|
case "stale-across-namespace":
|
|
294
356
|
return "the holder was recorded by another boot and its heartbeat is past the staleness window";
|
|
357
|
+
case "stale-legacy":
|
|
358
|
+
return "the holder was recorded by a build that kept no identity or heartbeat, and its pid is dead, so the channel is free";
|
|
359
|
+
case "stale-legacy-recycled":
|
|
360
|
+
return "the holder was recorded by a build that kept no identity or heartbeat, and the live pid now wearing its number started after that record was written, so it is a recycled pid and not the holder";
|
|
361
|
+
case "held-legacy":
|
|
362
|
+
return `the holder was recorded by a build that kept no identity or heartbeat, so liveness cannot be read from the record, and its pid is still alive; confirm it is not polling, then remove ${path} (a clean disable releases it)`;
|
|
295
363
|
}
|
|
296
364
|
}
|
|
297
365
|
|
|
@@ -303,6 +371,24 @@ function heartbeatIsCurrent(holder: PollerLockInfo, now: number): boolean {
|
|
|
303
371
|
return now - (holder.heartbeatAt as number) <= POLLER_HEARTBEAT_STALE_MS;
|
|
304
372
|
}
|
|
305
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Start time of a live pid in epoch ms, from `ps`. Undefined when ps cannot say:
|
|
376
|
+
* an undated live pid is a refusal, never a takeover.
|
|
377
|
+
*/
|
|
378
|
+
function readPidStartedAt(pid: number): number | undefined {
|
|
379
|
+
try {
|
|
380
|
+
const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
381
|
+
encoding: "utf8",
|
|
382
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
383
|
+
}).trim();
|
|
384
|
+
if (!out) return undefined;
|
|
385
|
+
const parsed = Date.parse(out);
|
|
386
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
387
|
+
} catch {
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
306
392
|
/**
|
|
307
393
|
* Take the lock. The rule, in order:
|
|
308
394
|
*
|
|
@@ -319,6 +405,12 @@ function heartbeatIsCurrent(holder: PollerLockInfo, now: number): boolean {
|
|
|
319
405
|
* 7. another boot or no recorded identity → refuse `held-across-namespace`
|
|
320
406
|
* while the heartbeat is current, acquire `stale-across-namespace` once it
|
|
321
407
|
* is not, and refuse `stale-unknown` when no heartbeat can be read at all.
|
|
408
|
+
* 8. a record written before identity existed (`instanceId` absent, so no
|
|
409
|
+
* heartbeat ever): a dead pid is evidence enough, so acquire `stale-legacy`;
|
|
410
|
+
* a live pid whose process started later than the record was written is a
|
|
411
|
+
* recycled pid, so acquire `stale-legacy-recycled`; anything else refuses
|
|
412
|
+
* `held-legacy` and names the file, because a live pid we cannot date is the
|
|
413
|
+
* one case where guessing either steals a channel or wedges one.
|
|
322
414
|
*/
|
|
323
415
|
export function acquirePollerLock(
|
|
324
416
|
path: string,
|
|
@@ -326,6 +418,7 @@ export function acquirePollerLock(
|
|
|
326
418
|
options: AcquirePollerLockOptions = {},
|
|
327
419
|
): AcquirePollerLockResult {
|
|
328
420
|
const pidAlive = options.pidAlive ?? isPidAlive;
|
|
421
|
+
const pidStartedAt = options.pidStartedAt ?? readPidStartedAt;
|
|
329
422
|
const now = options.now ?? Date.now;
|
|
330
423
|
const at = now();
|
|
331
424
|
const instanceId = stamp.instanceId ?? resolveInstanceId();
|
|
@@ -369,6 +462,27 @@ export function acquirePollerLock(
|
|
|
369
462
|
return { ok: false, reason: "held-same-instance", holder: existing, path };
|
|
370
463
|
}
|
|
371
464
|
|
|
465
|
+
// A record from a build that had no instance identity and no heartbeat. Its
|
|
466
|
+
// pid is the only field, and it is the field that used to wedge the channel:
|
|
467
|
+
// the arm below read the absent heartbeat as "cannot tell" and refused
|
|
468
|
+
// forever, even when the holder had died days earlier.
|
|
469
|
+
if (existing.instanceId === undefined) {
|
|
470
|
+
if (existing.pid === stamp.pid) return acquire("same-pid-refresh");
|
|
471
|
+
if (!pidAlive(existing.pid)) return acquire("stale-legacy");
|
|
472
|
+
const startedAt = pidStartedAt(existing.pid);
|
|
473
|
+
const recordedAt = Number.isFinite(existing.startedAt)
|
|
474
|
+
? (existing.startedAt as number)
|
|
475
|
+
: undefined;
|
|
476
|
+
if (
|
|
477
|
+
startedAt !== undefined &&
|
|
478
|
+
recordedAt !== undefined &&
|
|
479
|
+
startedAt - recordedAt > LEGACY_PID_REUSE_SLACK_MS
|
|
480
|
+
) {
|
|
481
|
+
return acquire("stale-legacy-recycled");
|
|
482
|
+
}
|
|
483
|
+
return { ok: false, reason: "held-legacy", holder: existing, path };
|
|
484
|
+
}
|
|
485
|
+
|
|
372
486
|
if (!Number.isFinite(existing.heartbeatAt)) {
|
|
373
487
|
return { ok: false, reason: "stale-unknown", holder: existing, path };
|
|
374
488
|
}
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
* `rich: true` (split-to-fit) or an explicit strategy.
|
|
10
10
|
* - `notifyMaxChars` — craft target + guard for turn-end notifications.
|
|
11
11
|
* Default 1200: a full readable update, not a 280-char fragment.
|
|
12
|
+
* - `pollerAutoStart` — which working directories start the inbound poller by
|
|
13
|
+
* themselves. `true` means everywhere, an array means those cwds only, and
|
|
14
|
+
* anything else means nowhere: the env var and the tools stay the way in.
|
|
15
|
+
* Matching is EXACT, never a prefix: a seat at
|
|
16
|
+
* ~/.pi/agent/braid/orchestrator opts in without every worker worktree
|
|
17
|
+
* under /private/tmp inheriting the channel with it. A session started by
|
|
18
|
+
* hand in a listed directory therefore has a channel with no environment
|
|
19
|
+
* variable at all.
|
|
12
20
|
*
|
|
13
21
|
* Degradation: a missing file, unreadable JSON, or an invalid entry (not a
|
|
14
22
|
* finite number ≥ 1) falls back to the default — a bad setting never breaks a
|
|
@@ -29,11 +37,15 @@ export const DEFAULT_SEND_MAX_CHARS = 4096;
|
|
|
29
37
|
/** Fallback notify cap: full readable update, not a tweet-length squeeze. */
|
|
30
38
|
export const DEFAULT_NOTIFY_MAX_CHARS = 1200;
|
|
31
39
|
|
|
40
|
+
/** Fallback auto-start: no directory reaches the poller until settings names it. */
|
|
41
|
+
export const DEFAULT_POLLER_AUTO_START = false;
|
|
42
|
+
|
|
32
43
|
interface RawBraidSettings {
|
|
33
44
|
braid?: {
|
|
34
45
|
telegram?: {
|
|
35
46
|
sendMaxChars?: unknown;
|
|
36
47
|
notifyMaxChars?: unknown;
|
|
48
|
+
pollerAutoStart?: unknown;
|
|
37
49
|
};
|
|
38
50
|
};
|
|
39
51
|
}
|
|
@@ -43,6 +55,9 @@ export interface TelegramCaps {
|
|
|
43
55
|
notifyMaxChars: number;
|
|
44
56
|
}
|
|
45
57
|
|
|
58
|
+
/** Which directories start the poller on their own: everywhere, a list, or nowhere. */
|
|
59
|
+
export type PollerAutoStart = boolean | string[];
|
|
60
|
+
|
|
46
61
|
/** A cap must be a positive finite number; anything else → the default. */
|
|
47
62
|
function sanitizeCap(raw: unknown, fallback: number): number {
|
|
48
63
|
return typeof raw === "number" && Number.isFinite(raw) && raw >= 1
|
|
@@ -77,6 +92,59 @@ export function readTelegramCaps(
|
|
|
77
92
|
};
|
|
78
93
|
}
|
|
79
94
|
|
|
95
|
+
/** Normalize a path for the cwd comparison: no trailing slash, no symlink tail. */
|
|
96
|
+
function normalizeCwd(path: string): string {
|
|
97
|
+
let out = path.trim();
|
|
98
|
+
while (out.length > 1 && out.endsWith("/")) out = out.slice(0, -1);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read `braid.telegram.pollerAutoStart`: `true` (everywhere), an array of
|
|
104
|
+
* absolute cwds (those only), or `false` when absent or malformed. A relative
|
|
105
|
+
* entry, an empty string, or any other type is dropped rather than guessed at,
|
|
106
|
+
* because a mistyped cwd would otherwise start the poller somewhere nobody
|
|
107
|
+
* asked for.
|
|
108
|
+
*/
|
|
109
|
+
export function readPollerAutoStart(
|
|
110
|
+
settingsPath: string = SETTINGS_PATH,
|
|
111
|
+
): PollerAutoStart {
|
|
112
|
+
let parsed: RawBraidSettings | undefined;
|
|
113
|
+
try {
|
|
114
|
+
if (existsSync(settingsPath)) {
|
|
115
|
+
parsed = JSON.parse(
|
|
116
|
+
readFileSync(settingsPath, "utf8"),
|
|
117
|
+
) as RawBraidSettings;
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
return DEFAULT_POLLER_AUTO_START;
|
|
121
|
+
}
|
|
122
|
+
const raw = parsed?.braid?.telegram?.pollerAutoStart;
|
|
123
|
+
if (raw === true) return true;
|
|
124
|
+
if (!Array.isArray(raw)) return DEFAULT_POLLER_AUTO_START;
|
|
125
|
+
const cwds = raw
|
|
126
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
127
|
+
.map((entry) => normalizeCwd(entry))
|
|
128
|
+
.filter((entry) => entry.startsWith("/"));
|
|
129
|
+
return cwds;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Does this working directory start the poller by itself? Exact match only.
|
|
134
|
+
* A subdirectory of a listed cwd does NOT inherit it: the seat opts in for the
|
|
135
|
+
* directory it runs in, and a worker in a worktree beneath it must not.
|
|
136
|
+
*/
|
|
137
|
+
export function pollerAutoStartFor(
|
|
138
|
+
cwd: string,
|
|
139
|
+
settingsPath: string = SETTINGS_PATH,
|
|
140
|
+
): boolean {
|
|
141
|
+
const setting = readPollerAutoStart(settingsPath);
|
|
142
|
+
if (setting === true) return true;
|
|
143
|
+
if (setting === false) return false;
|
|
144
|
+
const here = normalizeCwd(cwd);
|
|
145
|
+
return setting.includes(here);
|
|
146
|
+
}
|
|
147
|
+
|
|
80
148
|
/** Default cap for `telegram_send` plain text (`braid.telegram.sendMaxChars`). */
|
|
81
149
|
export function resolveSendMaxChars(
|
|
82
150
|
settingsPath: string = SETTINGS_PATH,
|