omp-conductor 0.16.2 → 0.17.1

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.
Files changed (52) hide show
  1. package/README.md +38 -4
  2. package/REFERENCE.md +18 -12
  3. package/package.json +2 -1
  4. package/schema/config.schema.json +16 -0
  5. package/src/admission.ts +159 -43
  6. package/src/availability.ts +27 -1
  7. package/src/briefs/worker.md +2 -0
  8. package/src/clack-ui.ts +83 -0
  9. package/src/command-manifest.ts +16 -7
  10. package/src/commands/arm.ts +11 -3
  11. package/src/commands/decision.ts +17 -7
  12. package/src/commands/doctor.ts +18 -1
  13. package/src/commands/hold.ts +9 -7
  14. package/src/commands/ledger.ts +25 -4
  15. package/src/commands/message.ts +32 -4
  16. package/src/commands/setup.ts +61 -10
  17. package/src/commands/stats.ts +9 -5
  18. package/src/commands/status.ts +32 -5
  19. package/src/commands/tail.ts +13 -1
  20. package/src/commands/watch.ts +16 -7
  21. package/src/config-schema.ts +20 -0
  22. package/src/config.ts +37 -0
  23. package/src/daemon.ts +1240 -18
  24. package/src/doctor.ts +310 -22
  25. package/src/escalate.ts +560 -57
  26. package/src/failure-class.ts +56 -13
  27. package/src/fleet.ts +224 -47
  28. package/src/gitops.ts +103 -24
  29. package/src/lifecycle.ts +7 -2
  30. package/src/orchestrator-tick.ts +372 -157
  31. package/src/privileged.ts +3 -0
  32. package/src/release-policy.ts +177 -5
  33. package/src/setup-answers.ts +135 -0
  34. package/src/setup-host.ts +193 -4
  35. package/src/setup-install.ts +2 -0
  36. package/src/setup-probe.ts +1 -0
  37. package/src/setup-wizard.ts +1296 -101
  38. package/src/setup.ts +60 -3
  39. package/src/status-render.ts +11 -1
  40. package/src/store.ts +333 -12
  41. package/src/tracker/github.ts +562 -13
  42. package/src/types.ts +204 -2
  43. package/src/ui/progress.ts +32 -0
  44. package/src/ui/style.ts +11 -0
  45. package/src/upgrade.ts +50 -19
  46. package/src/verbs/actions.ts +66 -18
  47. package/src/verbs/protocol.ts +45 -0
  48. package/src/verbs/server.ts +212 -11
  49. package/src/wizard-ui.ts +14 -5
  50. package/src/worker.ts +26 -0
  51. package/systemd/omp-conductor-recover.sh +73 -0
  52. package/systemd/recover-unit-test.sh +61 -0
package/src/escalate.ts CHANGED
@@ -20,9 +20,9 @@
20
20
  * those strings end up in daemon logs and, on the fallback path, in a public
21
21
  * issue comment.
22
22
  */
23
- import { readFileSync } from "node:fs";
23
+ import { readFileSync, statSync } from "node:fs";
24
24
  import { homedir } from "node:os";
25
- import { join } from "node:path";
25
+ import { dirname, join } from "node:path";
26
26
 
27
27
  import { availabilityOpen, interruptDisposition, type InterruptDisposition } from "./availability.ts";
28
28
  import { heldNoticeId } from "./notices.ts";
@@ -434,6 +434,12 @@ export interface ClaimedTopic {
434
434
  threadId: number;
435
435
  /** The bridge's own title for the claim. */
436
436
  name: string;
437
+ /**
438
+ * The claiming pane's process id, when the bridge captured one. omp-telegram
439
+ * treats a claim as live only while this pid is alive; a claim that captured
440
+ * no pid is indistinguishable from a dead one for liveness purposes.
441
+ */
442
+ pid?: number;
437
443
  /** The herdr space the claiming pane sits in, when the bridge captured one. */
438
444
  workspaceLabel?: string;
439
445
  /**
@@ -458,7 +464,7 @@ export interface ClaimedTopic {
458
464
  * The operator's pin still wins whenever it is live. Only a pin that is
459
465
  * *provably* absent from the current claims is replaced, and only by the claim
460
466
  * this project can be identified with — so a deliberately separate alerts topic
461
- * is never hijacked by the pane's own thread. Unreadable bridge state changes
467
+ * is never hijacked by the pane's own thread. Unavailable bridge state changes
462
468
  * nothing, and #318's stale-topic retry remains the last line of defence.
463
469
  *
464
470
  * Identity is read from the herdr space first, and only then from the claim's
@@ -474,8 +480,9 @@ export interface ClaimedTopic {
474
480
  export function resolveProjectTopicId(project: ProjectConfig): number | undefined {
475
481
  const pinned = project.escalation.telegramTopicId;
476
482
  if (pinned === undefined) return undefined;
477
- const claims = claimedTelegramTopics();
478
- if (claims.length === 0) return pinned;
483
+ const result = claimedTelegramTopics();
484
+ if (result.kind !== "ok" || result.claims.length === 0) return pinned;
485
+ const claims = result.claims;
479
486
  if (claims.some((claim) => claim.threadId === pinned)) return pinned;
480
487
  const match = claimForProject(claims, project.name);
481
488
  if (match === undefined) return pinned;
@@ -488,15 +495,64 @@ export function resolveProjectTopicId(project: ProjectConfig): number | undefine
488
495
  }
489
496
 
490
497
  /**
491
- * The one claim answering to a project's identity herdr space first, title
492
- * second — or nothing when none or several do (#412).
498
+ * The one claim answering to this project — or the reason there is none.
499
+ *
500
+ * `kind: "match"` is the unique claim a substitution can follow. `ambiguous`
501
+ * is the multi-answer case: several claims wearing the project's space (or,
502
+ * when no claim wears a space at all, several wearing its title) means the
503
+ * bridge cannot say which pane belongs to this project. A caller that must
504
+ * not certify a guess — doctor's arm probe, which would have to pick a
505
+ * session file — needs this spelled out, not collapsed into "none".
506
+ */
507
+ export type ProjectClaim =
508
+ | { readonly kind: "match"; readonly claim: ClaimedTopic }
509
+ | { readonly kind: "none" }
510
+ | { readonly kind: "ambiguous"; readonly claimants: readonly ClaimedTopic[] };
511
+
512
+ /**
513
+ * How the live claims answer to a project, identity spellings in order:
514
+ * unique herdr space, then unique title.
515
+ *
516
+ * Ambiguity is not a coin toss: paging the wrong project's topic is worse
517
+ * than the flat-chat degrade #318 already handles, and scanning the wrong
518
+ * pane's session misses the reply entirely. The title is a fallback for
519
+ * bridges that never captured a space, so it runs only when *no* claim
520
+ * carries the project's space — several claims wearing the space is an
521
+ * ambiguity the title cannot resolve, because the titled claim may be a
522
+ * sibling pane, not this project (#626).
523
+ */
524
+ export function resolveProjectClaim(
525
+ claims: readonly ClaimedTopic[],
526
+ projectName: string,
527
+ ): ProjectClaim {
528
+ const bySpace = claims.filter((claim) => claim.workspaceLabel === projectName);
529
+ if (bySpace.length > 0) {
530
+ return bySpace.length === 1
531
+ ? { kind: "match", claim: bySpace[0]! }
532
+ : { kind: "ambiguous", claimants: bySpace };
533
+ }
534
+ const byTitle = claims.filter((claim) => claim.name === projectName);
535
+ if (byTitle.length === 0) return { kind: "none" };
536
+ return byTitle.length === 1
537
+ ? { kind: "match", claim: byTitle[0]! }
538
+ : { kind: "ambiguous", claimants: byTitle };
539
+ }
540
+
541
+ /**
542
+ * The unique live claim answering to a project, if exactly one does.
543
+ *
544
+ * `undefined` covers both "no claim answers" and "several answer" — {@link
545
+ * resolveProjectClaim} is the one that tells them apart. Callers that may
546
+ * only fall back (a substitution keeps the pin, a session dir falls back to
547
+ * the tick cwd) read that as "nothing to follow"; callers that must not
548
+ * certify a guess need the richer outcome.
493
549
  */
494
550
  export function claimForProject(
495
551
  claims: readonly ClaimedTopic[],
496
552
  projectName: string,
497
553
  ): ClaimedTopic | undefined {
498
- const bySpace = soleClaim(claims, (claim) => claim.workspaceLabel === projectName);
499
- return bySpace ?? soleClaim(claims, (claim) => claim.name === projectName);
554
+ const match = resolveProjectClaim(claims, projectName);
555
+ return match.kind === "match" ? match.claim : undefined;
500
556
  }
501
557
 
502
558
  /**
@@ -513,79 +569,130 @@ export function claimForProject(
513
569
  * directory, which is the honest answer for a host that predates claims.
514
570
  */
515
571
  export function resolveClaimedSessionFile(project: ProjectConfig): string | undefined {
516
- const claims = claimedTelegramTopics();
517
- if (claims.length === 0) return undefined;
518
- return claimForProject(claims, project.name)?.sessionFile;
572
+ const result = claimedTelegramTopics();
573
+ if (result.kind !== "ok" || result.claims.length === 0) return undefined;
574
+ return claimForProject(result.claims, project.name)?.sessionFile;
519
575
  }
520
576
 
521
577
  /**
522
- * The one claim answering to `predicate`, or nothing when none or several do.
523
- *
524
- * Ambiguity is not a coin toss: two claims wearing the same identity means the
525
- * bridge cannot say which pane belongs to this project, and paging the wrong
526
- * project's topic is worse than the flat-chat degrade #318 already handles.
578
+ * omp-telegram's canonical thread-id key spelling: a plain positive decimal
579
+ * integer ("4001"). `Number(id)` alone would coerce `""`, whitespace, `"0"`,
580
+ * `"01"`, `"1e2"` or `"0x10"` into integers the bridge never wrote and a
581
+ * thread id the bridge never wrote is a malformed registry, not a readable
582
+ * topic (#626).
527
583
  */
528
- function soleClaim(
529
- claims: readonly ClaimedTopic[],
530
- predicate: (claim: ClaimedTopic) => boolean,
531
- ): ClaimedTopic | undefined {
532
- const hits = claims.filter(predicate);
533
- return hits.length === 1 ? hits[0] : undefined;
584
+ function parseClaimedTopicId(id: string): number | undefined {
585
+ if (!/^[1-9][0-9]*$/.test(id)) return undefined;
586
+ const parsed = Number(id);
587
+ return Number.isSafeInteger(parsed) ? parsed : undefined;
534
588
  }
535
589
 
590
+ /**
591
+ * What omp-telegram's live claim registry says, or why it could not be read.
592
+ *
593
+ * `kind: "unavailable"` carries the failure instead of collapsing it to an
594
+ * empty list, so a caller that must tell "no claims" from "cannot tell" — the
595
+ * #626 doctor check — can stay fail-closed. `kind: "missing"` is the one
596
+ * absence that is not an operator error: a bridge that has never run a forum
597
+ * pane has no file at all, exactly as a missing token is not one. Callers that
598
+ * must not certify a route keep both non-`ok` kinds diagnostic; the setup
599
+ * wizard is allowed to keep the missing-file silence (#318) while surfacing a
600
+ * present-but-broken registry.
601
+ */
602
+ export type ClaimedTopicsResult =
603
+ | { kind: "ok"; claims: readonly ClaimedTopic[] }
604
+ | { kind: "missing" }
605
+ | { kind: "unavailable"; problem: string };
606
+
536
607
  /**
537
608
  * omp-telegram's live topic claims, from
538
609
  * `{ threads: { "<threadId>": { name, workspaceLabel } } }`. Borrowed exactly as
539
- * the token is, and tolerant of every shape it might not be a bridge that has
540
- * never run a forum pane has no file at all.
610
+ * the token is. A registry that cannot be read or does not parse as the bridge
611
+ * shape is `unavailable` with a human reason, never a silent empty list — the
612
+ * empty list is reserved for a registry that really carries no claims. A single
613
+ * malformed claim member makes the whole registry `unavailable`: a partial list
614
+ * would let doctor certify a route it never verified (#626). A registry that
615
+ * does not exist is `missing`, not an empty list: the bridge may simply never
616
+ * have claimed, and the caller decides whether that is silence or signal.
541
617
  *
542
618
  * `stateDir` is passed by the setup wizard, which has already probed for the
543
619
  * bridge; send-time callers let it resolve the same way the token does.
544
620
  */
545
- export function claimedTelegramTopics(stateDir?: string): ClaimedTopic[] {
621
+ export function claimedTelegramTopics(stateDir?: string): ClaimedTopicsResult {
546
622
  const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
547
623
  const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
624
+ const registry = join(dir, "threads.json");
548
625
  let raw: unknown;
549
626
  try {
550
- raw = JSON.parse(readFileSync(join(dir, "threads.json"), "utf8"));
551
- } catch {
552
- return [];
627
+ raw = JSON.parse(readFileSync(registry, "utf8"));
628
+ } catch (err) {
629
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
630
+ return {
631
+ kind: "unavailable",
632
+ problem: `cannot read ${registry}: ${err instanceof Error ? err.message : String(err)}`,
633
+ };
634
+ }
635
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("threads" in raw)) {
636
+ return {
637
+ kind: "unavailable",
638
+ problem: `${registry} is not a { threads: { "<threadId>": { name, workspaceLabel } } } registry`,
639
+ };
553
640
  }
554
- if (typeof raw !== "object" || raw === null || Array.isArray(raw) || !("threads" in raw)) return [];
555
641
  const threads = raw.threads;
556
- if (typeof threads !== "object" || threads === null || Array.isArray(threads)) return [];
642
+ if (typeof threads !== "object" || threads === null || Array.isArray(threads)) {
643
+ return {
644
+ kind: "unavailable",
645
+ problem: `${registry}: "threads" is not a map of claims`,
646
+ };
647
+ }
557
648
  const out: ClaimedTopic[] = [];
558
649
  for (const [id, entry] of Object.entries(threads)) {
559
- const threadId = Number(id);
560
- if (!Number.isFinite(threadId) || !Number.isSafeInteger(threadId)) continue;
650
+ const threadId = parseClaimedTopicId(id);
651
+ if (threadId === undefined) {
652
+ return {
653
+ kind: "unavailable",
654
+ problem: `${registry}: claim "${id}" is not a thread id`,
655
+ };
656
+ }
657
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
658
+ return {
659
+ kind: "unavailable",
660
+ problem: `${registry}: claim "${id}" is not a { name, workspaceLabel } claim`,
661
+ };
662
+ }
663
+ // A claim member is only as good as its strings: a non-string field means
664
+ // the registry is not the shape the bridge writes, and coercing it would
665
+ // let doctor certify a route it never verified (#626). Same for the pid —
666
+ // the bridge records a number or nothing, so any other spelling fails the
667
+ // registry instead of being dressed up as a live owner. Empty strings are
668
+ // the bridge's own spelling for an absent field, so they stay readable.
669
+ if ("name" in entry && typeof entry.name !== "string") {
670
+ return { kind: "unavailable", problem: `${registry}: claim "${id}" has a non-string name` };
671
+ }
672
+ if ("workspaceLabel" in entry && typeof entry.workspaceLabel !== "string") {
673
+ return { kind: "unavailable", problem: `${registry}: claim "${id}" has a non-string workspaceLabel` };
674
+ }
675
+ if ("sessionFile" in entry && typeof entry.sessionFile !== "string") {
676
+ return { kind: "unavailable", problem: `${registry}: claim "${id}" has a non-string sessionFile` };
677
+ }
678
+ if ("pid" in entry && (typeof entry.pid !== "number" || !Number.isFinite(entry.pid))) {
679
+ return { kind: "unavailable", problem: `${registry}: claim "${id}" has a non-numeric pid` };
680
+ }
561
681
  let name = id;
562
682
  let workspaceLabel: string | undefined;
563
683
  let sessionFile: string | undefined;
564
- if (typeof entry === "object" && entry !== null) {
565
- if ("name" in entry) {
566
- const candidate = entry.name;
567
- if (typeof candidate === "string" && candidate.trim() !== "") name = candidate.trim();
568
- }
569
- if ("workspaceLabel" in entry) {
570
- const candidate = entry.workspaceLabel;
571
- if (typeof candidate === "string" && candidate.trim() !== "") workspaceLabel = candidate.trim();
572
- }
573
- if ("sessionFile" in entry) {
574
- const candidate = entry.sessionFile;
575
- if (typeof candidate === "string" && candidate.trim() !== "") sessionFile = candidate.trim();
576
- }
577
- }
578
- out.push(
579
- workspaceLabel === undefined && sessionFile === undefined
580
- ? { threadId, name }
581
- : workspaceLabel === undefined
582
- ? { threadId, name, sessionFile }
583
- : sessionFile === undefined
584
- ? { threadId, name, workspaceLabel }
585
- : { threadId, name, workspaceLabel, sessionFile },
586
- );
684
+ let pid: number | undefined;
685
+ if ("name" in entry && entry.name.trim() !== "") name = entry.name.trim();
686
+ if ("workspaceLabel" in entry && entry.workspaceLabel.trim() !== "") workspaceLabel = entry.workspaceLabel.trim();
687
+ if ("sessionFile" in entry && entry.sessionFile.trim() !== "") sessionFile = entry.sessionFile.trim();
688
+ if ("pid" in entry) pid = entry.pid;
689
+ const claim: ClaimedTopic = { threadId, name };
690
+ if (workspaceLabel !== undefined) claim.workspaceLabel = workspaceLabel;
691
+ if (sessionFile !== undefined) claim.sessionFile = sessionFile;
692
+ if (pid !== undefined) claim.pid = pid;
693
+ out.push(claim);
587
694
  }
588
- return out;
695
+ return { kind: "ok", claims: out };
589
696
  }
590
697
 
591
698
  /**
@@ -611,6 +718,402 @@ export function telegramTopicsTidy(stateDirPath?: string): boolean {
611
718
  }
612
719
  }
613
720
 
721
+ /**
722
+ * Whether a pid is a live process as `topics.ts` judges a claim
723
+ * (`src/topics.ts:isAlive`): `process.kill(pid, 0)` throws `ESRCH` for a gone
724
+ * process, and **any** other error — including `EPERM`, which means the
725
+ * process exists but is owned by another uid — is dead here. The bridge
726
+ * declines a claim it cannot prove is alive, so conductor must too; a claim
727
+ * the bridge would treat as dead is never dressed up as a live recipient.
728
+ *
729
+ * `kill` is injectable so a test can deterministically throw `EPERM` without
730
+ * owning another uid's process. Distinct from {@link lockPidAlive}, which
731
+ * applies `api.ts`'s opposite rule to the poll lock.
732
+ */
733
+ export function pidAlive(
734
+ pid: number,
735
+ kill: (target: number, signal: number) => void = (target, signal) => {
736
+ process.kill(target, signal);
737
+ },
738
+ ): boolean {
739
+ try {
740
+ kill(pid, 0);
741
+ return true;
742
+ } catch {
743
+ return false;
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Whether a bot.lock owner pid is a live process as the bridge's `api.ts`
749
+ * judges it (`src/api.ts:pidAlive`): `EPERM` means the process exists but is
750
+ * owned by another uid, which is *live* — the bridge reads its own lock with
751
+ * this rule (`acquireLock`), so a lock owner is judged differently from a
752
+ * topic claim or the DM owner (see {@link pidAlive}).
753
+ */
754
+ export function lockPidAlive(
755
+ pid: number,
756
+ kill: (target: number, signal: number) => void = (target, signal) => {
757
+ process.kill(target, signal);
758
+ },
759
+ ): boolean {
760
+ try {
761
+ kill(pid, 0);
762
+ return true;
763
+ } catch (err) {
764
+ return (err as NodeJS.ErrnoException).code === "EPERM";
765
+ }
766
+ }
767
+
768
+ /**
769
+ * The omp-telegram bridge's single-poller lock cadence, ported from
770
+ * `src/api.ts` (`LOCK_HEARTBEAT_MS`): the elected poller refreshes `bot.lock`'s
771
+ * mtime on this interval while it polls.
772
+ */
773
+ export const TELEGRAM_LOCK_HEARTBEAT_MS = 15_000;
774
+
775
+ /**
776
+ * A `bot.lock` whose mtime is younger than this is live regardless of its
777
+ * recorded pid, ported from `src/api.ts` (`LOCK_FRESH_MS`). This is the
778
+ * contract that makes daemon-pid liveness insufficient: on a `getMe` or poller
779
+ * failure the daemon releases `bot.lock`, stops its heartbeat and sleeps while
780
+ * its own pid and `daemon.json` remain live — so only the lock (or its absence)
781
+ * says whether anything is actually polling.
782
+ */
783
+ export const TELEGRAM_LOCK_FRESH_MS = 45_000;
784
+
785
+ /** The owner record omp-telegram writes into `bot.lock`. */
786
+ export interface TelegramLockOwner {
787
+ pid: number;
788
+ startedAt: number;
789
+ nonce?: string;
790
+ name?: string;
791
+ sessionId?: string;
792
+ sessionFile?: string;
793
+ }
794
+
795
+ /**
796
+ * Outcome of reading the bridge's `bot.lock`, told apart so absent or
797
+ * malformed state is a named failure, never a silent "no poller" (#612).
798
+ */
799
+ export type TelegramPollState =
800
+ | { kind: "ok"; owner: TelegramLockOwner; mtimeMs: number }
801
+ | { kind: "unreadable" };
802
+
803
+ /**
804
+ * Parse a `bot.lock` owner record, mirroring omp-telegram's own
805
+ * `parseLockOwner` (`src/api.ts`) exactly: the first line is the owning pid,
806
+ * read with `Number.parseInt(firstLine, 10)` — a positive decimal only, so
807
+ * `0x10` parses to 0 and is no owner rather than becoming 16, and PID 1 is a
808
+ * valid owner (the bridge excludes only `pid <= 0`). A legacy bare-pid lock
809
+ * is still an owner (`{ pid, startedAt: 0 }`); a v2 record whose JSON object
810
+ * repeats the first-line pid is returned whole — carrying the poller's
811
+ * session identity — and a malformed v2 record falls back to the first-line
812
+ * pid exactly as the bridge does.
813
+ */
814
+ function parseTelegramLockOwner(content: string): TelegramLockOwner | undefined {
815
+ const trimmed = content.trim();
816
+ if (trimmed === "") return undefined;
817
+ const newline = trimmed.indexOf("\n");
818
+ const firstLine = newline === -1 ? trimmed : trimmed.slice(0, newline);
819
+ const pid = Number.parseInt(firstLine, 10) || 0;
820
+ if (pid <= 0) return undefined;
821
+ if (newline !== -1) {
822
+ try {
823
+ const owner: unknown = JSON.parse(trimmed.slice(newline + 1));
824
+ if (typeof owner === "object" && owner !== null && !Array.isArray(owner)) {
825
+ const record = owner as { readonly pid?: unknown };
826
+ if (record.pid === pid) return owner as TelegramLockOwner;
827
+ }
828
+ } catch {
829
+ // Malformed v2 record — fall back to the first-line pid, as the bridge does.
830
+ }
831
+ }
832
+ return { pid, startedAt: 0 };
833
+ }
834
+
835
+ /**
836
+ * The omp-telegram bridge's poll ownership, from the same state dir as
837
+ * `threads.json` (`bot.lock`). `unreadable` when the file is absent,
838
+ * unreadable or not a valid owner record — a bridge that is not polling holds
839
+ * no lock, and a malformed record is a named failure, never a pass. The
840
+ * `mtimeMs` is the file's own mtime, so the caller can apply the bridge's
841
+ * freshness rule (a heartbeat younger than {@link TELEGRAM_LOCK_FRESH_MS} is
842
+ * live even when the recorded pid is gone).
843
+ */
844
+ export function readTelegramPollState(stateDir?: string): TelegramPollState {
845
+ const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
846
+ const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
847
+ const lockPath = join(dir, "bot.lock");
848
+ let mtimeMs: number;
849
+ let raw: string;
850
+ try {
851
+ mtimeMs = statSync(lockPath).mtimeMs;
852
+ raw = readFileSync(lockPath, "utf8");
853
+ } catch {
854
+ return { kind: "unreadable" };
855
+ }
856
+ const owner = parseTelegramLockOwner(raw);
857
+ if (owner === undefined) return { kind: "unreadable" };
858
+ return { kind: "ok", owner, mtimeMs };
859
+ }
860
+
861
+ /**
862
+ * The live untopiced-DM recipient omp-telegram pins (`dm-owner.json`), as much
863
+ * as conductor reads. The bridge routes an *unthreaded* private reply to
864
+ * whatever session owns this file — never to a project-matched
865
+ * `threads.json` claim, which is the forum route's owner (`src/bridge.ts`).
866
+ */
867
+ export interface TelegramDmOwner {
868
+ pid: number;
869
+ name?: string;
870
+ sessionId?: string;
871
+ sessionFile?: string;
872
+ workspaceLabel?: string;
873
+ }
874
+
875
+ /**
876
+ * Outcome of reading the bridge's `dm-owner.json`. Mirrors omp-telegram's own
877
+ * `loadDmOwner` (`src/topics.ts`): the record is a live `ThreadEntry`, so a
878
+ * `pid` that is not a finite number — an absent file, or a malformed record —
879
+ * is no owner at all. Absent and unreadable are deliberately one state: for
880
+ * the flat route both mean the private reply has no pinned recipient, which
881
+ * is a named failure, never a silent pass.
882
+ */
883
+ export type TelegramDmOwnerState =
884
+ | { kind: "ok"; owner: TelegramDmOwner }
885
+ | { kind: "unreadable" };
886
+
887
+ /**
888
+ * The bridge's untopiced-DM recipient, from the same state dir as
889
+ * `threads.json` (`dm-owner.json`).
890
+ */
891
+ export function readTelegramDmOwner(stateDir?: string): TelegramDmOwnerState {
892
+ const override = stateDir?.trim() ?? process.env.OMP_TELEGRAM_STATE_DIR?.trim();
893
+ const dir = override ? override : join(homedir(), ".omp", "agent", "telegram");
894
+ let raw: string;
895
+ try {
896
+ raw = readFileSync(join(dir, "dm-owner.json"), "utf8");
897
+ } catch {
898
+ return { kind: "unreadable" };
899
+ }
900
+ let parsed: unknown;
901
+ try {
902
+ parsed = JSON.parse(raw);
903
+ } catch {
904
+ return { kind: "unreadable" };
905
+ }
906
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
907
+ return { kind: "unreadable" };
908
+ }
909
+ const record = parsed as { readonly [key: string]: unknown };
910
+ const pid = record["pid"];
911
+ if (typeof pid !== "number" || !Number.isFinite(pid)) return { kind: "unreadable" };
912
+ const owner: TelegramDmOwner = { pid };
913
+ for (const key of ["name", "sessionId", "sessionFile", "workspaceLabel"] as const) {
914
+ const value = record[key];
915
+ if (typeof value === "string" && value.trim() !== "") owner[key] = value.trim();
916
+ }
917
+ return { kind: "ok", owner };
918
+ }
919
+
920
+ /**
921
+ * The paired inbound channel as the tick defines it, probed exactly the way
922
+ * `armTicks` probes it (the access file must parse, the bridge must be
923
+ * enabled and `allowFrom` must hold exactly one paired owner). Anything else
924
+ * — a missing or unreadable file, a disabled bridge, a missing or foreign
925
+ * pair — is down with the arm's own reason.
926
+ */
927
+ export type TelegramChannelState =
928
+ | { kind: "up"; owner: string }
929
+ | { kind: "down"; reason: string };
930
+
931
+ /**
932
+ * The paired inbound channel verdict, byte-identical to the one `armTicks`
933
+ * reads through its own `readPairedChannel` (fleet.ts keeps that one private;
934
+ * this port lives with the other bridge-state readers so `doctor` and the arm
935
+ * challenge can never disagree): the access file parses, the bridge is
936
+ * enabled and `allowFrom` holds exactly one paired owner id. Every failure
937
+ * reason is the arm's own, so a doctor finding and the arm's refusal name the
938
+ * same fact.
939
+ */
940
+ export function readTelegramChannel(path: string): TelegramChannelState {
941
+ let parsed: unknown;
942
+ try {
943
+ parsed = JSON.parse(readFileSync(path, "utf8"));
944
+ } catch {
945
+ return { kind: "down", reason: "unreadable or missing access.json" };
946
+ }
947
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
948
+ return { kind: "down", reason: "access.json is not an object" };
949
+ }
950
+ const access = parsed as { readonly [key: string]: unknown };
951
+ if (access["enabled"] !== true) return { kind: "down", reason: "bridge disabled (enabled !== true)" };
952
+ const allowFrom = access["allowFrom"];
953
+ if (!Array.isArray(allowFrom) || allowFrom.length !== 1) {
954
+ return { kind: "down", reason: "allowFrom must hold exactly one paired owner id" };
955
+ }
956
+ const owner = allowFrom[0];
957
+ if (typeof owner !== "string" && typeof owner !== "number") {
958
+ return { kind: "down", reason: "paired owner id is not a string or number" };
959
+ }
960
+ return { kind: "up", owner: String(owner) };
961
+ }
962
+
963
+ /** A fact a live-plumbing verdict verified; a pass names which kinds it proved. */
964
+ export type TelegramPlumbingProof = "claim" | "dm-owner" | "poller";
965
+
966
+ /**
967
+ * The bounded reasons a live-plumbing verdict fails, each naming the fact that
968
+ * failed. Forum-route failures name the topic claim the challenge would send
969
+ * into; flat-route failures name the reply's recipient. An unreadable or
970
+ * malformed state is always its own named failure — never folded into "no
971
+ * claim"/"no owner", which would read as a pass.
972
+ */
973
+ export type TelegramPlumbingFailureReason =
974
+ | "channel-down"
975
+ | "registry-unreadable"
976
+ | "no-topic-claim"
977
+ | "claim-dead"
978
+ | "claim-session-outside"
979
+ | "no-dm-owner"
980
+ | "dm-owner-dead"
981
+ | "dm-owner-unrelated"
982
+ | "no-poller-state"
983
+ | "poller-dead";
984
+
985
+ /**
986
+ * The liveness-checked verdict on omp-telegram plumbing, reusable by `arm`,
987
+ * `doctor` and recovery. A pass names the proof kinds only — never a chat or
988
+ * topic identifier, which are exactly the values that leak from log lines.
989
+ */
990
+ export type TelegramPlumbingVerdict =
991
+ | { ok: true; proof: readonly TelegramPlumbingProof[] }
992
+ | { ok: false; reason: TelegramPlumbingFailureReason };
993
+
994
+ /** Pure-code wiring so a test injects live/dead pids and freshness without touching the host. */
995
+ export interface TelegramPlumbingProbe {
996
+ /**
997
+ * The typed claim-registry read. Consulted only on the forum route — a flat
998
+ * challenge never reads a claim. Any non-`ok` kind is a named failure.
999
+ */
1000
+ registry: ClaimedTopicsResult;
1001
+ /** The typed `bot.lock` poll-ownership read — absent/malformed is a named failure. */
1002
+ poll: TelegramPollState;
1003
+ /**
1004
+ * The typed `dm-owner.json` read — the untopiced-DM recipient a flat
1005
+ * challenge's reply routes through. Consulted only on the flat route.
1006
+ */
1007
+ dmOwner: TelegramDmOwnerState;
1008
+ /**
1009
+ * The paired inbound channel state, exactly as `armTicks` reads it. Down is
1010
+ * a named failure on both routes: the arm challenge sends to the paired
1011
+ * owner, so no channel means no challenge can go out at all.
1012
+ */
1013
+ channel: TelegramChannelState;
1014
+ /**
1015
+ * Whether a recorded claim or dm-owner pid is a live process, with `topics.ts`
1016
+ * semantics (`src/topics.ts:isAlive`): `EPERM` is dead. Distinct from
1017
+ * {@link lockPidAlive} — the bridge itself uses two rules.
1018
+ */
1019
+ alive: (pid: number) => boolean;
1020
+ /**
1021
+ * Whether a recorded bot.lock owner pid is a live process, with `api.ts`
1022
+ * semantics (`src/api.ts:pidAlive`): `EPERM` means the process exists but is
1023
+ * owned by another uid, so it is *live*. The bridge reads its own lock with
1024
+ * this rule (`acquireLock`), so a lock owner is judged differently from a
1025
+ * topic claim or the DM owner.
1026
+ */
1027
+ lockAlive: (pid: number) => boolean;
1028
+ /**
1029
+ * Whether a lock-file mtime is younger than {@link TELEGRAM_LOCK_FRESH_MS} —
1030
+ * the bridge treats a fresh heartbeat as live even when the recorded pid is
1031
+ * gone (its own `acquireLock` liveness rule).
1032
+ */
1033
+ fresh: (mtimeMs: number) => boolean;
1034
+ }
1035
+
1036
+ /**
1037
+ * The session surface the arm waiter actually scans for the challenge reply:
1038
+ * {@link armTicks} polls every `.jsonl` transcript *directly inside* these
1039
+ * directories — the tick-cwd-derived session directory plus, when the
1040
+ * project's claim names a session file elsewhere, that file's directory. The
1041
+ * recipient of the reply (a claim's, the DM owner's, or the local poll
1042
+ * owner's session file) must land in this set, or the reply can never satisfy
1043
+ * the challenge even though inbound works.
1044
+ */
1045
+ export type TelegramPlumbingScan = { dirs: readonly string[] };
1046
+
1047
+ /**
1048
+ * Whether Telegram plumbing is live *now*, without the 300-second challenge and
1049
+ * without any network call, on the route {@link armTicks} actually sends on.
1050
+ *
1051
+ * The paired inbound channel must be up on both routes. Forum route (a
1052
+ * send-time topic was resolved): the claim for that *exact* topic must be
1053
+ * live — never an unrelated project-name match, which is what lets a live
1054
+ * sibling claim false-pass a stale pin — and its session identity must land
1055
+ * inside the scan surface or the reply can never be seen. Flat route (no
1056
+ * topic): the challenge is unthreaded and the reply routes through the DM
1057
+ * owner, so the pinned owner must be live and inside the scan surface; with
1058
+ * no pinned owner (`/telegram own clear`) the bridge delivers locally in the
1059
+ * polling session, so the lock's v2 owner record must carry a session
1060
+ * identity inside the scan surface — a daemon-only or unrelated owner is not a
1061
+ * recipient. On both routes the bridge's poll ownership must be live too:
1062
+ * `bot.lock` holds a valid owner whose pid is alive, or whose heartbeat is
1063
+ * fresh. Any unreadable or malformed state is a deterministic typed failure
1064
+ * naming the failed fact — never a pass. Poll liveness deliberately follows
1065
+ * the lock, not `daemon.json`: on a fatal the daemon releases `bot.lock` and
1066
+ * stops its heartbeat while its own pid stays live (#612 ecosystem
1067
+ * correction).
1068
+ */
1069
+ export function telegramPlumbingVerdict(
1070
+ sendTopic: number | undefined,
1071
+ scan: TelegramPlumbingScan,
1072
+ probe: TelegramPlumbingProbe,
1073
+ ): TelegramPlumbingVerdict {
1074
+ const { registry, poll, dmOwner, channel, alive, lockAlive, fresh } = probe;
1075
+ if (channel.kind === "down") return { ok: false, reason: "channel-down" };
1076
+ let recipient: TelegramPlumbingProof;
1077
+ if (sendTopic !== undefined) {
1078
+ if (registry.kind !== "ok") return { ok: false, reason: "registry-unreadable" };
1079
+ const claim = registry.claims.find((c) => c.threadId === sendTopic);
1080
+ if (claim === undefined) return { ok: false, reason: "no-topic-claim" };
1081
+ if (claim.pid === undefined || !alive(claim.pid)) return { ok: false, reason: "claim-dead" };
1082
+ if (claim.sessionFile !== undefined && !sessionWithinScan(claim.sessionFile, scan.dirs)) {
1083
+ return { ok: false, reason: "claim-session-outside" };
1084
+ }
1085
+ recipient = "claim";
1086
+ } else {
1087
+ if (dmOwner.kind === "ok") {
1088
+ if (dmOwner.owner.pid === undefined || !alive(dmOwner.owner.pid)) return { ok: false, reason: "dm-owner-dead" };
1089
+ if (dmOwner.owner.sessionFile === undefined || !sessionWithinScan(dmOwner.owner.sessionFile, scan.dirs)) {
1090
+ return { ok: false, reason: "dm-owner-unrelated" };
1091
+ }
1092
+ recipient = "dm-owner";
1093
+ } else {
1094
+ // No pinned DM owner — after `/telegram own clear` the bridge delivers
1095
+ // local replies in the polling session itself, so the lock's v2 owner
1096
+ // record must name that session. A daemon-only or unrelated owner is no
1097
+ // recipient: the reply would land in a transcript arm never scans.
1098
+ if (poll.kind !== "ok") return { ok: false, reason: "no-dm-owner" };
1099
+ if (poll.owner.sessionFile === undefined || !sessionWithinScan(poll.owner.sessionFile, scan.dirs)) {
1100
+ return { ok: false, reason: "no-dm-owner" };
1101
+ }
1102
+ recipient = "poller";
1103
+ }
1104
+ }
1105
+ if (poll.kind !== "ok") return { ok: false, reason: "no-poller-state" };
1106
+ if (!lockAlive(poll.owner.pid) && !fresh(poll.mtimeMs)) return { ok: false, reason: "poller-dead" };
1107
+ // When the poller's own session is the flat recipient, the live lock is the
1108
+ // whole proof — the recipient and the poll are one fact, not two kinds.
1109
+ return { ok: true, proof: recipient === "poller" ? ["poller"] : [recipient, "poller"] };
1110
+ }
1111
+
1112
+ /** Whether a session file sits directly inside one of the arm scan directories. */
1113
+ function sessionWithinScan(sessionFile: string, dirs: readonly string[]): boolean {
1114
+ return dirs.includes(dirname(sessionFile));
1115
+ }
1116
+
614
1117
  /**
615
1118
  * The one Telegram send in this package. Exported so the report outbox (#123)
616
1119
  * reuses it rather than forking it: the response handling below is load-bearing