omp-conductor 0.3.22 → 0.3.24

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
@@ -165,6 +165,22 @@ Also required on the host:
165
165
  writes `access.json`. The fleet heartbeat also reads that file and refuses to
166
166
  tick unless exactly one owner is paired, on the grounds that unattended
167
167
  dispatch is only defensible while a tier-2 page can reach a person.
168
+ - **Approving a Learning-loop amendment from a heartbeat tick** needs one more
169
+ setting than pairing: a notify destination. `/telegram notify` writes
170
+ `notifyMode` and `notifyChat` into the same `access.json`. omp-telegram
171
+ mounts its `telegram_ask` tool only for a turn that resolves a notify target,
172
+ and a locally injected tick resolves one only through that setting — so
173
+ without it the orchestrator can page you but cannot put a yes/no question in
174
+ front of you, which is the one thing the Learning loop's approval step
175
+ requires. `omp-conductor status` reports this on the `telegram` row, and a
176
+ tick that cannot ask says so in its own prompt and falls back to
177
+ `telegram_send`.
178
+
179
+ Set `notifyChat` even on a forum fleet. `/telegram topics` routes to a topic
180
+ this session claims at runtime, and that claim is not visible in
181
+ `access.json` — so a file carrying only `topicsChat` is reported as
182
+ unconfigured rather than guessed at, on the grounds that a health row which
183
+ reads green over a broken contract is worse than one that overstates a fault.
168
184
 
169
185
  With neither, tier 2 degrades to a comment on the issue. Nothing is broken in
170
186
  that configuration: it is supported, just slower to reach you.
@@ -462,22 +478,34 @@ Four control planes used to answer "stop" differently. The package verbs:
462
478
 
463
479
  `omp-conductor board [--project NAME]` opens the same facts as a live terminal
464
480
  kanban instead of a scrolling wall of status text. Its columns are Queue,
465
- Claimed, Running, Green, Blocked, Failed, and Merged. Queue cards are the
466
- bounded per-issue hold sample recorded by the latest dispatch; the Queue count
467
- is the authoritative ready count even when there are more ready issues than
468
- sampled cards. Run columns show the newest attempt for each issue. Merged keeps
469
- only the last 24 hours so the board stays operational rather than becoming an
470
- analytics archive.
481
+ Claimed, Running, Green, Blocked, Failed, Orphaned, Merged, and History.
482
+
483
+ Every column is a claim about an issue's state **now**, and each issue appears
484
+ in exactly one of them. Claimed, Running, and Green come from the store, because
485
+ a live worker or an open pull request is the most current fact there is. Every
486
+ stopped column comes from the labels the tracker carries right now: Queue is the
487
+ queue label plus the latest tick's admission holds, Failed is the failed state
488
+ label, Blocked is the blocked state label, and Orphaned is an issue the
489
+ in-progress label still claims with no worker running it — a protected orphan
490
+ whose worktree is kept because it may hold uncommitted work. So a requeued issue
491
+ is queue work even when its last attempt failed, and a closed issue is not
492
+ active work at all. Merged and History are the two backward-looking columns,
493
+ both bounded to the last 24 hours and neither one an action list: History holds
494
+ recently finished runs that no current label claims, so the board stays
495
+ operational rather than becoming an analytics archive.
471
496
 
472
497
  The board refreshes run, spend, turn, and dispatch values from SQLite every
473
- second. It refreshes the slower daemon, Herdr, Telegram, and code-graph health
474
- layer every ten seconds or immediately with `r`. Use arrow keys or `h/j/k/l` to
475
- select a card, `Enter` to inspect and follow its worker transcript, `u` to run
476
- the normal unblock workflow, `i` / `p` to open the issue / pull request, `?` for
477
- help, and `q`, `Esc`, or `Ctrl-C` to go back and quit. Narrow terminals show a
478
- sliding subset of columns around the selection; terminals below 50×20 get a
479
- single resize instruction instead of a broken layout. The board is read-only
480
- except for the explicit `u` action: it never claims work or changes stages.
498
+ second. It refreshes the slower daemon, Herdr, Telegram, code-graph, and tracker
499
+ label layer every ten seconds or immediately with `r`; the header reports
500
+ `tracker ok`, or how stale the label read is and why, so an empty Queue is never
501
+ mistaken for an empty queue. Use arrow keys or `h/j/k/l` to select a card,
502
+ `Enter` to inspect and follow its worker transcript, `u` to run the normal
503
+ unblock workflow on a Blocked, Failed, or Orphaned card, `i` / `p` to open the
504
+ issue / pull request, `?` for help, and `q`, `Esc`, or `Ctrl-C` to go back and
505
+ quit. Narrow terminals show a sliding subset of columns around the selection;
506
+ terminals below 50×20 get a single resize instruction instead of a broken
507
+ layout. The board is read-only except for the explicit `u` action: it never
508
+ claims work or changes stages.
481
509
 
482
510
  `halt --pane` is **fail-closed**: it exits `0` only when the conductor agent is
483
511
  *proven* gone. It writes the recovery pin first, so a failed stop still cannot be
@@ -518,7 +546,8 @@ Per tick, for the daemon's project:
518
546
  the independent head/check verification; green → `pushed-green`, red →
519
547
  `failed`, and still pending stays occupied. For every verified
520
548
  `pushed-green` run, ask what became of its PR. Merged → `merged`; closed
521
- without merging → `failed`. Unknown answers leave the row unchanged. This
549
+ without merging → `failed`. Unknown answers leave the row unchanged. Every row
550
+ that settles also loses its `agent:in-progress` label. This
522
551
  maintenance runs even while dispatch is paused or workers are active, so
523
552
  status converges on the five-minute tick cadence. It also runs above admission
524
553
  so a row settled here frees its issue in the same tick. See
@@ -548,8 +577,12 @@ Per tick, for the daemon's project:
548
577
  survived step 8 — so at most one API call per free slot, never one per queued
549
578
  issue — the daemon asks whether an **open** PR already closes it. An open PR
550
579
  normally holds the issue. One narrow exception permits a routed continuation:
551
- the PR URL must match the latest terminal run's retained PR exactly. Drafts
552
- count because their branch can hold the only copy of the work.
580
+ the latest run must be terminal, and the open PR must be that run's retained
581
+ work either the PR URL it recorded or a PR opened on the branch it retained.
582
+ The branch half matters because a run can be cap-killed before its worker ever
583
+ opens a PR, leaving a retained branch and no recorded URL; a PR pushed to that
584
+ branch afterwards is still the continuation target. Drafts count because their
585
+ branch can hold the only copy of the work.
553
586
  The tracker also finds work missing from a new, moved, restored, or cleared
554
587
  store. If the check fails, the candidate is **held**, not admitted, and
555
588
  retried next tick: the cost of holding is five minutes, the cost of admitting
@@ -583,13 +616,14 @@ Then, per admitted issue:
583
616
  | Outcome | Labels | Worktree | Escalation |
584
617
  | --- | --- | --- | --- |
585
618
  | `pushed-pending` | `agent:in-progress` stays while the daemon rechecks GitHub | removed | none |
586
- | `pushed-green` | `agent:in-progress` stays until the merge closes the issue | removed | none |
619
+ | `pushed-green` | `agent:in-progress` stays while the PR is open | removed | none |
587
620
  | `blocked` | swapped to `agent:blocked` | removed | Tier 1 |
588
621
  | `failed` / `killed` | swapped to `agent:failed` | dirty tree committed to the branch, then retained until the PR or issue is terminal | Tier 1 |
589
622
  | unexpected error | swapped to `agent:failed` | same | Tier 1 |
590
623
 
591
624
  `pushed-pending` and `pushed-green` are not the end of the row: later ticks
592
- verify outstanding checks and settle the PR once it resolves. See
625
+ verify outstanding checks and settle the PR once it resolves, and a row that
626
+ settles gives up its `agent:in-progress` label. See
593
627
  [what settles a green PR](#what-settles-a-green-pr).
594
628
 
595
629
  Label swaps add the new label before removing the old one: the reverse order
@@ -683,11 +717,18 @@ every pushed PR it is still holding:
683
717
  | could not be determined | unchanged | A flaky network, a revoked token, a deleted PR. An unknown answer never settles a row; the next tick asks again for free. |
684
718
 
685
719
  Run history is untouched. A PR closed without merging becomes a concrete failed
686
- attempt; a merge does not spend failure or continuation budget. A merge normally
687
- closes the issue, and a human who closed a PR is already looking at it, so what
688
- an issue's labels should say next remains the orchestrator's drain-duty
689
- judgement. One unreachable PR costs its own row and nothing else; the rest of
690
- the sweep still settles.
720
+ attempt; a merge does not spend failure or continuation budget. A settled row also
721
+ loses `agent:in-progress` from its issue: the row transition and the label are one
722
+ fact, and a terminal answer about the PR proves no worker process owns the issue,
723
+ so the duplicate-dispatch guard it exists for is spent. The label comes off
724
+ *before* the row is written terminal, because this sweep is the only thing that
725
+ revisits a `pushed-*` row: a tracker that refuses the removal therefore leaves the
726
+ row pending and the next tick tries again, rather than stranding the label on an
727
+ issue nothing will look at twice. The issue stays in the busy set while it waits,
728
+ so nothing is dispatched onto it in between. Anything beyond that one release — a
729
+ re-queue, a `blocked` marker — is still the orchestrator's drain-duty judgement.
730
+ One unreachable PR costs its own row and nothing else; the rest of the sweep
731
+ still settles.
691
732
 
692
733
  Until this existed, nothing ever revisited a `pushed-green` row: the startup
693
734
  reconciler only settles rows that held a process, and `merged` went unwritten. On
@@ -696,6 +737,12 @@ merged and whose issues were all closed, through two daemon restarts — and bec
696
737
  the active set *is* the busy set, those three issues were permanently unclaimable.
697
738
  A status page that has stopped being evidence is worse than no status page.
698
739
 
740
+ The label half of that outlived the row half by two days. On 2026-08-09 a merged
741
+ PR and a closed-unmerged one both settled their rows correctly and both left their
742
+ issues carrying `agent:in-progress`, which eligibility reads as "a worker owns
743
+ this" — with the brief forbidding the orchestrator from editing a state label and
744
+ `unblock` declining to clear that one, neither issue could ever be claimed again.
745
+
699
746
  ### Continuation runs
700
747
 
701
748
  When a worktree is provisioned onto a branch that already exists in the mirror
@@ -960,7 +1007,9 @@ label on the issue, and eligibility reads any state label as disqualifying, so a
960
1007
  answered issue that keeps one is never re-claimed and the answer is inert — nothing
961
1008
  fails, the issue just stops existing as far as dispatch is concerned.
962
1009
  [`omp-conductor unblock <issue>`](#cli-reference) is the way back: it clears the
963
- label through the same tracker the dispatcher writes with. The brief tells the
1010
+ label through the same tracker the dispatcher writes with, including
1011
+ `agent:in-progress` when the newest recorded run is terminal, since a terminal row
1012
+ is proof the worker process is gone. The brief tells the
964
1013
  orchestrator to run that verb rather than edit the label itself, and that is not a
965
1014
  formality — orphan detection works by comparing `agent:in-progress` labels against
966
1015
  live runs, and it is only trustworthy while every state label on the tracker was
@@ -1219,13 +1268,23 @@ start: the bridge is reconfigured out-of-band, and a heartbeat that trusted a
1219
1268
  startup snapshot would keep dispatching for days after the channel went away. A
1220
1269
  stale arm marker must not outlive the channel that makes running unattended safe.
1221
1270
 
1222
- The check passes only when the file parses to an object with `enabled: true` and
1223
- exactly one `allowFrom` entry. Everything else stops the heartbeat: file missing,
1271
+ The check passes only when a bot token is resolvable `TELEGRAM_BOT_TOKEN` in
1272
+ the environment, or in the `.env` beside `accessFile` and the file parses to an
1273
+ object with `enabled: true` and exactly one `allowFrom` entry. Everything else
1274
+ stops the heartbeat: no token, so nothing outbound works at all; file missing,
1224
1275
  unreadable or truncated; not JSON, or JSON that is not an object; `enabled`
1225
1276
  absent or false; zero owners paired (nobody to page) or more than one (ambiguous:
1226
1277
  the conductor refuses to guess which human is on the hook). Failure modes are
1227
1278
  deliberately not distinguished in the decision: each one means a page lands
1228
- nowhere.
1279
+ nowhere. `omp-conductor status` is where they are told apart — its `telegram` row
1280
+ names the specific fault.
1281
+
1282
+ One caveat the file cannot express: omp-telegram binds its own copy of the token
1283
+ in `startBot()` at session start, and rebinds it only on `/telegram token` or
1284
+ `/telegram on`. Writing a token into `.env` out-of-band therefore restores
1285
+ tier-2 paging immediately — conductor sends those itself — while the bridge's own
1286
+ tools, `telegram_send` and `telegram_ask`, stay dead until you reload it. After
1287
+ adding a token by hand, run `/telegram on` in the orchestrator session.
1229
1288
 
1230
1289
  Leaving `accessFile` unset passes the gate, because an ordinary developer session
1231
1290
  that happens to have a `.conductor-tick.json` has no bridge to check. It is not an
@@ -1350,7 +1409,7 @@ omp-conductor help
1350
1409
  | `restart` | Prefer `systemctl restart` when the unit owns the live pid so the replacement stays supervised; otherwise `stop` then `start`, inheriting the running daemon's port and project unless a flag overrides them. The new process **salvages dirty live worktrees before orphaning** those rows — see [Deploying a new package onto a busy fleet](#deploying-a-new-package-onto-a-busy-fleet). |
1351
1410
  | `upgrade [--to VERSION] [--project NAME]` | Deterministically update the Bun-global CLI, omp plugin, Herdr recovery plugin, and managed brief as one release. Resolves the npm version and exact `gitHead`, pauses only new claims, drains active workers, installs all surfaces, reloads Herdr and the daemon, waits for pane recovery, verifies identities and fleet health twice, then restores the original dispatch state. A no-op when already current. Failure leaves dispatch paused. Must run outside a Herdr-managed session. |
1352
1411
  | `status [--project NAME]` | Layered fleet report first: `dispatch` / `ticks` / next scheduled tick / `pane` / `recovery` / `herdr` / `telegram` / optional `code graph` / `daemon`, then the project body. The project body includes the latest completed dispatch timestamp, ready/routed/admitted counts, and bounded hold groups; API failures are marked `DEGRADED` so queue starvation cannot look idle. The next tick comes from the live heartbeat process, not a guess from log timestamps. Telegram health uses `getMe` to prove API authentication without sending a message and reports inbound bridge configuration separately. Configured graphs report prerequisites, indexed repos, timer state, and refresh freshness without blocking dispatch. The daemon block includes `rss` from `/healthz`; live workers add a busy-deploy warning. A `.conductor-stalled` marker adds an `orchestrator STALLED since …` line. |
1353
- | `board [--project NAME]` | Live keyboard-driven kanban over the same SQLite and `/healthz` truth as `status`: Queue, Claimed, Running, Green, Blocked, Failed, and the last 24 hours of Merged. Refreshes run/spend/turn values every second and slower health every ten seconds. `Enter` follows the selected transcript in place; `u` invokes the existing unblock workflow; `i` / `p` open the issue / PR; `r` refreshes health; `?` shows all keys. Requires an interactive terminal of at least 50×20. |
1412
+ | `board [--project NAME]` | Live keyboard-driven kanban over the same SQLite and `/healthz` truth as `status`, plus the tracker's current labels: Queue, Claimed, Running, Green, Blocked, Failed, Orphaned, and the last 24 hours of Merged and History. Columns are mutually exclusive and describe current state, not the newest run row, so a requeued issue is queued rather than failed and a closed issue is neither. Refreshes run/spend/turn values every second, and health plus the label read every ten seconds. `Enter` follows the selected transcript in place; `u` invokes the existing unblock workflow on a Blocked, Failed, or Orphaned card; `i` / `p` open the issue / PR; `r` refreshes health; `?` shows all keys. Requires an interactive terminal of at least 50×20. |
1354
1413
  | `hold [--project NAME]` | Soft stop: pause claiming **and** disarm ticks. Daemon and pane stay up. Prefer this over `pause` when the intent is "stop the conductor" without killing processes. See [Stop the conductor](#stop-the-conductor-hold--halt). |
1355
1414
  | `halt [--pane] [--project NAME]` | `hold`, then stop the dispatch daemon (systemctl-aware). Pane stays up unless `--pane` is passed. `halt --pane` also pins herdr-conductor recovery off for the conductor agent only — it does **not** stop `herdr-fleet.service` or any other herdr session. Fail-closed: exits nonzero unless the agent is proven gone. |
1356
1415
  | `arm [--project NAME]` | Proof-gated: send a Telegram challenge and write the arm marker only after your reply appears as a user turn in the orchestrator transcript. Never auto-armed by `resume` / `hold`. |
@@ -1358,7 +1417,7 @@ omp-conductor help
1358
1417
  | `release-pane [--project NAME]` | Clear the `halt --pane` recovery pin so herdr-conductor may resume the fleet agent again. |
1359
1418
  | `tail <issue>` | Follow the newest run for that issue: the worker's assistant text as `assistant: …` and each tool it calls as `tool: <name>`, printed as they land. Workers are omp sessions inside the daemon rather than terminals, so this is the only way to watch one live — a herdr pane running it becomes an observation window. Starts from the top of the transcript, not the end, so attaching to a run that is already ten turns in shows those ten turns. Exits `1` with `no run recorded for #N` when the issue has never been dispatched, or `no transcript yet (state: …)` when the attempt has not opened one. Otherwise it runs until `Ctrl-C`, or until the run has finished and its transcript has been silent for five seconds, and prints `run ended: <state>`. |
1360
1419
  | `extend <issue> --turns N [--project NAME]` | Monotonically raise that live worker's effective turn ceiling through its owning daemon. The current omp session keeps running; no restart or continuation is created. The daemon persists the new ceiling for `status` and rejects missing, settled, cap-killed, equal, or lower requests instead of implying that an immutable session changed. |
1361
- | `unblock <issue>` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again. `agent:in-progress` is never touched. Run history remains intact: blocks consume the independent continuation budget, not failed implementation attempts. The output reports both budgets and warns when either will make the next tick escalate instead of dispatch. Exits `2` when the issue number is missing or malformed. |
1420
+ | `unblock <issue>` | Remove that issue's `blocked` and `failed` labels so an answered escalation can be claimed again. `agent:in-progress` comes off too, but only when the newest recorded run is terminal — that row is the proof no worker still owns the issue, so a live run keeps the label, and so does an issue with no run row at all. Run history remains intact: blocks consume the independent continuation budget, not failed implementation attempts. The output reports both budgets and warns when either will make the next tick escalate instead of dispatch. Exits `2` when the issue number is missing or malformed. |
1362
1421
  | `friction <kind> --detail TEXT [--issue N]` | Record one bounded judgment the daemon cannot infer: an escalation belonged in a digest, or a tick report was noise/surprising. The detail is limited to 160 characters. One event never changes policy; three observations inside seven days make the aggregate eligible for one Learning-loop prompt, followed by a seven-day cooldown. |
1363
1422
  | `daemon` | Run the loop in the **foreground**, ticking every 5 minutes and serving `/healthz`. Admitted workers run in a tracked background pool, so settlement and capacity checks remain periodic while they work; shutdown drains the pool before closing the store. This is what `start` launches and what a systemd unit should call. |
1364
1423
  | `daemon --once` | Run a single tick, wait for workers admitted by that tick, and exit. No HTTP server or pidfile — a drill must not register itself as the daemon, or the next reader believes it and the real daemon's in-flight runs get reconciled as orphans. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-conductor",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Can a *locally injected* turn ask its operator a yes/no question?
3
+ *
4
+ * One predicate, shared by the tick that has to warn about the answer and the
5
+ * status row that has to report it, because two spellings of it would drift and
6
+ * the whole point is that the health line and the tick agree.
7
+ *
8
+ * It is deliberately a question about **configuration**, not about the mounted
9
+ * tool set, and that distinction is the correction to a first attempt that got
10
+ * it wrong. omp-telegram mounts `telegram_ask` in `before_agent_start` and
11
+ * removes it again in `agent_end` (`restorePromptTools`), so the tool exists
12
+ * only *during* a turn. Anything that looks at `getActiveTools()` between turns
13
+ * — which is exactly where a heartbeat composes its prompt — sees the base set
14
+ * and would report the tool missing on every tick, including a correctly
15
+ * configured fleet. A false alarm every interval is worse than no alarm: it
16
+ * trains the operator to ignore the line that is supposed to mean something.
17
+ *
18
+ * What actually decides the mounting is knowable ahead of the turn. omp-telegram
19
+ * resolves a prompt target from the turn's own `<telegram-message>` wrapper, or
20
+ * failing that from `notifyTarget()`, which needs `notifyMode` set to "away" or
21
+ * "always" *and* a destination it can reach. A locally injected tick never has a
22
+ * wrapper, so for ticks the predicate reduces to config keys readable before the
23
+ * turn starts — with one deliberate narrowing, documented at the destination
24
+ * check below: only `notifyChat` counts, because a forum topic depends on live
25
+ * per-session state no file can evidence.
26
+ *
27
+ * That is exactly what failed on 2026-08-09 06:17Z: the fleet's access.json had
28
+ * no `notifyMode`, so the locally injected tick could not ask the Learning-loop
29
+ * yes/no question the package floor requires — while the status line reported
30
+ * `telegram ok (@tbcoder_bot; inbound configured)` throughout (#114). A health
31
+ * row that stays green through a broken contract is worse than no row.
32
+ *
33
+ * The legacy `away: true` boolean counts: `loadAccess()` migrates it to
34
+ * `notifyMode: "away"` on read, so a fleet still carrying it resolves a target
35
+ * and must not be reported as broken.
36
+ */
37
+
38
+ import { readFileSync } from "node:fs";
39
+ import { join } from "node:path";
40
+
41
+ /** The tool the package floor names for the Learning-loop yes/no amendment
42
+ * approval (`## Learning loop`, step 2, in `briefs/orchestrator.md`). Held as
43
+ * a constant because the prose that tells a turn to call it and the check that
44
+ * says whether it can be called must name the same thing. */
45
+ export const TELEGRAM_APPROVAL_TOOL = "telegram_ask";
46
+
47
+ export type ApprovalSurface = { kind: "ready" } | { kind: "missing"; reason: string };
48
+
49
+ /** One checked read of a JSON property, so nothing below asserts a shape the
50
+ * parse never proved. Anything that is not a plain object, or a key that is
51
+ * absent, answers undefined — which every caller here already treats as
52
+ * "unconfigured". */
53
+ function field(value: unknown, key: string): unknown {
54
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
55
+ return Object.hasOwn(value, key) ? Reflect.get(value, key) : undefined;
56
+ }
57
+
58
+ /**
59
+ * Whether omp-telegram would find a bot token — its rule, not a reasonable one.
60
+ *
61
+ * This gates the heartbeat, so it has to predict another package's answer, and
62
+ * anywhere it is more generous than that package the gate fails *open*: it would
63
+ * report a token, let the tick fire, and leave the fleet dispatching with an
64
+ * outbound channel that never worked. So it mirrors `resolveToken()` in
65
+ * omp-telegram's `access.ts` exactly — process environment first, then the first
66
+ * line of `<state dir>/.env` matching `/^TELEGRAM_BOT_TOKEN=(.*)$/` after a
67
+ * trim, first match winning even when its value is empty.
68
+ *
69
+ * That regex is deliberately strict where a dotenv reader would not be:
70
+ * `export TELEGRAM_BOT_TOKEN=…` and `TELEGRAM_BOT_TOKEN = …` are both files
71
+ * omp-telegram reads no token from, so both must close this gate.
72
+ *
73
+ * `escalate.ts` parses the same file more permissively on purpose, and the two
74
+ * are not a contradiction to be tidied away: that one asks "can *conductor* send
75
+ * a page with this", and it can, because it does the parsing and the request
76
+ * itself. This one asks "will *omp-telegram* have a token", and only omp-telegram
77
+ * decides that.
78
+ */
79
+ export function hasBotToken(stateDir: string): boolean {
80
+ const fromEnv = process.env["TELEGRAM_BOT_TOKEN"];
81
+ if (fromEnv !== undefined && fromEnv.length > 0) return true;
82
+ let raw: string;
83
+ try {
84
+ raw = readFileSync(join(stateDir, ".env"), "utf8");
85
+ } catch {
86
+ return false;
87
+ }
88
+ for (const line of raw.split("\n")) {
89
+ const match = /^TELEGRAM_BOT_TOKEN=(.*)$/.exec(line.trim());
90
+ if (match === null) continue;
91
+ // First match wins even when empty, exactly as `resolveToken()` returns on
92
+ // its first hit — a file whose first token line is blank has no token, and
93
+ // reading past it would disagree with the package this predicts.
94
+ return (match[1] ?? "").length > 0;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ /** Reads the omp-telegram access file and answers whether a locally injected
100
+ * turn would resolve an answerable destination.
101
+ *
102
+ * A bot token is *not* checked here, deliberately: without one nothing outbound
103
+ * works at all, so it is a whole-channel fault rather than an approval-surface
104
+ * one. Both callers settle it first — the tick through its escalation-channel
105
+ * gate, which refuses to fire at all, and `probeTelegramHealth` through a
106
+ * `getMe` that has already succeeded. This function is only ever asked about a
107
+ * bridge that can send. Never throws: an unreadable or malformed file is a
108
+ * missing surface with the path named, not an exception taken out of a
109
+ * heartbeat. */
110
+ export function readApprovalSurface(path: string): ApprovalSurface {
111
+ let access: unknown;
112
+ try {
113
+ access = JSON.parse(readFileSync(path, "utf8"));
114
+ } catch {
115
+ return { kind: "missing", reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: cannot read ${path}` };
116
+ }
117
+ if (access === null || typeof access !== "object" || Array.isArray(access)) {
118
+ return {
119
+ kind: "missing",
120
+ reason: `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: ${path} is not an object`,
121
+ };
122
+ }
123
+ const mode = field(access, "notifyMode");
124
+ const active = mode === "away" || mode === "always" || field(access, "away") === true;
125
+ if (!active) {
126
+ return {
127
+ kind: "missing",
128
+ reason:
129
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: no notifyMode in ${path} — ` +
130
+ `set it to "always" and set notifyChat to the paired owner id`,
131
+ };
132
+ }
133
+ // Only `notifyChat` counts, and `topicsChat` deliberately does not, even
134
+ // though omp-telegram can resolve a forum topic. `notifyTarget()` is handed a
135
+ // topic destination only when `ownTopic && access.topicsChat`, and `ownTopic`
136
+ // is per-session runtime state — the thread this process actually claimed —
137
+ // assigned during topic setup and cleared on failure. A file cannot prove it.
138
+ // So a fleet carrying `topicsChat` alone may or may not have the tool mounted,
139
+ // and this check answers the safe way: #114 exists because a health signal
140
+ // said "ok" through a broken contract, and repeating that with a stale
141
+ // `topicsChat` would be the same bug wearing a different key. `notifyChat` is
142
+ // resolvable from the file alone, so it is what "configured" means here.
143
+ const destination = field(access, "notifyChat");
144
+ if (typeof destination !== "string" || destination.length === 0) {
145
+ const topicsChat = field(access, "topicsChat");
146
+ const viaTopic =
147
+ typeof topicsChat === "string" && topicsChat.length > 0
148
+ ? " — topicsChat only resolves once this session has claimed its forum topic, which this file cannot show"
149
+ : "";
150
+ return {
151
+ kind: "missing",
152
+ reason:
153
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyMode is set but ${path} names no notifyChat` +
154
+ `${viaTopic} — set notifyChat to the paired owner id`,
155
+ };
156
+ }
157
+
158
+ // Naming a destination is not the same as being able to answer in it, and
159
+ // `buildPromptTarget()` applies two more gates before a target exists. Both
160
+ // are decidable from this file, so both belong here — a `ready` that skipped
161
+ // them would say the approval works while a stale or wrong chat id silently
162
+ // reproduces #114.
163
+ //
164
+ // First, `pairedOwnerId()` is the single entry of `allowFrom`; anything else
165
+ // yields no responder and therefore no target.
166
+ const allowFrom = field(access, "allowFrom");
167
+ const owner = Array.isArray(allowFrom) && allowFrom.length === 1 ? String(allowFrom[0]) : undefined;
168
+ if (owner === undefined) {
169
+ return {
170
+ kind: "missing",
171
+ reason:
172
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: ${path} has no single paired owner in allowFrom, ` +
173
+ "so no one is authorised to answer",
174
+ };
175
+ }
176
+
177
+ // Then `canAnswerPrompt()`, which is the same DM/group policy inbound turns
178
+ // use. `isDmChat()` calls anything not starting with "-" a DM, and a DM only
179
+ // authorises the owner's *own* chat — a DM chat id equals its user id, so a
180
+ // notifyChat pointing at any other person is unanswerable by the paired owner.
181
+ if (!destination.startsWith("-")) {
182
+ if (destination !== owner) {
183
+ return {
184
+ kind: "missing",
185
+ reason:
186
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyChat ${destination} in ${path} is a DM that ` +
187
+ `is not the paired owner's (${owner}), and only the owner's own DM authorises an answer — ` +
188
+ "set notifyChat to the paired owner id",
189
+ };
190
+ }
191
+ return { kind: "ready" };
192
+ }
193
+
194
+ // A group destination needs a policy entry, and that policy must admit the
195
+ // owner: an empty or absent `allowFrom` means everyone in the group, else the
196
+ // owner has to be listed.
197
+ const policy = field(field(access, "groups"), destination);
198
+ if (policy === null || typeof policy !== "object") {
199
+ return {
200
+ kind: "missing",
201
+ reason:
202
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: notifyChat ${destination} in ${path} is a group with ` +
203
+ "no entry under groups, so no answer from it is authorised",
204
+ };
205
+ }
206
+ const groupAllow = field(policy, "allowFrom");
207
+ const admitted =
208
+ !Array.isArray(groupAllow) || groupAllow.length === 0 || groupAllow.map(String).includes(owner);
209
+ if (!admitted) {
210
+ return {
211
+ kind: "missing",
212
+ reason:
213
+ `${TELEGRAM_APPROVAL_TOOL} unavailable on local ticks: group ${destination} in ${path} does not admit the ` +
214
+ `paired owner (${owner}) in its allowFrom, so their answer would be rejected`,
215
+ };
216
+ }
217
+ return { kind: "ready" };
218
+ }