instar 0.28.77 → 0.28.78

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 (46) hide show
  1. package/dashboard/index.html +184 -4
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +46 -2
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/monitoring/TokenLedger.d.ts +39 -0
  6. package/dist/monitoring/TokenLedger.d.ts.map +1 -1
  7. package/dist/monitoring/TokenLedger.js +110 -13
  8. package/dist/monitoring/TokenLedger.js.map +1 -1
  9. package/dist/monitoring/TokenLedgerPoller.d.ts.map +1 -1
  10. package/dist/monitoring/TokenLedgerPoller.js +8 -8
  11. package/dist/monitoring/TokenLedgerPoller.js.map +1 -1
  12. package/dist/server/AgentServer.d.ts +4 -0
  13. package/dist/server/AgentServer.d.ts.map +1 -1
  14. package/dist/server/AgentServer.js +14 -1
  15. package/dist/server/AgentServer.js.map +1 -1
  16. package/dist/server/routes.d.ts +8 -1
  17. package/dist/server/routes.d.ts.map +1 -1
  18. package/dist/server/routes.js +98 -0
  19. package/dist/server/routes.js.map +1 -1
  20. package/dist/threadline/BackfillCore.d.ts +70 -0
  21. package/dist/threadline/BackfillCore.d.ts.map +1 -0
  22. package/dist/threadline/BackfillCore.js +117 -0
  23. package/dist/threadline/BackfillCore.js.map +1 -0
  24. package/dist/threadline/ListenerSessionManager.d.ts +35 -0
  25. package/dist/threadline/ListenerSessionManager.d.ts.map +1 -1
  26. package/dist/threadline/ListenerSessionManager.js +41 -0
  27. package/dist/threadline/ListenerSessionManager.js.map +1 -1
  28. package/dist/threadline/TelegramBridge.d.ts +140 -0
  29. package/dist/threadline/TelegramBridge.d.ts.map +1 -0
  30. package/dist/threadline/TelegramBridge.js +224 -0
  31. package/dist/threadline/TelegramBridge.js.map +1 -0
  32. package/dist/threadline/ThreadlineMCPServer.d.ts.map +1 -1
  33. package/dist/threadline/ThreadlineMCPServer.js +5 -0
  34. package/dist/threadline/ThreadlineMCPServer.js.map +1 -1
  35. package/dist/threadline/ThreadlineObservability.d.ts +95 -0
  36. package/dist/threadline/ThreadlineObservability.d.ts.map +1 -0
  37. package/dist/threadline/ThreadlineObservability.js +310 -0
  38. package/dist/threadline/ThreadlineObservability.js.map +1 -0
  39. package/package.json +1 -1
  40. package/scripts/threadline-bridge-backfill.mjs +379 -0
  41. package/src/data/builtin-manifest.json +47 -47
  42. package/upgrades/0.28.78.md +90 -0
  43. package/upgrades/side-effects/threadline-bridge-backfill.md +203 -0
  44. package/upgrades/side-effects/threadline-observability-tab.md +206 -0
  45. package/upgrades/side-effects/threadline-tg-bridge-module.md +196 -0
  46. package/upgrades/side-effects/token-ledger-bounded-scan.md +230 -0
@@ -0,0 +1,203 @@
1
+ # Side-Effects Review — Threadline Bridge Backfill Script
2
+
3
+ **Version / slug:** `threadline-bridge-backfill`
4
+ **Date:** `2026-05-02`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `self (incident-grounded reasoning)`
7
+
8
+ ## Summary of the change
9
+
10
+ Final deliverable in topic-8686. Ships a one-shot CLI script that
11
+ backfills threadline-message history into Telegram topics using the
12
+ bridge primitives from PR #117 — so the user has a complete picture of
13
+ agent-to-agent traffic, including conversations that landed BEFORE the
14
+ bridge shipped.
15
+
16
+ Files added:
17
+
18
+ - `scripts/threadline-bridge-backfill.mjs` — the CLI script. Reads the
19
+ agent's canonical inbox/outbox files (PR #113, PR #118), an optional
20
+ seed file of historically-reconstructed messages, and the existing
21
+ bridge bindings. Creates Telegram topics with the bridge naming
22
+ pattern, posts a backfill banner explaining what the user is seeing,
23
+ then posts each message chronologically with chunking under 4000 chars.
24
+ Idempotent via a per-thread ledger at
25
+ `.instar/threadline/bridge-backfill-ledger.json`.
26
+ - `src/threadline/BackfillCore.ts` — pure helpers (`buildTopicName`,
27
+ `chunkBody`, `groupByThread`, `pickCounterparty`, `ledgerKey`,
28
+ `formatBackfillMessage`). Source of truth for the contract; the
29
+ script's inline copies must stay in sync.
30
+ - `tests/unit/BackfillCore.test.ts` — 16 unit cases pinning the contract.
31
+
32
+ Files modified: none (this PR is pure additions on top of the four
33
+ prior PRs in the topic-8686 set).
34
+
35
+ ## Decision-point inventory
36
+
37
+ - `scripts/threadline-bridge-backfill.mjs` — **add** — CLI with
38
+ `--state-dir`, `--port`, `--threads`, `--seed`, `--dry-run`,
39
+ `--no-create` flags. Calls `POST /telegram/topics` to create and
40
+ `POST /telegram/post-update` to post.
41
+ - `BackfillCore.buildTopicName` — **add** — same shape and limits as
42
+ `TelegramBridge.buildTopicName` from PR #117.
43
+ - `BackfillCore.chunkBody` — **add** — fixed 3800-char chunks with no
44
+ word-boundary alignment (Telegram preserves whitespace; visual
45
+ continuity is acceptable).
46
+ - `BackfillCore.groupByThread` — **add** — combines inbox + outbox +
47
+ seed; sorts each thread chronologically.
48
+ - `BackfillCore.formatBackfillMessage` — **add** — emoji prefix
49
+ (📥 / 📤), counterparty name, ISO timestamp, body.
50
+ - Backfill ledger format — **add** — `{ version: 1, threads: { [threadId]: { topicId, topicName, posted: [...], lastBackfillAt } } }`.
51
+
52
+ ---
53
+
54
+ ## 1. Over-block
55
+
56
+ **What legitimate inputs does this change reject that it shouldn't?**
57
+
58
+ The script is purely additive and doesn't gate any existing flows.
59
+ Validation:
60
+
61
+ - Empty / unparseable seed file → warn + treat as `[]`.
62
+ - Thread with no on-disk or seed messages → warn + skip.
63
+ - `--no-create` + no existing binding → warn + skip (the user explicitly
64
+ asked NOT to create topics).
65
+ - `--dry-run` → prints the plan without making any HTTP calls.
66
+
67
+ No false-positive rejects. The `--threads` filter is exact-match on
68
+ threadId; that's intentional — partial matching here would be a
69
+ foot-gun (accidentally backfilling more threads than intended).
70
+
71
+ ## 2. Under-block
72
+
73
+ **What failure modes does this still miss?**
74
+
75
+ - **Orphan topics on partial-success.** If topic creation succeeds but
76
+ the first message post fails, we have a Telegram topic with only the
77
+ banner. Acceptable — the ledger records the topic id; a re-run
78
+ picks up where it left off and posts the remaining messages.
79
+ - **Order-of-arrival sensitivity for the first run.** If two backfill
80
+ invocations race, both could create a topic for the same thread.
81
+ Mitigation: the ledger write is the second step (after creation); a
82
+ duplicate would be detectable and could be removed manually. This
83
+ script is one-shot, intended to be run by Justin on demand, not
84
+ scheduled — the race window is hypothetical.
85
+ - **No HMAC verification of inbox/outbox lines.** The script trusts
86
+ the JSONL files. Same posture as the observability layer in PR #118:
87
+ the HMAC is for tamper-evidence at write time; reading for
88
+ user-visible mirroring doesn't need to re-verify (no decision
89
+ surface). If a tampered line surfaces in Telegram, the user sees
90
+ it in the conversation view and can investigate.
91
+ - **No quota-aware throttling beyond a 250ms gap.** Telegram's
92
+ default rate limit is ~30 messages/second to the same chat;
93
+ 4 messages/second is well below. If a thread has 1000+ messages
94
+ this still completes in ~4 minutes, which is acceptable for a
95
+ one-shot.
96
+
97
+ ## 3. Level-of-abstraction fit
98
+
99
+ The script is a CLI wrapper over the agent's existing
100
+ `/telegram/topics` and `/telegram/post-update` HTTP routes. It
101
+ deliberately does NOT:
102
+
103
+ - Instantiate `TelegramAdapter` directly — would duplicate the agent
104
+ server's connection state.
105
+ - Call `TelegramBridge.mirrorInbound` — the bridge writes its own
106
+ bindings file; the script reuses that file. We don't go through the
107
+ bridge because the bridge would consult `TelegramBridgeConfig`
108
+ (default-OFF) and refuse to post; the script's purpose is to
109
+ backfill regardless of the live policy.
110
+ - Touch the canonical inbox/outbox files — those are append-only
111
+ signals from the live agent; the script reads them but never
112
+ rewrites them.
113
+
114
+ Putting the pure helpers in `src/threadline/BackfillCore.ts` keeps
115
+ them tsc-checked and unit-tested while letting the script stay a
116
+ plain `.mjs` (no build step required to run).
117
+
118
+ ## 4. Signal-vs-authority compliance
119
+
120
+ - **Signal:** the on-disk inbox/outbox files; the optional seed file.
121
+ - **Authority:** none. The script has no decision surface on the
122
+ routing path — it doesn't gate or alter any threadline flow. The
123
+ Telegram topic creation goes through the existing `/telegram/topics`
124
+ route, which is the authority for forum-topic creation.
125
+
126
+ The script intentionally **bypasses** `TelegramBridgeConfig` because
127
+ backfill is an explicit user action. The user already opted in by
128
+ running the script with the relevant `--threads` argument; consulting
129
+ the dashboard toggle would re-derive that consent. The script's
130
+ output is fully auditable in the ledger.
131
+
132
+ ## 5. Interactions
133
+
134
+ - **PR #113 (canonical inbox).** Reads `inbox.jsonl.active`. No write.
135
+ - **PR #117 (bridge module).** Reads `telegram-bridge-bindings.json`.
136
+ Reuses existing bindings (no new bridge-side artifact unless the
137
+ script creates a topic, in which case the next live `mirrorInbound`
138
+ / `mirrorOutbound` call will see the binding via the bindings file
139
+ the script writes — wait, NO: this script does NOT write to
140
+ `telegram-bridge-bindings.json`. It writes only to its own ledger.
141
+ This means the live bridge has no awareness of script-created
142
+ topics until a new bridge-driven message comes through, at which
143
+ point `findOrCreateForumTopic` returns the existing topic id and
144
+ the bridge writes its own binding. This is desirable: the bridge
145
+ remains the only writer of `telegram-bridge-bindings.json`,
146
+ preserving single-writer simplicity.
147
+ - **PR #118 (observability tab).** Reads `outbox.jsonl.active` (which
148
+ PR #118 now writes). No write.
149
+ - **`/telegram/topics` route.** Existing route, unchanged.
150
+ - **`/telegram/post-update` route.** Existing route, unchanged.
151
+ - **Backfill ledger.** New file at
152
+ `.instar/threadline/bridge-backfill-ledger.json` — the script is the
153
+ only writer. Format is versioned (`version: 1`) so future format
154
+ changes can land without breaking older ledgers.
155
+
156
+ ## 6. Rollback cost
157
+
158
+ - The script is a CLI; not running it is the rollback. No recurring
159
+ process, no background job, no config flag.
160
+ - If a bad backfill posted unwanted content to Telegram, the user
161
+ deletes the topic via the Telegram UI. The script's ledger entry
162
+ for that thread can be removed manually (`rm` the
163
+ `bridge-backfill-ledger.json` entry), and a re-run with
164
+ `--no-create` will skip the deleted thread.
165
+ - `BackfillCore.ts` is unused outside the test file (the script
166
+ duplicates the logic inline) — drop the file with no import
167
+ consequences.
168
+
169
+ ## Plan if a regression appears
170
+
171
+ - **Symptom: script creates duplicate topics.** Check the ledger;
172
+ ensure the thread's `topicId` field is populated. If null, the
173
+ topic-creation HTTP call must have failed mid-flight; re-running
174
+ is safe (the `bindings` lookup will find the orphan binding from
175
+ the previous run via the agent's bridge if it ran since). If
176
+ duplicates persist, delete one in Telegram and remove its ledger
177
+ entry.
178
+ - **Symptom: messages posted in wrong order.** The grouping step
179
+ sorts by ISO timestamp. If timestamps are missing or non-ISO,
180
+ inbound/outbound order can drift. Check the seed file for
181
+ timestamp consistency.
182
+ - **Symptom: rate-limited by Telegram.** Increase the `SEND_GAP_MS`
183
+ constant or add `--gap <ms>` flag. Documented in the script header.
184
+
185
+ ## Phase / scope
186
+
187
+ Final of five deliverables in topic-8686. Closes the build:
188
+
189
+ 1. (a) Canonical inbox write-path — **MERGED** (#113).
190
+ 2. (2) Settings surface — **MERGED** (#114).
191
+ 3. (b) Bridge module — **MERGED** (#117).
192
+ 4. (4) Observability tab — **PR open** (#118).
193
+ 5. **(c) Backfill script — THIS PR.**
194
+
195
+ After this PR ships, Justin can use the dashboard's Threadline tab to
196
+ see live agent-to-agent traffic in real time (post-bridge-enable),
197
+ and run the backfill script to populate Telegram with any historical
198
+ threads he wants visible. The four specific threads named in the
199
+ topic-8686 brief (worktree-audit handoff, Dawn first handoff, Dawn
200
+ four-spawn thread, GROUND-TRUTH round-trip) can be backfilled by
201
+ running the script with a `--seed` file containing the reconstructed
202
+ historical messages — the script handles topic creation, banner,
203
+ chunking, and idempotency.
@@ -0,0 +1,206 @@
1
+ # Side-Effects Review — Threadline Observability Tab
2
+
3
+ **Version / slug:** `threadline-observability-tab`
4
+ **Date:** `2026-05-02`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `self (incident-grounded reasoning)`
7
+
8
+ ## Summary of the change
9
+
10
+ Fourth of five deliverables in topic-8686. Lights up the dashboard
11
+ "Threadline" tab (added in PR #114) with a real conversation-observability
12
+ view: thread list, color-coded message stream, per-thread metrics,
13
+ filters, and search across all threadline message bodies.
14
+
15
+ Reads three already-existing sources of truth:
16
+
17
+ - `.instar/threadline/inbox.jsonl.active` — every inbound threadline
18
+ message (single source post-PR #113).
19
+ - `.instar/threadline/telegram-bridge-bindings.json` — thread → Telegram
20
+ topic links (single source post-PR #117).
21
+ - `.instar/threadline/thread-resume-map.json` — spawn-session
22
+ bookkeeping (existing).
23
+
24
+ Adds one new source of truth:
25
+
26
+ - `.instar/threadline/outbox.jsonl.active` — every outbound threadline
27
+ message sent via `/threadline/relay-send`. Mirror of the inbox-write
28
+ pattern from PR #113. Gives the conversation view BOTH sides of an
29
+ agent-to-agent thread.
30
+
31
+ Files added:
32
+
33
+ - `src/threadline/ThreadlineObservability.ts` — read-only view layer
34
+ with `listThreads(filters)`, `getThread(threadId)`, `searchMessages(q, limit)`.
35
+ - `tests/unit/ThreadlineObservability.test.ts` — 15 unit cases.
36
+
37
+ Files modified:
38
+
39
+ - `src/threadline/ListenerSessionManager.ts` — adds
40
+ `canonicalOutboxPath` getter and `appendCanonicalOutboxEntry(opts)`
41
+ helper.
42
+ - `src/server/routes.ts`:
43
+ - `RouteContext.threadlineObservability: ThreadlineObservability | null`.
44
+ - Three new endpoints: `GET /threadline/observability/threads`,
45
+ `GET /threadline/observability/threads/:threadId`,
46
+ `GET /threadline/observability/search`.
47
+ - `/threadline/relay-send`: appends a canonical-outbox entry on BOTH
48
+ success paths (local-delivery + relay-delivery) before returning.
49
+ - `src/server/AgentServer.ts` / `src/commands/server.ts` — instantiate
50
+ and pass through `threadlineObservability`.
51
+ - `dashboard/index.html` — replaces the placeholder card on the
52
+ Threadline tab with the conversation view: 280px threads list,
53
+ conversation pane with header metrics + per-message bubbles,
54
+ toolbar with filters + debounced search.
55
+
56
+ ## Decision-point inventory
57
+
58
+ - `appendCanonicalOutboxEntry` — **add** — mirror of the inbound
59
+ helper from PR #113. HMAC-signed, JSONL-append, 0o600 perms,
60
+ failure-open.
61
+ - `ThreadlineObservability.listThreads(filters)` — **add** — combines
62
+ inbox + outbox + bindings + thread-resume-map into per-thread
63
+ summaries; sorts most-recent first; supports remoteAgent / since /
64
+ until / hasTopic filters.
65
+ - `ThreadlineObservability.getThread(threadId)` — **add** — returns
66
+ summary + chronological message stream.
67
+ - `ThreadlineObservability.searchMessages(q, limit)` — **add** —
68
+ case-insensitive substring search over inbox+outbox bodies; returns
69
+ hits with snippets bracketed by «...».
70
+ - Three GET endpoints (bearer-auth via global authMiddleware) — **add**.
71
+ - `/threadline/relay-send` outbox writes — **modify** — add one
72
+ helper call on each of two success branches; failure-open.
73
+ - Dashboard JS handlers (`tlObsLoadThreads`, `tlObsLoadThread`,
74
+ `tlObsRunSearch`) — **add**.
75
+
76
+ ---
77
+
78
+ ## 1. Over-block
79
+
80
+ **What legitimate inputs does this change reject that it shouldn't?**
81
+
82
+ The observability layer is read-only and never blocks. The new outbox
83
+ write is failure-open and never throws back to `/threadline/relay-send`
84
+ (the route returns its existing success response either way). No
85
+ over-blocks possible.
86
+
87
+ The endpoints validate query string fields (`hasTopic` only accepts
88
+ `yes` / `no`; everything else is treated as "no filter"). A typo in
89
+ the dashboard's filter UI returns the unfiltered list — which is the
90
+ desirable behavior; the dashboard's controls produce only the valid
91
+ values, and a curl-from-the-CLI user gets an unambiguous result rather
92
+ than a 400.
93
+
94
+ ## 2. Under-block
95
+
96
+ **What failure modes does this still miss?**
97
+
98
+ - **No persistence boundary on outbox.** `outbox.jsonl.active` grows
99
+ forever. At the current ~10K msgs/agent envelope this isn't a
100
+ problem, but a future PR should add rotation parallel to the
101
+ warm-listener queue's rotation. Out of scope here.
102
+ - **No streaming SSE for the conversation view.** Threads list and
103
+ conversation are pull-on-activate + manual refresh; new messages
104
+ don't appear without a refresh. Acceptable for v1; a follow-up can
105
+ add the existing `/events` SSE channel for live updates.
106
+ - **No FTS5 index.** `searchMessages` does a full-file scan.
107
+ Sub-100ms at the current envelope; rebuild as FTS5 if it ever
108
+ becomes a complaint. Documented in the class header.
109
+ - **HMAC verification is NOT performed during read.** The
110
+ observability layer reads JSONL lines and parses them as data; it
111
+ doesn't call `verifyEntry` on each row. This is intentional: the
112
+ inbox write uses HMAC for tamper-evidence at write time; reading
113
+ for display doesn't need to re-verify. If an attacker tampers with
114
+ the file at rest, the dashboard would render corrupted bodies, but
115
+ no decision is made on that data — there's no authority surface
116
+ here to subvert.
117
+
118
+ ## 3. Level-of-abstraction fit
119
+
120
+ The class is intentionally thin: it composes the existing files into
121
+ view models. Three sources of truth (inbox, outbox, bindings) compose
122
+ into one summary; the `ListenerSessionManager` already owns the writers.
123
+ This matches the pattern set in PR #113 and #117: each class owns a
124
+ single concern, the observability layer is just a join.
125
+
126
+ The dashboard handlers debounce input and bind to existing endpoints
127
+ through the existing `apiFetch` helper. No new client-side state
128
+ machine, no caching beyond what the browser does naturally.
129
+
130
+ ## 4. Signal-vs-authority compliance
131
+
132
+ - **Signal:** dashboard query string parameters; text typed into the
133
+ search box.
134
+ - **Authority:** none — this layer makes no decisions, gates nothing,
135
+ blocks nothing. Read-only.
136
+
137
+ The new outbox write follows the same signal-vs-authority shape as
138
+ the inbound write from PR #113: relay-only, failure-open, no decision
139
+ surface. The route's authority (whether to deliver) was already taken
140
+ upstream.
141
+
142
+ ## 5. Interactions
143
+
144
+ - **PR #113 (canonical inbox).** Reads the inbox file written by that
145
+ PR. No coupling beyond file format (well-documented JSONL with
146
+ `id, timestamp, from, senderName, trustLevel, threadId, text, hmac`).
147
+ - **PR #114 (settings).** Shares the Threadline dashboard tab — the
148
+ bridge settings card stays at the top, the conversation view sits
149
+ below.
150
+ - **PR #117 (bridge module).** Reads
151
+ `telegram-bridge-bindings.json` to populate the per-thread bridge
152
+ link. The bridge is unaware of the observability layer; the
153
+ observability layer is unaware of the bridge's runtime — they
154
+ communicate exclusively through the on-disk file.
155
+ - **`thread-resume-map.json`.** The class accepts both the legacy
156
+ flat shape (`{threadId: ...}`) and the newer `{threads: {...}}`
157
+ shape, so it works against either.
158
+ - **`/threadline/relay-send`.** Two new helper calls on the success
159
+ paths. Same failure-open pattern as PR #113's inbox hoist.
160
+
161
+ ## 6. Rollback cost
162
+
163
+ - Drop the three observability endpoints + the dashboard handlers →
164
+ the Threadline tab loses the conversation view but the bridge
165
+ settings card from PR #114 keeps working.
166
+ - Drop the outbox helper + the two route hooks → outbound messages
167
+ no longer accrue in `outbox.jsonl.active`, but the relay-send
168
+ route still functions. The conversation view degrades to inbound-only.
169
+ - The on-disk `outbox.jsonl.active` file is JSONL-append-only with no
170
+ cross-references; it can be `rm`'d safely if rolled back.
171
+
172
+ No schema migrations, no shared-state changes, no new processes.
173
+
174
+ ## Plan if a regression appears
175
+
176
+ - **Symptom: dashboard tab errors loading threads.** Check
177
+ `apiFetch('/threadline/observability/threads')` — 503 means
178
+ `threadlineObservability` is null in the route context (server-side
179
+ bootstrap regression). 200 with empty threads is the correct
180
+ response for a fresh agent.
181
+ - **Symptom: search slow.** The full-file scan is bounded by
182
+ `inbox.jsonl.active` + `outbox.jsonl.active` line counts. Profile;
183
+ if pathological, add an FTS5 index keyed on `(threadId, timestamp)`.
184
+ - **Symptom: outbound messages missing from conversation view.**
185
+ Either (a) the relay-send route's outbox-append helper threw and
186
+ was caught, or (b) the threadline message went out via a different
187
+ path (e.g. legacy direct relay client). Check the warn lines in
188
+ the agent log. Worst case: roll back the outbox helper additions
189
+ and rely on the bridge bindings file alone (which still gives
190
+ thread-level visibility).
191
+
192
+ ## Phase / scope
193
+
194
+ Fourth of five deliverables in topic-8686:
195
+
196
+ 1. (a) Canonical inbox write-path — **MERGED** (#113).
197
+ 2. (2) Settings surface — **MERGED** (#114).
198
+ 3. (b) Bridge module — **MERGED** (#117).
199
+ 4. **(4) Observability tab — THIS PR.**
200
+ 5. (c) Backfill four open threads — final, one-shot script.
201
+
202
+ After (4) merges, the Threadline tab is the user's single pane of
203
+ glass for agent-to-agent traffic: every thread, every message,
204
+ filters by remote agent / date / has-topic, search, and a clear
205
+ visual signal of which threads have a Telegram topic and which have
206
+ been spawned into a Claude Code session.
@@ -0,0 +1,196 @@
1
+ # Side-Effects Review — Threadline → Telegram Bridge Module
2
+
3
+ **Version / slug:** `threadline-tg-bridge-module`
4
+ **Date:** `2026-05-02`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `self (incident-grounded reasoning)`
7
+
8
+ ## Summary of the change
9
+
10
+ Ships the actual **bridge module** that mirrors threadline messages into
11
+ per-thread Telegram topics — third of five deliverables in topic-8686.
12
+
13
+ Builds on:
14
+ - (a) Canonical inbox write-path fix — PR #113, commit `9cc3e9af` — gives
15
+ the bridge a single source of truth for inbound traffic.
16
+ - (2) Settings surface — PR #114 — provides the toggles + allow/deny
17
+ list policy the bridge consults on every message.
18
+
19
+ Files added:
20
+
21
+ - `src/threadline/TelegramBridge.ts` — bridge class. Methods:
22
+ - `mirrorInbound(evt)` — relay handler calls this after the canonical
23
+ inbox write; auto-creates a topic if config policy allows, otherwise
24
+ no-op or mirrors into existing.
25
+ - `mirrorOutbound(evt)` — `/threadline/relay-send` calls this after
26
+ success; mirrors into existing topic only (outbound never auto-creates).
27
+ - Persistence in `.instar/threadline/telegram-bridge-bindings.json`
28
+ (mode `0o600`).
29
+
30
+ Files modified:
31
+
32
+ - `src/commands/server.ts` — instantiates `TelegramBridge` after the
33
+ Telegram adapter is constructed; passes through to AgentServer; the
34
+ relay handler's `gate-passed` listener fires `mirrorInbound` async.
35
+ - `src/server/routes.ts` — `RouteContext.telegramBridge` typed; the
36
+ `/threadline/relay-send` route fires `mirrorOutbound` on both the
37
+ local-delivery and relay-delivery success paths.
38
+ - `src/server/AgentServer.ts` — accepts `options.telegramBridge`,
39
+ passes through `routeCtx`.
40
+
41
+ Tests added: 18 unit cases in `tests/unit/TelegramBridge.test.ts`.
42
+
43
+ ## Decision-point inventory
44
+
45
+ - `TelegramBridge.mirrorInbound` — **add** — relay-only mirror with
46
+ auto-create gate via `TelegramBridgeConfig`.
47
+ - `TelegramBridge.mirrorOutbound` — **add** — relay-only mirror into
48
+ existing topic; never auto-creates.
49
+ - `TelegramBridge.buildTopicName` — **add** — `{local}↔{remote} — {subject}`
50
+ with truncation to 96 chars (Telegram 128 cap with headroom).
51
+ - `TelegramBridgeBindingsFile` — **add** — version=1 JSON file persisted
52
+ at `.instar/threadline/telegram-bridge-bindings.json`.
53
+ - Relay handler in `server.ts` — **modify** — new `mirrorInbound` call
54
+ AFTER the canonical inbox write, fire-and-forget with `.catch()`.
55
+ - `/threadline/relay-send` route — **modify** — new `mirrorOutbound`
56
+ calls in BOTH success paths (local + relay); fire-and-forget.
57
+
58
+ ---
59
+
60
+ ## 1. Over-block
61
+
62
+ **What legitimate inputs does this change reject that it shouldn't?**
63
+
64
+ The bridge is **relay-only**: it has zero blocking authority. It cannot
65
+ reject any input, route, or send. The only gate is "should I post this
66
+ into Telegram?" — and the answer is decided by `TelegramBridgeConfig`,
67
+ which was reviewed and pinned in PR #114.
68
+
69
+ False over-blocks are not possible at this layer. If the user reports
70
+ "a message I expected to see in Telegram didn't show up", the cause is
71
+ either (a) bridge disabled (master switch off), (b) auto-create denied
72
+ because the remote agent isn't allow-listed and `autoCreateTopics=false`,
73
+ or (c) the underlying Telegram API call failed (logged via the bridge's
74
+ warn channel, not surfaced as an error to the routing path).
75
+
76
+ ## 2. Under-block
77
+
78
+ **What failure modes does this still miss?**
79
+
80
+ - **No retry on transient Telegram failures.** A 429 / 5xx from Telegram
81
+ during `findOrCreateForumTopic` or `sendToTopic` is logged and skipped.
82
+ No queue, no exponential backoff. Acceptable: the bridge is observability,
83
+ not delivery — message liveness matters for the threadline relay, not
84
+ for the Telegram mirror. A future PR can add a delivery queue if losing
85
+ the occasional mirror becomes a real complaint.
86
+ - **Inbound from a relay that uses a different `threadId` than the
87
+ outbound send.** The bridge keys bindings by `threadId`, which is the
88
+ threadline-side id. As long as both sides use a consistent thread id
89
+ (which they do post-(a)), the binding is stable. If a thread-id
90
+ divergence sneaks in (e.g. the outbound side mints a fresh id), the
91
+ outbound mirror returns `no-binding` and silently no-ops. This is the
92
+ documented behavior; the canonical inbox in (a) is unaffected.
93
+ - **No de-duplication across rapid-fire same-thread inbound.** If the
94
+ relay handler is invoked twice for the same `messageId`, the bridge
95
+ will post twice. Out of scope for this PR — same-thread rapid-fire
96
+ is handled at the relay layer (existing pipe-mode guard) and the
97
+ threadline replay-gate (already enforced by the relay client).
98
+
99
+ ## 3. Level-of-abstraction fit
100
+
101
+ The split is the same one used in (2):
102
+
103
+ - **TelegramBridgeConfig** owns *whether* to mirror (validation + policy).
104
+ - **TelegramBridge** owns *how* to mirror (binding lookup, topic creation,
105
+ HTTP call, body formatting).
106
+ - The relay handler and `/threadline/relay-send` route own *when* to
107
+ mirror (event firing).
108
+
109
+ The bridge depends only on a `TelegramSink` interface (subset of
110
+ `TelegramAdapter`'s `findOrCreateForumTopic` + `sendToTopic`). Tests
111
+ inject a fake sink to exercise success and failure paths without a
112
+ running Telegram. This keeps the bridge testable and the dependency
113
+ surface narrow.
114
+
115
+ ## 4. Signal-vs-authority compliance
116
+
117
+ - **Signal:** the gate-passed inbox event (already authorized by
118
+ `InboundMessageGate` upstream); the threadline_send → relay-send
119
+ outbound success.
120
+ - **Authority:** `TelegramBridgeConfig` decides whether the bridge runs
121
+ at all. The bridge itself emits zero decisions back to the routing
122
+ layer — `mirrorInbound` and `mirrorOutbound` return `{posted, reason}`
123
+ for observability only; nothing in the routing path inspects that
124
+ return value. This is the canonical signal-vs-authority pattern: the
125
+ bridge is a low-context observer; the higher-level intelligent gate
126
+ (config + the existing inbound gate) holds blocking authority.
127
+
128
+ ## 5. Interactions
129
+
130
+ - **Canonical inbox (PR #113).** The bridge fires AFTER the canonical
131
+ inbox write at relay-ingest. Two writes — one local audit, one
132
+ Telegram mirror — both flow from the same event. No coupling: if the
133
+ canonical write fails, the bridge call still runs (and vice versa).
134
+ - **`telegram-reply.sh` pipeline.** The bridge does NOT use this
135
+ pipeline — it calls TelegramAdapter primitives directly, bypassing
136
+ the agent-reply pre-tone-gate path. There is no double-fire because
137
+ `telegram-reply` is for agent → user replies tied to specific topics,
138
+ whereas the bridge writes into bridge-owned topics distinguished by
139
+ the `{local}↔{remote}` naming convention. If the user runs
140
+ `/link <session>` to bind a session to a bridge topic, that's the
141
+ user's explicit choice; the bridge doesn't generate that overlap by itself.
142
+ - **Spawn-session prompt.** The bridge does NOT replace the existing
143
+ spawn-session orchestration. The relay handler still spawns Claude
144
+ Code sessions on inbound; the bridge runs alongside, purely for user
145
+ visibility.
146
+ - **`topic-session-registry.json`.** The bridge maintains its own,
147
+ separate file (`telegram-bridge-bindings.json`) so it does not
148
+ collide with session ↔ topic bindings. Reusing
149
+ `topic-session-registry.json` would have conflated two distinct
150
+ concerns (bridge bindings = thread → topic; session registry =
151
+ session ↔ topic).
152
+
153
+ ## 6. Rollback cost
154
+
155
+ - Set `enabled=false` via the dashboard or `PATCH
156
+ /threadline/telegram-bridge/config`. Bridge stops mirroring on the
157
+ next event. Existing bindings stay in the file (no dangling Telegram
158
+ topics get cleaned up — they continue to exist in Telegram but the
159
+ bridge stops feeding them).
160
+ - Drop the bridge module entirely: remove the
161
+ `TelegramBridge` import + instantiation in server.ts + the two route
162
+ hooks. Settings UI from (2) keeps working unchanged. Bindings file
163
+ becomes an orphaned `.instar/threadline/telegram-bridge-bindings.json`
164
+ on disk; safe to leave.
165
+ - No database migrations, no Telegram-side cleanup, no schema changes.
166
+
167
+ ## Plan if a regression appears
168
+
169
+ - **Symptom: Telegram noise.** Verify settings — the user can flip
170
+ `enabled=false` instantly via the dashboard. If the noise comes
171
+ through an existing topic that the user wants quieter,
172
+ `mirrorExisting=false` stops it without affecting auto-create policy.
173
+ - **Symptom: routing latency increases.** The bridge calls are
174
+ fire-and-forget (`.catch(() => {})` on both `mirrorInbound` and
175
+ `mirrorOutbound`). Routing should not be on the critical path of any
176
+ Telegram call. If a regression shows otherwise, audit for an
177
+ unintended `await` in the relay handler.
178
+ - **Symptom: Telegram topic spam.** Auto-create policy gone wrong.
179
+ `shouldAutoCreateTopic` is unit-tested for "deny on either id" and
180
+ "allow on either id"; if a real spam case appears, capture the
181
+ `remoteAgent` + `remoteAgentName` values and add to the deny-list.
182
+
183
+ ## Phase / scope
184
+
185
+ Third of five deliverables in topic-8686:
186
+
187
+ 1. (a) Canonical inbox write-path — **MERGED** (#113).
188
+ 2. (2) Settings surface — **PR #114, merging**.
189
+ 3. **(b) Bridge module — THIS PR.**
190
+ 4. (4) Observability tab — extends the Threadline dashboard tab to render
191
+ the canonical inbox + bindings + thread-resume-map.
192
+ 5. (c) Backfill four open threads — one-shot script.
193
+
194
+ After (b) merges, the bridge is **armed but quiet by default**. The
195
+ user must flip `enabled=true` in the dashboard for any traffic to reach
196
+ Telegram. The next PR (4) lights up the observability view.