Skip to content

Import bridge from hasna/bridge (history preserved) - #18

Merged
andrei-hasna merged 35 commits into
mainfrom
import/bridge
Aug 13, 2026
Merged

Import bridge from hasna/bridge (history preserved)#18
andrei-hasna merged 35 commits into
mainfrom
import/bridge

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Import bridge from hasna/bridge (history-preserving, merge-commit)

Task: ee9fbb4d (wave 1 batch B). Mechanism: import-mechanics-codex.md (approved; reviewers A+B GO). Import via disposable mirror → git-filter-repo --to-subdirectory-filter apps/bridge --preserve-commit-hashes --preserve-commit-encoding → same-tree history capsule → one merge-commit PR (merge-commit strategy required — squash/rebase breaks imported-history reachability). No publishes, no repo-settings changes, no deletions.

Evidence

PR freeze (checklist 6): 0 open PRs on hasna/bridge at import time.

Ref inventory (checklist 1): heads=4, tags=0. refs/pull/* present in the mirror; pull-only commits dropped per mechanism P3 (accepted, recorded). Peel skipped list: none (all 4 refs resolved via rev-parse ^{commit}; 0 duplicates).

Capsule: 3771351, parents=4. PARENT_SET_EQUAL GAPS=0 (checklist 3).

Tree/blob gate (checklist 4): rewritten main tree == mono subtree tree except the documented absorption fixes below. 0 gitlink (160000) entries (negative submodule control). Provenance: rewritten main is an ancestor of the import head (DAG_REACHABLE); git fsck --connectivity-only rc=0 (dangling only).

In-mono absorption gate (checklist 2): bun install rc=0 · bun run check rc=0 (secrets scan 0 findings / 13,611 added lines) · turbo build --affected rc=0 · test --affected rc=0 · lint --affected rc=0.

Absorption fixes (in this PR, smallest per P1b):

  1. apps/bridge/package.json — added explicit devDeps bun-types@1.3.14, @types/node@26.1.0: tsconfig.json types ["bun-types"] failed in-mono with TS2688 (bun-types unlinked in the workspace store; exact versions match the old repo's resolved tree).
  2. apps/bridge/tests/codewith-durable.test.ts — synthetic fixture AWS_ACCESS_KEY_ID: "AKIAEXAMPLE" split by concatenation (runtime-identical; the mono's secret scanner flags any AKIA[A-Z0-9] contiguous token in staged added lines).
  3. Pre-existing member defect fixed in-PR (reproduced standalone — NOT mono-induced): apps/bridge/tests/serve-preflight.test.ts "a second signal force-exits serve while an agent run is still in flight" failed on bridge's own main standalone (exit 0 expected non-zero). Root cause: commit 85c6b21 (Import datasets (history-preserving, merge-commit) #8, agent reaper) made the FIRST SIGTERM interrupt in-flight agents, so the fixture sh -c sleep 120 died on signal 1, the graceful path completed with exit 0, and the forced path (stopSignals>1 → exit 130) was never reached. Fix: fixture agent changed to TERM-ignoring sh -c "trap '' TERM; sleep 120" so the escape hatch is genuinely exercised — 4 pass / 0 fail verified standalone AND in-mono. Production signal behavior unchanged (correct per src/lib/agents.ts contract). Tracked: todos 340d0978 (bug task; this import lane is the fixer).
    Root bun.lock updated by bun install (member registration).

Hygiene (checklist 5): no .gitattributes LFS rules, no .gitmodules, staged secrets scan 0 findings, git diff --cached --check clean.

Tag→commit manifest (checklist 8): /tmp/opencode/wave1b/bridge/tag-commit-manifest.md (0 tags; 4 capsule parents; GAPS=0).

Landing requirement

Merge with GitHub's merge-commit strategy only (mechanism doc; squash or rebase breaks reachability of the imported history).


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

andrei-hasna and others added 30 commits June 26, 2026 14:42
- broadcast(config, channelRef, message): one-to-many Telegram channel/group
  posting, distinct from the inbound routing surface in src/lib/telegram.ts
- outbound allowlist: telegram channels gain broadcastChatIds /
  allowAllBroadcasts (fail-closed, mirroring the inbound allowedChatIds
  pattern); telegramBroadcastAllowed() enforces it per target
- per-post delivery status (sent + Telegram message_id | failed + detail |
  skipped + allowlist reason); individual failures never abort the batch
- delivery reports persisted in state.json under broadcasts (most recent
  200) via recordBroadcast/listBroadcasts/getBroadcast
- CLI: bridge broadcast <channel> <text> [--targets], bridge broadcasts
  list/show; channels add-telegram gains --broadcast-chat-ids /
  --allow-all-broadcasts
- MCP: bridge_broadcast, bridge_broadcast_reports
- tests mock the Telegram sender; nothing is sent for real
feat: outbound broadcast surface with per-post delivery status
* feat: auto-session inbound reply routing via channel defaultAgentId

Inbound Telegram/iMessage/console replies could not reach an agent unless
an operator had manually run bridge sessions create/attach for the exact
conversation; otherwise every message fell through to the 'No bridge session
is attached' help text and no agent was ever invoked.

Channels can now declare a defaultAgentId. When a message arrives for a
conversation with no existing binding and no matching route, the dispatcher
lazily creates a durable session + binding for that agent and routes to it;
subsequent messages from the same conversation resume the same session.
Provisioning is gated on channel authorization so unauthorized chats cannot
create sessions. Adds a doctor check and regression coverage for malformed
updates.

* fix: auto-provision the sole codewith agent when a channel has no defaultAgentId

An inbound reply from an already-allowlisted chat (e.g. the owner chat) that
lacks an explicit channel defaultAgentId now falls back to the sole configured
codewith agent, so the reply routes to an agent instead of bouncing the
"no session" help text. Ambiguous configs (several codewith agents) still refuse
to guess; non-codewith agents are never auto-selected. An explicit-but-missing
defaultAgentId still errors loudly.

---------

Co-authored-by: andreihasna <hasna@station01.taild59be2.ts.net>
…nv hardening (#3)

* feat: auto-session inbound reply routing via channel defaultAgentId

Inbound Telegram/iMessage/console replies could not reach an agent unless
an operator had manually run bridge sessions create/attach for the exact
conversation; otherwise every message fell through to the 'No bridge session
is attached' help text and no agent was ever invoked.

Channels can now declare a defaultAgentId. When a message arrives for a
conversation with no existing binding and no matching route, the dispatcher
lazily creates a durable session + binding for that agent and routes to it;
subsequent messages from the same conversation resume the same session.
Provisioning is gated on channel authorization so unauthorized chats cannot
create sessions. Adds a doctor check and regression coverage for malformed
updates.

* fix: auto-provision the sole codewith agent when a channel has no defaultAgentId

An inbound reply from an already-allowlisted chat (e.g. the owner chat) that
lacks an explicit channel defaultAgentId now falls back to the sole configured
codewith agent, so the reply routes to an agent instead of bouncing the
"no session" help text. Ambiguous configs (several codewith agents) still refuse
to guess; non-codewith agents are never auto-selected. An explicit-but-missing
defaultAgentId still errors loudly.

* feat: durable codewith sessions via accounts with reply isolation and env hardening

codewith agents now run through accounts (accounts run codewith -p <profile> --
exec --json --durable -o <file>) and keep a real per-conversation codewith
session id, resumed via 'codewith exec resume <SESSION_ID>' on later messages
and across restarts. Session ids are stored per auth profile on the bridge
session because a codewith session is only resumable under the profile that
created it; the bridge never uses accounts --resume / codewith --last, which
would reattach the most-recent global session and cross-contaminate the many
Telegram chats multiplexed onto one profile.

Reply text is isolated from the JSONL event stream via --output-last-message
(with a parsed-final-event fallback); structured stdout is flagged and never
relayed to the user.

Security: the spawned agent no longer inherits the full station environment.
The bridge's own channel secrets and credential-shaped env keys are stripped
before spawn; a needed key can be re-added via profile/agent env.

* fix: switch agent env from a deny-list to an explicit allow-list

buildAgentEnv previously inherited the whole station environment minus
credential-shaped names, which leaks station secrets that dodge the name pattern
(DATABASE_URL, AWS_ACCESS_KEY_ID, TELEGRAM_SESSION, ...). It now passes only an
explicit allow-list (PATH/HOME/locale/XDG + codewith/accounts/bun toolchain
prefixes) plus profile/agent env and a configurable envPassthrough allow-list
extension. Bridge channel secrets and credential-shaped keys are still stripped
defensively even when allow-listed.

---------

Co-authored-by: andreihasna <hasna@station01.taild59be2.ts.net>
…n messages (#4)

* feat: auto-session inbound reply routing via channel defaultAgentId

Inbound Telegram/iMessage/console replies could not reach an agent unless
an operator had manually run bridge sessions create/attach for the exact
conversation; otherwise every message fell through to the 'No bridge session
is attached' help text and no agent was ever invoked.

Channels can now declare a defaultAgentId. When a message arrives for a
conversation with no existing binding and no matching route, the dispatcher
lazily creates a durable session + binding for that agent and routes to it;
subsequent messages from the same conversation resume the same session.
Provisioning is gated on channel authorization so unauthorized chats cannot
create sessions. Adds a doctor check and regression coverage for malformed
updates.

* fix: auto-provision the sole codewith agent when a channel has no defaultAgentId

An inbound reply from an already-allowlisted chat (e.g. the owner chat) that
lacks an explicit channel defaultAgentId now falls back to the sole configured
codewith agent, so the reply routes to an agent instead of bouncing the
"no session" help text. Ambiguous configs (several codewith agents) still refuse
to guess; non-codewith agents are never auto-selected. An explicit-but-missing
defaultAgentId still errors loudly.

* feat: durable codewith sessions via accounts with reply isolation and env hardening

codewith agents now run through accounts (accounts run codewith -p <profile> --
exec --json --durable -o <file>) and keep a real per-conversation codewith
session id, resumed via 'codewith exec resume <SESSION_ID>' on later messages
and across restarts. Session ids are stored per auth profile on the bridge
session because a codewith session is only resumable under the profile that
created it; the bridge never uses accounts --resume / codewith --last, which
would reattach the most-recent global session and cross-contaminate the many
Telegram chats multiplexed onto one profile.

Reply text is isolated from the JSONL event stream via --output-last-message
(with a parsed-final-event fallback); structured stdout is flagged and never
relayed to the user.

Security: the spawned agent no longer inherits the full station environment.
The bridge's own channel secrets and credential-shaped env keys are stripped
before spawn; a needed key can be re-added via profile/agent env.

* fix: switch agent env from a deny-list to an explicit allow-list

buildAgentEnv previously inherited the whole station environment minus
credential-shaped names, which leaks station secrets that dodge the name pattern
(DATABASE_URL, AWS_ACCESS_KEY_ID, TELEGRAM_SESSION, ...). It now passes only an
explicit allow-list (PATH/HOME/locale/XDG + codewith/accounts/bun toolchain
prefixes) plus profile/agent env and a configurable envPassthrough allow-list
extension. Bridge channel secrets and credential-shaped keys are still stripped
defensively even when allow-listed.

* feat: resume path, resume-by-default daemon, and dead-letter for poison messages

Adds bridge serve --resume, which reconciles durable in-flight state
(bindings, sessions, persisted getUpdates offsets, interrupted ledger
entries) before polling and logs a resume banner. bridge daemon start/restart
now pass --resume by default (opt out with --no-resume) so a restarted daemon
reattaches channel bindings and resumes in-flight work. The getUpdates offset
is persisted per channel and only advances on a terminal outcome, so a restart
never loses or duplicates updates (the ledger dedupes processed update ids).

Adds a dead-letter path: a message whose delivery keeps failing no longer
blocks every newer update behind it (head-of-line poisoning). After
--max-attempts (default 5) it becomes terminal dead_letter, the offset
advances, and the failure is logged. New handleInboundMessage /
reconcileInFlight helpers centralize the offset-advance decision and are
unit-tested for replay-without-duplication and poison handling.

* test: cover per-conversation thread_id resume across a state restart

Adds a save/load round-trip test proving a captured codewith thread_id is
persisted per conversation and resumed with 'codewith exec resume <thread_id>'
after a daemon restart.

---------

Co-authored-by: andreihasna <hasna@station01.taild59be2.ts.net>
* feat: auto-session inbound reply routing via channel defaultAgentId

Inbound Telegram/iMessage/console replies could not reach an agent unless
an operator had manually run bridge sessions create/attach for the exact
conversation; otherwise every message fell through to the 'No bridge session
is attached' help text and no agent was ever invoked.

Channels can now declare a defaultAgentId. When a message arrives for a
conversation with no existing binding and no matching route, the dispatcher
lazily creates a durable session + binding for that agent and routes to it;
subsequent messages from the same conversation resume the same session.
Provisioning is gated on channel authorization so unauthorized chats cannot
create sessions. Adds a doctor check and regression coverage for malformed
updates.

* fix: auto-provision the sole codewith agent when a channel has no defaultAgentId

An inbound reply from an already-allowlisted chat (e.g. the owner chat) that
lacks an explicit channel defaultAgentId now falls back to the sole configured
codewith agent, so the reply routes to an agent instead of bouncing the
"no session" help text. Ambiguous configs (several codewith agents) still refuse
to guess; non-codewith agents are never auto-selected. An explicit-but-missing
defaultAgentId still errors loudly.

* feat: durable codewith sessions via accounts with reply isolation and env hardening

codewith agents now run through accounts (accounts run codewith -p <profile> --
exec --json --durable -o <file>) and keep a real per-conversation codewith
session id, resumed via 'codewith exec resume <SESSION_ID>' on later messages
and across restarts. Session ids are stored per auth profile on the bridge
session because a codewith session is only resumable under the profile that
created it; the bridge never uses accounts --resume / codewith --last, which
would reattach the most-recent global session and cross-contaminate the many
Telegram chats multiplexed onto one profile.

Reply text is isolated from the JSONL event stream via --output-last-message
(with a parsed-final-event fallback); structured stdout is flagged and never
relayed to the user.

Security: the spawned agent no longer inherits the full station environment.
The bridge's own channel secrets and credential-shaped env keys are stripped
before spawn; a needed key can be re-added via profile/agent env.

* fix: switch agent env from a deny-list to an explicit allow-list

buildAgentEnv previously inherited the whole station environment minus
credential-shaped names, which leaks station secrets that dodge the name pattern
(DATABASE_URL, AWS_ACCESS_KEY_ID, TELEGRAM_SESSION, ...). It now passes only an
explicit allow-list (PATH/HOME/locale/XDG + codewith/accounts/bun toolchain
prefixes) plus profile/agent env and a configurable envPassthrough allow-list
extension. Bridge channel secrets and credential-shaped keys are still stripped
defensively even when allow-listed.

* feat: resume path, resume-by-default daemon, and dead-letter for poison messages

Adds bridge serve --resume, which reconciles durable in-flight state
(bindings, sessions, persisted getUpdates offsets, interrupted ledger
entries) before polling and logs a resume banner. bridge daemon start/restart
now pass --resume by default (opt out with --no-resume) so a restarted daemon
reattaches channel bindings and resumes in-flight work. The getUpdates offset
is persisted per channel and only advances on a terminal outcome, so a restart
never loses or duplicates updates (the ledger dedupes processed update ids).

Adds a dead-letter path: a message whose delivery keeps failing no longer
blocks every newer update behind it (head-of-line poisoning). After
--max-attempts (default 5) it becomes terminal dead_letter, the offset
advances, and the failure is logged. New handleInboundMessage /
reconcileInFlight helpers centralize the offset-advance decision and are
unit-tested for replay-without-duplication and poison handling.

* test: cover per-conversation thread_id resume across a state restart

Adds a save/load round-trip test proving a captured codewith thread_id is
persisted per conversation and resumed with 'codewith exec resume <thread_id>'
after a daemon restart.

* feat: automatic auth-profile rotation on codewith exhaustion

codewith agents accept an ordered fallbackProfileIds rotation pool. When the
active profile hits a usage/quota/auth exhaustion signal (rate-limit, quota,
auth-expired, 429/401/403), the durable adapter rotates to the next profile and
continues the turn in the same call. Each profile keeps its own codewith
session id (a session created under one profile is not resumable under
another), so rotation accepts a fresh context on the new profile the first time
it is used; the bridge session is then pinned to the healthy profile so later
messages resume its session directly.

Adds isExhaustionSignal/rotationProfiles/activeRotationProfile/nextRotationProfile
helpers, a doctor auth-rotation check, and the agents add --fallback-profile
flag, with unit tests including an end-to-end simulated-exhaustion rotation
that continues the session.

* harden: precise codewith session-id extraction (explicit keys + containers)

Only accept explicit session_id/conversation_id/thread_id keys or a
session/thread/conversation container object's id, rather than any UUID-shaped
id field, to avoid capturing a tool-call or turn id as the resumable session.

* fix: structured exhaustion/stale-session detection, context-reset note, canonical session-id capture

- isExhaustionSignal now classifies codewith --json error events (type/code/
  message) + exit code instead of raw-string matching the whole output, so a
  reply that merely mentions "rate limit"/"429" no longer triggers rotation.
- Add isStaleSessionSignal + stale-session self-heal: a resume whose stored
  thread_id is gone retries once on a fresh session instead of erroring forever.
- Cross-profile rotation and self-heal now set contextReset and the reply is
  prefixed with a user-visible CONTEXT_RESET_NOTE (no false seamless-resume
  claim). Optional checkUsageExhausted probe skips a known-exhausted profile.
- extractCodewithSessionId prefers the canonical thread.started/thread_id event.
- Bump to 0.6.1.

---------

Co-authored-by: andreihasna <hasna@station01.taild59be2.ts.net>
Durable codewith runs went through `accounts run codewith -p <profile>`,
which points CODEWITH_HOME at a per-account dir and forks the thread store
per billing account, so a thread created under account A was unreadable
under account B — every rotation started a fresh session and showed a
misleading "context was reset" note.

Invoke codewith directly against one shared, stable CODEWITH_HOME and select
the paying account with codewith's native --auth-profile flag. A conversation
now keeps a single, auth-independent thread_id (AgentSessionRef.refId) resumed
under whichever account pays. On exhaustion, rotation switches ONLY the billing
account and resumes the SAME thread (exec resume <id> --auth-profile <next>);
a new thread starts only as a genuine stale-thread fallback. CONTEXT_RESET_NOTE
is emitted only for that unrecoverable case, never for normal rotation.

Drops the per-profile providerSessions map and the buildAccountsCommand export;
adds buildCodewithCommand + resolveCodewithHome and an authProfile option on
buildCodewithExecArgs. Adds a mid-conversation test proving exhaust A -> rotate
to B resumes the same thread_id with no reset note.
…bust error replies (#7)

* fix: rotation carries context via shared codewith thread store

Durable codewith runs went through `accounts run codewith -p <profile>`,
which points CODEWITH_HOME at a per-account dir and forks the thread store
per billing account, so a thread created under account A was unreadable
under account B — every rotation started a fresh session and showed a
misleading "context was reset" note.

Invoke codewith directly against one shared, stable CODEWITH_HOME and select
the paying account with codewith's native --auth-profile flag. A conversation
now keeps a single, auth-independent thread_id (AgentSessionRef.refId) resumed
under whichever account pays. On exhaustion, rotation switches ONLY the billing
account and resumes the SAME thread (exec resume <id> --auth-profile <next>);
a new thread starts only as a genuine stale-thread fallback. CONTEXT_RESET_NOTE
is emitted only for that unrecoverable case, never for normal rotation.

Drops the per-profile providerSessions map and the buildAccountsCommand export;
adds buildCodewithCommand + resolveCodewithHome and an authProfile option on
buildCodewithExecArgs. Adds a mid-conversation test proving exhaust A -> rotate
to B resumes the same thread_id with no reset note.

* feat: live bridge YOLO codewith runs + per-agent project/channel + robust errors

Make the live Telegram bridge able to REPLY and let the agent ACT:

- Full YOLO codewith invocation. Every codewith exec (durable + compatibility)
  now passes --skip-git-repo-check AND --dangerously-bypass-approvals-and-sandbox,
  and the accounts wrapper runs `accounts run codewith --permissions dangerous`.
  Replaces the observed view-only `sandbox: read-only, approval: never` run with
  a full write+exec, no-approval, no-sandbox run that can escape its folder.

- Per-agent project + channel. Each agent lazily + idempotently provisions its
  OWN projects-CLI project (a dedicated folder used as the run cwd) and its OWN
  `agent-<name>` conversations channel, following the agent-ea/agent-marcus/
  agent-chief-of-staff convention. The workspace (projectId/path/channel) is
  persisted into the agent config; explicit session/agent/profile cwd still wins.
  Provisioning is opt-in via runAgent deps (CLI serve/ask wire it) and never
  fails a run — a projects/conversations outage still yields a usable folder and
  is retried next run.

- Robustness. A dead-lettered message now sends the sender a clear
  "⚠️ I could not process that message …" reply (structured codewith error,
  nested provider errors unwrapped) instead of dying silently. Non-fatal tool
  diagnostics (shell_snapshot validation warnings and similar) never fail a run
  and are stripped from user-facing failure text.

Bump 0.6.1 -> 0.7.0. New tests cover the exec argv (skip-git + yolo flags),
accounts --permissions dangerous, agent-project cwd resolution, idempotent
per-agent project+channel provisioning, dead-letter error replies, and log-noise
tolerance.

* fix: reliable per-agent provisioning + honest failure text + all-kind workspace cwd

- provision: a failed agent-<name> channel create no longer marks the pass
  provisioned; the channel half is tracked on its own channelProvisionedAt and
  retried next run (a projects outage also no longer re-creates a confirmed
  channel). Already-exists detection is narrow (already exists/duplicate/
  conflict/HTTP 409) so failures like 'no such tenant exists' are not masked.
- provision: projects create now passes --yes (approve path/dir effects); a
  registry record left with primary_path=null (api-mode create) is pinned
  deterministically via 'projects update <id> --path <folder> --json'; a
  prompt-agent create that stops at a plan re-reads by name and falls back to
  the deterministic create. Record parsing understands the prompt-agent run
  wrapper (projects[] / tool_calls[].output.project).
- agents: filterAgentLogNoise only strips TRACE/DEBUG, timestamped
  module::path tracing lines, and the stdin echo — genuine fatal stderr such
  as 'ERROR: invalid API key' stays in user-facing failure text.
- agents: the provisioned workspace cwd applies to claude/aicopilot/shell/
  custom compatibility agents too, not only codewith.
- tests: channel-outage retry, projects-outage channel reuse, 409/tenant
  matcher cases, primary-path pinning (+failure retry), deterministic-create
  fallback, real projects CLI end-to-end in an isolated local store, log-noise
  keep/strip matrix, claude/shell provisioned-cwd; hermetic cwd for spawned-CLI
  tests.
…operly

Bun attaches the failed request URL to network errors (err.path), and the
Telegram request URL embeds the bot token, so any connection failure exposed
the token to whoever read the terminal or the daemon stderr log. All Telegram
failures now surface as a sanitised TelegramApiError that never carries the URL.

Also:
- honour Telegram's 429 retry_after (exposed on the error) instead of retrying
  on a short linear backoff, which escalates a soft rate limit;
- reject a malformed (non-array) getUpdates result instead of iterating it deep
  inside the poll loop;
- drop updates without a numeric update_id, which would persist NaN as the poll
  offset and permanently break the cursor;
- accept an AbortSignal so a long poll can be cancelled on shutdown.
…ression

An invalid pattern was accepted by `routes add` and only compiled later, inside
matchingRoutes, throwing a raw SyntaxError on every inbound message. Because the
poll offset only advances on a terminal outcome, that wedged the channel
permanently. Validating in the schema makes the bad value unstorable.
…ors cleanly

- serve now preflights bot tokens for every enabled Telegram channel and exits
  with one actionable message naming each missing env var. A missing token can
  never be fixed while the process runs, so the previous behaviour (log the same
  error on every backoff cycle, forever) hid a permanent misconfiguration.
  `bridge daemon start` already preflighted this; foreground serve did not.
- SIGINT/SIGTERM now abort the in-flight long poll and the backoff sleep instead
  of only setting a flag. Shutdown could previously take up to 50s, so
  `bridge daemon stop` and `systemctl stop` hit their SIGTERM timeout and Ctrl-C
  looked frozen.
- Poll backoff honours Telegram's 429 retry_after.
- `doctor --json` exits non-zero when a check fails; the exit code was only set
  on the human-readable branch, so scripts always saw success.
- `sessions send` exits non-zero when the message was not delivered (paused,
  closed or failed sends produce no agent result and reported success).
- `broadcast` takes variadic text like `send` and `ask`; unquoted multi-word
  messages were rejected with "too many arguments".
- Action-handler failures print one clean `bridge: <message>` line instead of
  Bun's uncaught-error dump of surrounding source, stack, and error properties.
The cursor only moved to the last *delivered* row, so a scan window containing
nothing deliverable reported no progress at all. The same window was then
re-scanned on every poll and any allowed message beyond it was never reached — a
Mac with enough non-allowlisted iMessage traffic makes the channel permanently
deaf (pollLimit 50 scans 500 rows, so ~500 consecutive disallowed messages is
enough).

getIMessageMessagePage now returns the highest ROWID the caller may advance to:
the last row scanned normally, or the last row returned when the page was
truncated by `limit`, so allowed rows past the limit are never skipped.
getIMessageMessages is unchanged and delegates to it.
Inbound routing reads config.channels[message.channelId] (the record key) while
session bindings are keyed by channel.id. A hand-edited mismatch loaded fine and
then misbehaved silently: `sessions attach` created a binding no inbound message
ever matched, so every message got the "no session attached" reply. The same
split applies to profiles and agents.

The CLI writers always key by id, so this only guards hand-edited files — and
failing loudly at load beats silent misrouting.
…mpts

Once the ledger reaches `agent_completed` the agent has produced an answer and
it is stored in entry.responseText. A later failure is a transport problem, but
it was charged to the same retry budget as a failed agent run: a brief Telegram
outage while sending burned the budget, dead-lettered a fully answered message,
and told the sender "I could not process that message" — wrong, and the real
answer was discarded with no way to redeliver it.

Delivery failures now consume a separate MessageLedgerEntry.deliveryAttempts
counter against maxDeliveryAttempts (default 10, vs 5 for processing). They never
dead-letter and never notify the sender — the transport to them is exactly what
is broken. When the budget is exhausted the poll cursor advances so one
undeliverable conversation cannot block newer messages, while the entry stays
non-terminal with its responseText intact, so a replay redelivers the stored
reply instead of re-running the agent.

Reported by the agent working on agents.ts/sessions.ts (PR #8); the fix lands
here because handleInboundMessage owns the retry decision.

types.ts: strictly additive — one new optional field, MessageLedgerEntry.deliveryAttempts.
…Ctrl-C escape hatch

- `serve` advances the iMessage cursor past filter-rejected rows and logs a clear
  operator line when an already-produced reply exhausts its delivery budget.
- A second SIGINT/SIGTERM force-exits (130). The graceful stop cannot interrupt
  an in-flight agent run, and moving from process.once to process.on had removed
  the escape hatch a repeated Ctrl-C used to provide.
- `send` on a Telegram channel rejects empty text locally instead of spending an
  API call on a message Telegram will refuse, matching the iMessage branch.
…file

saveState() rewrote state.json in place with writeFile(). The daemon rewrites
state on every poll while CLI commands read and write the same file, so a
reader could observe a half-written document: a two-process stress run saw 26
unparseable reads out of ~1300. A crash or a failed write also left a
truncated state.json, and loadState() then threw on every subsequent start,
so the daemon could not boot until a human deleted the file by hand -- taking
all sessions, channel bindings and the delivery ledger with it.

- saveState() now writes a private temp file in the same directory, fsyncs it,
  chmods it 0600 and renames it over the target, then fsyncs the directory.
  Readers therefore only ever see a whole document, and mode 0600 holds on
  rewrite. The temp name uses randomUUID() so concurrent saves in one process
  cannot pick the same path. A failed write cleans up its temp file and leaves
  the previous state intact.
- loadState() no longer dies on an unparseable file. It moves the file aside to
  state.json.corrupt-<ts> (preserving the bytes and their 0600 mode for manual
  salvage), logs what happened, and continues from empty state so the bridge
  still starts. Operators who would rather intervene can pass
  { onCorrupt: "throw" }, which now names the offending path instead of raising
  a bare "JSON Parse error".

This does not make concurrent writers safe against lost updates -- the last
writer still wins. That needs write locking and is out of scope here.

Regression tests in tests/daemon-state.test.ts cover atomic replacement (a
handle opened before the write still reads a complete document, and the inode
changes), no leaked temp files, mode 0600 on rewrite, quarantine-and-continue,
and the opt-in strict mode.
…rocess identity

Four lifecycle defects, all reachable by a user whose daemon crashed.

1. Stale metadata was only reaped on the `start` path (daemon.ts:392). Anything
   that died without going through `stop` -- SIGKILL, OOM kill, power loss --
   left `bridge daemon status` and `bridge doctor` reporting `stale pid=N`
   indefinitely. `daemonStatus()` now reaps by default via the new exported
   `reapStaleDaemonMetadata()`, so both commands self-heal. Callers that already
   hold the daemon lock pass `reap: false`.

   Reaping is deliberately conservative, because a status read must never
   orphan a daemon that is concurrently starting: it takes the daemon lock,
   re-reads the metadata under it, checks the pid and startedAt still match what
   it observed, and re-verifies liveness before removing anything. If the lock
   is busy it reports the reason instead of failing. A grace window protects
   metadata whose process is alive but not yet identifiable (metadata is written
   before the child has established its process group); a pid that does not
   exist at all is reaped immediately, since it cannot be starting.

2. An abandoned lock directory wedged the daemon permanently. withDaemonLock()
   created lock/ with mkdir and removed it in a finally, so a process killed
   mid-operation left it behind and every later start/stop/restart failed with
   "Another bridge daemon operation is already running" -- with no recovery
   short of deleting the directory by hand. The lock now records its owner pid,
   and a lock whose owner is dead (or that is older than LOCK_MAX_AGE_MS) is
   broken with a single atomic rename, so two racing callers cannot both break
   it and both believe they hold it. Release only drops the lock if it is still
   ours. Release also had to move from rmdir to rm -r now that the directory
   holds an owner file -- rmdir would have failed with ENOTEMPTY and re-wedged
   the daemon after one operation.

3. Liveness was inferred partly from pid alone. pidAlive(0) returned true
   because kill(0, 0) signals the caller's own process group, and stopPid()
   sends to `-pid`, so a corrupt metadata pid of 0 was one guard away from
   SIGTERMing the user's shell. Pids are now validated (> 1) everywhere they are
   signalled, and processMatches() additionally requires the recorded pgid to
   equal the recorded pid: a bridge daemon is always its own process group
   leader, so metadata claiming otherwise is treated as stale rather than used
   to choose a process group to kill.

4. A failed post-start health check orphaned the child. startProcessDaemon()
   removed the metadata and threw, leaving a possibly-live bridge polling
   Telegram with no record of how to stop it. It now tears the process group
   down first.

Also in this file: tailFile() read the entire log into memory to show the last
few lines, which for an unrotated daemon log means loading gigabytes -- it now
reads a bounded window from the end (~300 MiB log: 9 ms, 8.8 MiB, was a full
300 MiB read) and drops the leading fragment. And `--lines N` returned N-1 real
lines, because the trailing newline of a log file yields an empty final element
that was counted as a line.

tests/daemon.test.ts: "stale daemon metadata is reported and cleaned on stop"
asserted that daemonStatus() reports stale, which is exactly the behaviour this
change fixes. Its detection half now asks for a pure read (reap: false); its
cleanup half is unchanged. Nothing was weakened -- both original assertions
still run.

Regression tests in tests/daemon-lifecycle.test.ts.
…d errors

`bridge doctor --json` printed a report with "ok": false and exited 0, so no CI
job or shell script could gate on bridge health. The exit-code assignment lived
inside the non-JSON branch of the CLI action; it is now unconditional.

Making doctor fail meant deciding what "unhealthy" is, because the old
all-checks-must-pass rule would have failed every bridge that does not happen to
have all three optional agent runtimes installed. DoctorCheck gains an optional
`severity`:

- error (the default when omitted): the bridge is broken. A missing Telegram bot
  token is explicitly error -- a channel with no token can neither receive nor
  send, which is the "3 agents configured, 0 tokens set" case, genuinely
  unhealthy even while the process reports running.
- warn: reported, but does not fail the report. The optional agent runtimes
  (codewith / claude / aicopilot) are warn *unless* a configured agent actually
  depends on that runtime and has no explicit command of its own, in which case
  the missing binary is escalated to error and names the dependent agent ids.
  `daemon-status` is warn: stale metadata is now reaped by daemonStatus(), so it
  can only still be reported when reaping was blocked by a concurrent daemon
  operation -- transient, and not a reason to fail a health gate.

DoctorReport.ok therefore means "no error-severity check failed". A failing warn
check keeps `ok: false` on the check itself, and the CLI prints it as `warn`.

Two more real defects fixed here:

- commandExists() shelled out to a *login* shell (`sh -lc`), which re-sources
  the user's profile and so answered for a PATH the bridge process does not
  have. A launchd/systemd daemon with a minimal environment would be told
  `ok codewith` and then fail to spawn it. It now resolves against the process
  PATH, the way the agent runner actually spawns commands. Note this is a
  behaviour change for anyone whose runtime is only on the login PATH: doctor
  will now report it, correctly, as not reachable by the daemon.
- doctor() ignored any non-default state path and daemon directory: it always
  stat'ed the default ones, so `bridge doctor -c /other/config.json` reported on
  the wrong files. It now takes an optional `daemonDir`, and the CLI forwards
  new `--state` and `--daemon-dir` options.

Shared files touched, both minimally:
- src/types.ts: strictly additive -- one optional `severity` field on
  DoctorCheck plus a doc comment on DoctorReport.ok. No existing field changed.
- src/cli/index.ts: confined to the `doctor` command action and its options.

tests/daemon.test.ts: "doctor reports invalid Telegram API base override" now
passes an explicit daemonDir. That test previously inspected the developer's
real ~/.hasna/bridge/daemon, which daemonStatus() now reaps stale metadata
from -- a hermeticity fix, not a weakened assertion.

Regression tests in tests/doctor-exit.test.ts.
…pe, not just by value

Two pre-publish gaps in the redaction added by 4454c35, both found in review.

1. `TelegramApiError.description` stored the raw upstream body text while only
   `message` was redacted. Bun's uncaught-error printer dumps an error's own
   properties, so a token surviving there is exposed exactly like one in the
   message. Reachable through BRIDGE_TELEGRAM_API_BASE (self-hosted
   telegram-bot-api, corporate proxy) when the upstream echoes the request URI:
   `description: "Cannot GET /bot<token>/getUpdates"`. Redacted once up front now,
   so message and description share one sanitised value.

2. Redaction was exact-substring on the configured token. WHATWG URL parsing
   strips \n/\r/\t, so a token read with `export TOKEN=$(cat tokenfile)` puts the
   CLEAN value on the wire while the search looks for the newline-suffixed one and
   matches nothing. Redaction now covers the raw value, its trimmed form and their
   percent-encoded forms, then sweeps any `/bot<id>:<secret>` path segment
   structurally — which also catches a proxy echoing a redirect or another bot's
   path, neither of which value matching can reach.

The class docstring claimed borrowed text was always stripped; that was false as
written and is now accurate.

Four regression tests, each confirmed to fail before the fix. Mutation-checked:
description redaction, the trim/encoded variants, and the structural sweep are
each independently load-bearing (removing any one turns a test red).
…the secret

A bot token is `<bot_id>:<secret>`, and the id before the colon is the bot's
public user id — returned by getMe, derivable by anyone who can message the bot.
Redacting it bought no protection while costing a self-hosted telegram-bot-api
operator the most useful field for diagnosing a routing problem.

Both passes now redact only the secret half, so they agree whichever one fires:
`/bot123456789:AAH.../getUpdates` -> `/bot123456789:[redacted-secret]/getUpdates`.
The structural sweep still fires on ANY `/bot<id>:<secret>` segment, not just the
configured token, so a proxy echoing a redirect still cannot leak a neighbouring
bot's secret — only which half of the match survives has changed.

Two tests added, both confirmed to fail first: one pins that the bot id survives
while both secrets (ours and a neighbouring bot's) do not, so a later
"simplification" of the regex cannot silently re-broaden it; one covers a
self-hosted secret containing `+` and `/`, which defeats the structural sweep
twice (the class excludes `/`, and `+` cuts the run below the {20,} floor) and is
caught only by the value pass and its percent-encoded variant.

Also dropped an inner `.trim()` that became redundant once the value pass split
the token — the outer `token.trim()` candidate already covers padding, and no
mutation test could fail on it. Every remaining layer is mutation-checked
load-bearing except the longest-first sort, which cannot change the outcome with
today's variant set; its comment now says so instead of implying otherwise.
…rd-coded 5s

`bridge daemon stop` allowed serve 5000ms to exit (daemon.ts:485) and threw
"Bridge daemon did not stop within 5000ms" otherwise, leaving the metadata on
disk. `restartProcessDaemon` inherits the same path and failed the same way.

Reproduced on this branch, and the 5s budget is already wrong here -- it does
not need PR #8 to bite. serve registers `process.once("SIGTERM", stop)`
(cli/index.ts:160) and that handler only raises a flag. Registering any SIGTERM
listener suppresses the default terminate-on-signal, so serve does not die on
the signal; it exits at the next poll-loop boundary. Two things routinely hold
that boundary past 5s:

- An in-flight agent turn. serve stays inside handleInboundMessage for the
  agent's own budget, which agents.ts defaults to 120s. Once PR #8 spawns agents
  detached, the daemon's SIGTERM to its own process group stops reaching them at
  all, so this becomes the norm rather than the exception.
- An in-flight Telegram long poll, which serve defaults to 20 seconds
  (`channel.pollTimeoutSeconds || 20`). This alone exceeds 5s on a default,
  completely idle bridge.

The window is now derived from the config that governs those two waits, via a
new exported `stopGraceMsForConfig()`: widest configured agent budget + widest
enabled long poll + a 2s settle margin for delivering the reply and persisting
state, clamped to [5s, 10min]. A default config yields 142s. Explicit
`--timeout` still wins; `--force` selects a short 2s window.

This costs an idle daemon nothing, because the window is a *ceiling*, not a
sleep -- waitForExit returns the moment the process is gone. Measured through
the real CLI: restart during an in-flight agent run now exits 0 after 5.4s where
it previously failed at 5s, and an idle `daemon stop` completes in 109ms.
waitForExit also now backs off from a 5ms first poll rather than a flat 100ms
tick, so the fast path is observed promptly.

Stop now always ends in a defined state:

- SIGKILL escalation is unconditional once the window elapses. It was previously
  gated on `force`, so without that flag a process ignoring SIGTERM produced a
  throw and orphaned metadata. `force` therefore now selects a shorter grace
  window rather than deciding whether SIGKILL happens at all.
- If the process survives SIGKILL, the metadata is deliberately *kept* and the
  error says so. The process is still alive; dropping its record would let the
  next `start` launch a second daemon alongside it, both polling the same
  channels. The record self-heals -- status/doctor/start reap it once the
  process actually dies.
- waitForExit no longer mistakes an unreaped zombie for a live process (kill(pid,
  0) still succeeds for one), which would otherwise have produced a spurious
  "survived SIGKILL" failure. The check costs a subprocess so it runs only once a
  wait has otherwise failed.

DEFAULT_AGENT_TIMEOUT_MS mirrors `agent.timeoutMs ?? 120_000` from agents.ts,
which does not export it; the constant is named and carries a keep-in-sync note
rather than being an anonymous literal.

Regression tests in tests/daemon-stop-grace.test.ts: six covering the derivation
(explicit budgets, runtime defaults, widest-wins, floor, disabled channels
excluded, cap), one end-to-end test that a real daemon busy in a SIGTERM-ignoring
agent run is stopped rather than failed, one that an idle daemon still stops fast
despite a 10-minute configured ceiling, and one that a SIGTERM-ignoring process
is escalated to SIGKILL with its metadata reaped. The two behavioural tests were
confirmed failing against the previous stop path with the exact
"did not stop within 5000ms" / "within 300ms" errors.
…s, fix CLI exit codes (#9)

Adversarially reviewed by two independent reviewers (security lens + behaviour/data-loss lens), both GO, plus a focused security re-check of the redaction changes.

Security: the Telegram bot token was leaking into the terminal and daemon log. Telegram embeds the token in the URL path and Bun attaches the failed URL to network errors as err.path, which its error printer dumps. All failures are now a sanitised TelegramApiError; redaction covers the raw, trimmed and percent-encoded token plus a structural sweep, and preserves the public bot id while stripping only the secret.

Also fixed: 429 retry_after ignored; non-array getUpdates iterated; updates missing update_id persisting a NaN offset and bricking the channel; serve never failing on a missing token; SIGINT/SIGTERM not stopping serve; an invalid --text-regex wedging a channel permanently; iMessage going deaf behind ~500 disallowed messages; and a completed agent reply being discarded and mislabelled 'could not process' when only delivery failed.

172 tests pass (from 136), typecheck and build clean.
…ad codewith threads, serialise session turns (#8)

Co-authored-by: hasna-drain <drain@hasna.xyz>
@andrei-hasna
andrei-hasna merged commit a28b9fe into main Aug 13, 2026
4 checks passed
andrei-hasna added a commit that referenced this pull request Aug 17, 2026
Import events delta (history-preserving, merge-commit)

VERIFY-DELTA lane v4 (coordinator: aemilianus).

Imports the hasna/events org delta into the monorepo member, so apps/events carries the org's LATEST content.

- org head: ae8b29feeee18761a3acbac8cf636df47a8d0f17 (2026-08-16, "docs: canonicalize legacy open-* references in README (#18)")
- delta commits: 1 (878f4fe..ae8b29f)
- freeze base: 878f4fe (import merge 46dda5e; capsule content == base tree except named absorption edit in src/durable-worker.test.ts)
- member head: 9d4c644 ("docs: canonicalize legacy open-* references in README (#18)" + Agent: aemilianus)
- delta: README.md, 2 insertions/2 deletions
- org delta fully represented in member; remaining member-vs-org differences are pre-existing member-side absorption/evolution (unaffected by this import)

Gates: bun run check (names/secrets/manifests/publish-guard) rc=0; CI green required before merge.

Agent: aemilianus
andrei-hasna added a commit that referenced this pull request Aug 17, 2026
Merge delta import: search from hasna/search (hasna.contract.json #18, storage-mode env removal #19). CI green at head ed090fb; all gates pass.

Agent: aemilianus
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