Skip to content

feat: add private Telegram bridge - #1444

Open
palinopr wants to merge 17 commits into
kunchenguid:mainfrom
palinopr:feat/telegram-bridge-v1
Open

feat: add private Telegram bridge#1444
palinopr wants to merge 17 commits into
kunchenguid:mainfrom
palinopr:feat/telegram-bridge-v1

Conversation

@palinopr

@palinopr palinopr commented Aug 1, 2026

Copy link
Copy Markdown

Intent

Build a disabled-by-default private one-captain Telegram Bot API long-poll bridge for Firstmate with owner-only local pairing, fail-closed authorization and durable exactly-once wake correlation, approval identity binding, bounded ambiguity-safe outbound receipts, quiet captain-relevant cross-project notifications, one deterministic safe rich/plain presentation boundary for every reply and selected supervised-worker update, full five-harness/runtime integration, tests, docs, and a green pull request; do not perform live Telegram setup, merge, deploy, restart, or self-update.

What Changed

  • Add a disabled-by-default, locally paired private Telegram Bot API bridge for one captain, with authorized long polling, threaded replies, approval binding, durable request/receipt state, and safe rich/plain rendering.
  • Integrate Telegram requests and quiet captain-relevant cross-project notifications with the existing wake queue, watcher, session-lock, turn-end, and five-harness supervision lifecycle.
  • Harden shared supervision and portability with progress-aware wedge detection, durable watcher-cycle evidence, attached-arm continuity fixes, macOS Bash 3.2-safe ship briefs, and expanded CI, tests, and documentation.

Risk Assessment

✅ Low: Captain, the repair admits Grok’s supported nested same-session turn only when the exact live lock holder appears in its ancestry, while independent lock-refused sessions remain unable to rotate the epoch.

Testing

Confirmed the exact target commit and scope; the focused bridge suite and six affected supervision/harness suites pass after two narrow test-fixture fixes. A local fake Bot API journey demonstrated disabled default state, protected numeric pairing, unauthorized silence, exact durable request correlation, threaded rich delivery with a readable plain snapshot and sent receipt, body retirement, quiet routine progress, and a selected green-PR notification. No Telegram screenshot was captured because live Telegram setup was explicitly forbidden; the exact CLI transcript and Bot API payloads provide reviewer-visible evidence instead.

Evidence: End-to-end fake Telegram bridge transcript
1. Disabled-by-default operator status
Telegram bridge: disabled

2. Owner-only local pairing with numeric identity only
Pending private-chat numeric IDs:
  user_id=42 chat_id=42
Type the exact pair to allow, as: PAIR <user_id> <chat_id>
Telegram bridge: paired locally for one private captain chat; rerun session start to activate polling
Telegram bridge: enabled for one private captain chat
Protected config summary: {"version":1,"enabled":true,"allowed_user_id":"42","allowed_chat_id":"42","token_present":true}
Protected config mode: 600

3. Unauthorized private update is silent
Accepted request count after unauthorized update: 0


4. Authorized captain request is persisted with one correlation
telegram-requests 1
{
  "request_id": "tg-11-501",
  "text": "Show me the bridge status & review link.",
  "telegram_message_id": 501,
  "wake_state": "offered",
  "wake_offer_count": 1
}
Durable notification: kind=check key=tg-11-501 payload=telegram-request tg-11-501

5. Threaded reply crosses the deterministic rich/plain boundary
rendered reply-e2e 1
Restart-stable presentation snapshot:
{
  "rich_text": "<b>Bridge status</b>\n\n• Your private request was accepted once.\n• Review <a href=\"https://example.test/pr/1442?a=1&amp;b=2\">the pull request</a>.\n\n<pre>Check         | Result            \n--------------+-------------------\nAuthorization | Exact captain chat\nDelivery      | Durable receipt   </pre>",
  "plain_text": "Bridge status\n\n• Your private request was accepted once.\n• Review the pull request (https://example.test/pr/1442?a=1&b=2).\n\nCheck         | Result            \n--------------+-------------------\nAuthorization | Exact captain chat\nDelivery      | Durable receipt   ",
  "utf16_units": null,
  "display_width": 65
}
sent reply-e2e 901
Telegram sendMessage payload:
{
  "chat_id": 42,
  "parse_mode": "HTML",
  "reply_parameters": {
    "message_id": 501,
    "allow_sending_without_reply": false
  },
  "text": "<b>Bridge status</b>\n\n• Your private request was accepted once.\n• Review <a href=\"https://example.test/pr/1442?a=1&amp;b=2\">the pull request</a>.\n\n<pre>Check         | Result            \n--------------+-------------------\nAuthorization | Exact captain chat\nDelivery      | Durable receipt   </pre>",
  "has_buttons": false
}
Durable outbound receipt:
{
  "receipt_id": "reply-e2e",
  "kind": "reply",
  "request_id": "tg-11-501",
  "state": "sent",
  "chunks_total": 1,
  "telegram_message_id": 901,
  "delivered_parts": [
    {
      "part": 0,
      "telegram_message_id": 901,
      "format": "rich"
    }
  ],
  "post_send_finalized_at": 1785549222
}
Body-free request retirement:
{
  "request_id": "tg-11-501",
  "status": "completed",
  "receipt_id": "reply-e2e",
  "telegram_message_id": null
}
Private request body retained after reply: no

6. Routine progress stays quiet; selected green-PR update is delivered
sendMessage calls added by raw-progress: 0
sent pr-green-e2e 902
Selected update payload:
{
  "chat_id": 42,
  "parse_mode": "HTML",
  "text": "<b>Pull request ready</b>\n\n• Cross-project notification selected.\n• <a href=\"https://example.test/pr/1442\">Open review</a>.",
  "has_buttons": false
}
Selected update receipt:
{
  "receipt_id": "pr-green-e2e",
  "kind": "notify",
  "event_kind": "pr-green",
  "state": "sent",
  "telegram_message_id": 902
}

Fixture boundary: local fake Bot API only; no live Telegram setup, network delivery, restart, merge, deploy, or self-update.
Evidence: Deterministic rich/plain presentation snapshot
{"schema":"firstmate.telegram-rendered.v1","receipt_id":"reply-e2e","source_sha256":"81d92ce3cce023f223eaf34ff8dd51cb8145aaa554acb112187c69059ad4edca","presentation":{"messages":[{"display_width":65,"plain_text":"Bridge status\n\n• Your private request was accepted once.\n• Review the pull request (https://example.test/pr/1442?a=1&b=2).\n\nCheck         | Result            \n--------------+-------------------\nAuthorization | Exact captain chat\nDelivery      | Durable receipt   ","rich_text":"<b>Bridge status</b>\n\n• Your private request was accepted once.\n• Review <a href=\"https://example.test/pr/1442?a=1&amp;b=2\">the pull request</a>.\n\n<pre>Check         | Result            \n--------------+-------------------\nAuthorization | Exact captain chat\nDelivery      | Durable receipt   </pre>"}],"parse_mode":"HTML","schema":"firstmate.telegram-presentation.v1"}}
Evidence: Threaded Telegram sendMessage payload
{
  "chat_id": 42,
  "text": "<b>Bridge status</b>\n\n• Your private request was accepted once.\n• Review <a href=\"https://example.test/pr/1442?a=1&amp;b=2\">the pull request</a>.\n\n<pre>Check         | Result            \n--------------+-------------------\nAuthorization | Exact captain chat\nDelivery      | Durable receipt   </pre>",
  "parse_mode": "HTML",
  "protect_content": true,
  "link_preview_options": {
    "is_disabled": true
  },
  "reply_parameters": {
    "message_id": 501,
    "allow_sending_without_reply": false
  }
}
Evidence: Selected green-PR notification payload
{
  "chat_id": 42,
  "text": "<b>Pull request ready</b>\n\n• Cross-project notification selected.\n• <a href=\"https://example.test/pr/1442\">Open review</a>.",
  "parse_mode": "HTML",
  "protect_content": true,
  "link_preview_options": {
    "is_disabled": true
  }
}
Evidence: Durable sent reply receipt
{
  "schema": "firstmate.telegram-outbound.v1",
  "receipt_id": "reply-e2e",
  "state": "sent",
  "kind": "reply",
  "event_kind": null,
  "request_id": "tg-11-501",
  "approval_id": null,
  "source_sha256": "81d92ce3cce023f223eaf34ff8dd51cb8145aaa554acb112187c69059ad4edca",
  "chunks_total": 1,
  "next_part": 1,
  "delivered_parts": [
    {
      "part": 0,
      "telegram_message_id": 901,
      "format": "rich"
    }
  ],
  "attempts": 1,
  "created_at": 1785549221,
  "updated_at": 1785549222,
  "telegram_message_id": 901,
  "sent_at": 1785549222,
  "post_send_finalized_at": 1785549222
}

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

⚠️ **Rebase** - 1 warning
  • ⚠️ .agents/skills/afk/SKILL.md - branch carries 29 commit(s) that exist on your local main branch but were never pushed to origin/main; rebasing would bundle this unrelated work (180 file(s)) into the PR:
  • 7562809 no-mistakes(document): Document progress-aware wedge checks
  • d8358b3 no-mistakes(review): Reject invalid crew-state timeout bounds
  • 912bb5a no-mistakes(review): Harden wedge progress proof and renderer parsing
  • ae94e36 no-mistakes(review): harden wedge progress evidence validation
  • b5dc5af fix(watch): escalate a wedge only when the work stopped advancing
  • 66853b2 Merge watcher-arm continuity fix into fork main
  • 2443d4a no-mistakes(document): Document stock macOS Bash compatibility
  • 81cc8f8 no-mistakes(review): Clarify partial scanner portability coverage
  • 2b64994 no-mistakes(review): Document partial scanner coverage boundary
  • 0b36390 no-mistakes(review): Make heredoc scanner quote-aware
  • a5bed82 fix(brief): unnest heredocs from $() so ship briefs work on bash 3.2
  • 649a248 no-mistakes(document): Align watcher alarm documentation
  • 51163e5 Supersede the gate lineage with the narrowed honesty-fix branch
  • 7522fa9 fix: stop an attached arm calling a delivered wake unexplained
  • 9fa91f9 Merge remote-tracking branch 'origin/main' into codex/fm-arm-continuity-gap
  • 3963fa1 no-mistakes(document): Document watcher continuity gap and cycle outcomes
  • 4a5a4d0 no-mistakes(test): Fix watcher continuity gap and duplicate alarms
  • 5815cf7 no-mistakes(review): Separate continuity proof from preservation guard
  • 0c48707 no-mistakes(review): Label unexplained-close test as preservation guard
  • 943f7b3 no-mistakes(review): Keep attached watcher closes loud and honest
  • 7fd118d no-mistakes(review): Harden watcher continuity and cycle evidence
  • b322f1a fix: stop the continuity gate and the watcher arm from fighting each other
  • ca56a14 no-mistakes(document): Document watcher continuity gap and cycle outcomes
  • 1c905a3 no-mistakes(test): Fix watcher continuity gap and duplicate alarms
  • e4a164f no-mistakes(review): Separate continuity proof from preservation guard
  • 921c4c5 no-mistakes(review): Label unexplained-close test as preservation guard
  • 0a8e57c no-mistakes(review): Keep attached watcher closes loud and honest
  • 039df61 no-mistakes(review): Harden watcher continuity and cycle evidence
  • 2e0828e fix: stop the continuity gate and the watcher arm from fighting each other

Push main to origin, or rebase your branch onto origin/main, before gating.

🔧 **Review** - 7 issues found → auto-fixed (6) ✅
  • 🚨 bin/fm-private-lib.sh:147 - After mv replaces an existing receipt, a directory-sync or validation failure deletes the destination. If this occurs while recording a successful Telegram part, both the previous dispatching receipt and new state disappear; retrying the same ID starts from part zero and can duplicate delivered content. Preserve recoverable state at this shared publication boundary instead of removing the post-rename destination.
  • 🚨 bin/fm-telegram-lib.sh:211 - Intent requires "durable exactly-once wake correlation", but wake_queued=true permanently suppresses requeueing. Once fm-wake-drain.sh consumes the row, a session crash before handling leaves the incomplete inbox record marked queued forever. Add a durable offer/ack lifecycle at the shared request/wake boundary so incomplete requests remain recoverable without duplicate concurrent offers.
  • 🚨 bin/fm-telegram-poll.sh:176 - Intent requires "approval identity binding", but the one-time approval claim is published before the authoritative inbox request. A crash or publication failure between this call and line 206 consumes the claim; replaying the same Telegram update sees the existing claim and downgrades the valid approval to ordinary text. Make an existing claim idempotent for the exact same request ID, or commit claim and request together.
  • 🚨 bin/fm-telegram-send.sh:353 - The outbound receipt becomes sent before the approval binding and request retirement are finalized. A crash after line 334 leaves the approval in preparing and the request body retained; retry enters the sent fast path at lines 117-121 and never repairs either state. Intent requires "approval identity binding"; the sent-receipt recovery path should idempotently finish these post-send transitions before returning.
  • 🚨 bin/fm-telegram-lib.sh:252 - Intent requires "bounded ambiguity-safe outbound receipts", but pruning unconditionally deletes uncertain and sent receipts after seven days. Reusing the stable notification receipt afterward is treated as a new send, so content that may already have arrived can be delivered again. Retain a compact immutable receipt/tombstone, especially for uncertain outcomes, while pruning message snapshots and other bulky state.
  • 🚨 bin/fm-telegram-present.pl:131 - Intent requires a bounded safe presentation boundary for every reply, but split_visible never splits or rejects a single extended grapheme whose UTF-16 size exceeds the limit. A base character followed by thousands of combining marks passes the 24,000-unit source limit and produces one oversized Telegram message because no final per-message bound is asserted. Add a deterministic code-point fallback and enforce the final rich/plain unit postcondition.
  • ⚠️ bin/fm-bootstrap.sh:769 - Telegram sets the shared FM_CHECK_INTERVAL to one second, while the watcher uses that value to schedule every *.check.sh. A paired home therefore accelerates existing PR, X, and custom checks from their 300-second schedule to every watcher loop, increasing external API and expensive-check churn. Give Telegram its own deadline instead of changing the shared check sweep.

🔧 Fix: Harden Telegram recovery, receipts, rendering, and cadence
5 issues (4 errors, 1 warning) still open:

  • 🚨 bin/fm-telegram-lib.sh:220 - Intent requires "durable exactly-once wake correlation", but an acknowledged request is re-offered after a fixed 300-second lease without proving that its original handler stopped or renewing the lease. A legitimate long-running request can therefore be offered again while its task is active. Bind the lease to a live handler with renewal or prove abandonment before re-offering.
  • 🚨 bin/fm-telegram-lib.sh:240 - Each expired offer or acknowledgement rewrites the inbox file and refreshes its mtime. Because pruning uses that mtime, an actively polled unresolved request is refreshed every 60 or 300 seconds and its private body never reaches the documented seven-day expiry. Base retention on immutable received_at or a separate immutable expiry.
  • 🚨 bin/fm-telegram-lib.sh:382 - Intent requires "approval identity binding", but a claim whose link succeeds and directory sync fails returns status 2; this branch treats it as an invalid approval without inspecting the preserved exact claim. The poll then persists an unbound request and advances the offset. Distinguish publication failure from invalid authorization so the update remains retryable rather than being downgraded.
  • 🚨 bin/fm-telegram-lib.sh:282 - Intent requires "durable exactly-once wake correlation", but successful retirement unlinks the inbox record without syncing its directory; the queue replacement is likewise unsynced. A host crash after the sent receipt is finalized can resurrect an already-completed request and re-offer it. Publish a durable completion tombstone or sync both retirement boundaries before reporting finalization.
  • ⚠️ bin/fm-watch.sh:101 - The dedicated one-second Telegram interval does not affect the watcher's terminal wait, which still blocks for the default FM_POLL=15. After an empty long poll, the bridge can therefore sit idle for another 15 seconds despite documentation promising the next poll promptly. Include the Telegram deadline when calculating the terminal wait.

🔧 Fix: Harden Telegram request recovery and durable retirement
2 errors still open:

  • 🚨 bin/fm-telegram-lib.sh:332 - Intent requires "durable exactly-once wake correlation", but acknowledgement suppression proves only that the long-lived harness session remains alive. If the handling turn aborts before replying while that session stays open, every poll suppresses the incomplete request indefinitely. Bind acknowledgement to a renewable turn/task claim or another boundary that proves the actual handler remains active.
  • 🚨 bin/fm-telegram-lib.sh:488 - Expiry can race a reply already in flight: the sender may read the request, pruning then publishes an immutable expired tombstone here, Telegram accepts the reply, and completion cannot replace that tombstone with the sent receipt. The receipt remains permanently unfinalized. Linearize expiry against outbound dispatch or define recoverable precedence for a matching sent completion.

🔧 Fix: Harden Telegram turn recovery and receipt precedence
2 issues (1 error, 1 warning) still open:

  • 🚨 bin/fm-telegram-lib.sh:392 - Intent requires "durable exactly-once wake correlation" and "full five-harness/runtime integration", but the cycle comparison still strands interrupted OpenCode and Pi turns. Those adapters start the successor watcher before delivering the wake and retain it across session idle, so ending or aborting the handling turn does not advance wake-cycle; this comparison continues suppressing the incomplete request indefinitely. Bind acknowledgement to an actual harness turn epoch or explicit handler release instead of watcher-process lifetime.
  • ⚠️ bin/fm-telegram-lib.sh:541 - When expiry races an in-flight reply, pruning queues telegram-error request-retention-expired; the new sent-receipt precedence can subsequently change the tombstone to completed, but it neither removes that distinct error wake nor prevents pruning from appending it after completion wins. The captain therefore receives a false expiry alarm for a delivered request. Publish the alert within the same terminal arbitration or revalidate the winning state before enqueueing it.

🔧 Fix: Harden Telegram turn recovery and expiry arbitration
1 error still open:

  • 🚨 bin/fm-turnend-guard.sh:114 - Intent requires "durable exactly-once wake correlation" and "full five-harness/runtime integration", but this new turn marker advances the shared epoch after checking only the checkout scope. A second Codex, Claude, OpenCode, Pi, or Grok session that failed to acquire state/.lock still runs the tracked turn-end hook and can rotate the real owner's epoch, causing an acknowledged request to be re-offered while its handler remains active. Require exact session-lock ownership at this shared mutation boundary before advancing the epoch.

🔧 Fix: Bind Telegram turn epochs to session-lock ownership
1 error still open:

  • 🚨 bin/fm-telegram-lib.sh:248 - Intent requires "durable exactly-once wake correlation" and "full five-harness/runtime integration", but this exact ownership check rejects Grok's intentionally nested same-session guard turn. fm-turnend-guard-grok.sh runs grok --resume beneath the outer Grok process that owns state/.lock; fm_harness_ancestry_pid returns the nearer nested Grok PID, so this call fails. If that forced recovery turn acknowledges a request, it records the still-live outer owner and current epoch, but its turn end cannot advance the epoch, leaving the incomplete request suppressed. Accept the exact lock PID anywhere in verified harness ancestry, or provide equivalent Grok-aware ownership proof, while continuing to reject independent sessions.

🔧 Fix: Accept nested Grok lock-owner ancestry
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • git rev-parse HEAD; git merge-base HEAD 3f71cddd764a49ab71bcd53a46b84e5e7336557a; base-to-target diff inspection
  • tests/fm-telegram-bridge.test.sh
  • bin/fm-test-run.sh tests/fm-supervision-instructions.test.sh tests/fm-session-start.test.sh tests/fm-turnend-guard.test.sh tests/fm-claude-stop-autoarm.test.sh tests/fm-pi-watch-extension.test.sh tests/fm-arm-pretool-check.test.sh
  • bin/fm-test-run.sh tests/fm-session-start.test.sh tests/fm-pi-watch-extension.test.sh after fixture repairs
  • tests/fm-session-start.test.sh after replacing the Bash 4-only fixture PID
  • Fake Bot API journey using fm-telegram-setup.sh status|pair, unauthorized and authorized fm-telegram-poll.sh calls, fm-telegram-render.sh, and fm-telegram-send.sh reply|notify in the isolated evidence home
🔧 **Document** - 3 issues found → auto-fixed ✅
  • 🚨 bin/fm-telegram-send.sh:87 - Target-wide lint fails with SC2329 because the trap-invoked cleanup function lacks an indirect-use annotation.
  • 🚨 tests/fm-telegram-bridge.test.sh:303 - Target-wide lint fails with seventeen SC2031 findings after the turn-epoch subshell assignments.
  • 🚨 tests/fm-turnend-guard.test.sh:224 - Target-wide lint fails with three SC2016 findings in intentional single-quoted harness fixtures.

🔧 Fix: Align Telegram docs and lint rationale
✅ Re-checked - no issues remain.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Search Agent added 16 commits July 31, 2026 21:50
Only the arm that owns a watcher child reads that cycle's wake output. An arm
merely attached to the same watcher - the ordinary shape whenever a manual
recovery arm and the Stop-owned auto-arm overlap - therefore could not tell a
delivered wake from a watcher that vanished, and reported the very cycle that
woke firstmate as "cycle ended without an actionable reason". The owner's own
ledger row already recorded the truth: reason=actionable-signal sat beside the
observer's attached-cycle-ended row for the same watcher_pid.

An attached arm now reads that owner record for the exact watcher PID and lock
identity, anchored at the moment it began following, before it classifies the
close. Proof of a delivered wake changes only the claim.

Loudness is deliberately unchanged. The close still exits nonzero either way,
because no live cycle remains and a silent return would let a harness treat an
unwatched home as healthy. That keeps every adapter recovering exactly as it
does today and makes a misclassification able to misword an alarm but never
suppress one. Absent, malformed, truncated, or ambiguous evidence keeps the
unexplained wording.

Scope: this began as a two-part change that also relaxed the Claude continuity
PreToolUse gate. That half is dropped - kunchenguid#1001 removed the watcher-status gate
entirely, so it is moot rather than merely declined, and this branch is rebased
onto that work and leaves it untouched.
`wedge_timer_check` escalated a provably-working stale pane on elapsed idle
time alone, deliberately never re-reading the crew state after classification.
A long validation step leaves the pane visually static for its whole duration,
so a healthy worker escalated as a "possible wedge" every
FM_STALE_ESCALATE_SECS, on exactly the same schedule a frozen one would.
Observed live on 2026-07-24: one endpoint escalated six times inside a single
40-minute review pass, each costing a supervision turn to confirm "yes, still
working". An alarm with a known benign cause trains its reader to ignore the
one that matters.

The authoritative signal already existed and was never consulted. The active
step's `last_activity` stamp records the age of the most recent thing the
pipeline actually did. At the one poll where the timer would otherwise fire,
and only there, the watcher now asks whether that activity falls inside the
window that just elapsed. Positive proof of advancement resets the timer and
absorbs; everything else escalates unchanged.

This makes the alarm more correct in both directions: a genuinely wedged
worker is now caught on the criterion that actually matters rather than on a
clock that fires identically for healthy and wedged panes.

The failure mode of the fix is worse than the bug, so it governs the design.

- Inability to determine progress escalates exactly as before. No attributed
  run, a coarse-only match, an unreadable or unparseable stamp, a bounded call
  that timed out, or a helper too old to understand `--progress` all read as
  could-not-determine. The evidence may only ever suppress an alarm on positive
  proof; absence of evidence is never proof.
- The demand-deep-inspection path stays reachable: an advancement absorb
  deliberately leaves the consecutive-escalation count untouched.
- Scope is the provably-working wedge timer only. `blocked:`, dead endpoints,
  declared pauses, and the away-mode daemon's own cadences are untouched.
- Cost is bounded and does not run per poll. The read happens only at a
  would-be escalation, reuses fm-crew-state's existing bounded-call budget, and
  skips the coarse runs-list fallback that could only answer unknown anyway.

The progress read reuses fm-crew-state's run attribution unchanged, so a
neighbouring branch's healthy run can never suppress this crew's alarm.

Tests: a behavior test proving the false escalation (fails without the change,
reporting "idle 500s, possible wedge, escalation 1"), plus a preservation guard
that a non-advancing pipeline still escalates and still reaches
demand-deep-inspection. The guard passes with the change reverted, by design.
Parser and attribution cases cover the stamp shapes read live from the
installed CLI and every unprovable answer.
@palinopr
palinopr force-pushed the feat/telegram-bridge-v1 branch from 27c1b52 to 4309ac6 Compare August 1, 2026 02:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant