pi-braid-telegram 0.1.8 → 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 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 | 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.tts` | edge-tts | Voice source, rate and voices; read at extension load |
61
- | `braid.telegram.pauseStateFile` | unset | Where the pause control keeps its state; read at load |
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.8",
4
- "sha": "e1d647e5b1e0c9367bd49bcde72e3a316c908250",
5
- "shaShort": "e1d647e5",
3
+ "version": "0.1.9",
4
+ "sha": "ced405d5d39735e56ba7b2079c95b75371153e99",
5
+ "shaShort": "ced405d5",
6
6
  "branch": "detached",
7
7
  "dirty": false,
8
- "packedAt": "2026-11-04T17:20:23.646Z"
8
+ "packedAt": "2026-11-10T18:51:54.828Z"
9
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-braid-telegram",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Talk to your pi coding agent from Telegram: send text or voice, get the answer back, and tap a button to decide.",
5
5
  "type": "module",
6
6
  "files": [
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
- const pollEnabled = Boolean(env(ENV_POLL_ENABLED));
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: `${ENV_POLL_ENABLED}=1`,
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 to auto-start, or use /telegram enable or the telegram_poller tool)`,
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
 
@@ -48,7 +48,7 @@ export const POLLER_HEARTBEAT_STALE_MS = 30_000;
48
48
  */
49
49
  const LEGACY_PID_REUSE_SLACK_MS = 60_000;
50
50
 
51
- /** Boot identity recorded when neither the kernel id nor sysctl is readable. */
51
+ /** Boot identity recorded when the kernel offers no readable name at all. */
52
52
  const BOOT_IDENTITY_FALLBACK = "boot-unknown";
53
53
 
54
54
  /** Default lock location: beside the other braid-owned telegram state
@@ -178,30 +178,78 @@ export function isPidAlive(pid: number): boolean {
178
178
  }
179
179
  }
180
180
 
181
- /**
182
- * The kernel's boot identity: `/proc/sys/kernel/random/boot_id` where the proc
183
- * filesystem is mounted, `sysctl -n kern.boottime` on darwin, and a recorded
184
- * fallback when neither is readable. A fallback is deliberate rather than a
185
- * guess: two boots that both fall back and share a hostname look identical,
186
- * which the heartbeat then has to cover.
187
- */
188
- function readBootIdentity(): string {
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 {
189
193
  try {
190
- const proc = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
191
- if (proc) return proc;
194
+ return readFileSync("/proc/sys/kernel/random/boot_id", "utf8");
192
195
  } catch {
193
- // Not Linux, or /proc not mounted in this namespace.
196
+ return undefined;
194
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 {
195
203
  try {
196
- const sysctl = execFileSync("sysctl", ["-n", "kern.boottime"], {
204
+ return execFileSync("sysctl", ["-n", name], {
197
205
  encoding: "utf8",
198
206
  timeout: 2000,
199
207
  stdio: ["ignore", "pipe", "ignore"],
200
- }).trim();
201
- if (sysctl) return sysctl;
208
+ });
202
209
  } catch {
203
- // Absent on this platform, or sysctl refused.
210
+ return undefined;
204
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;
205
253
  return BOOT_IDENTITY_FALLBACK;
206
254
  }
207
255
 
@@ -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,