Import bridge from hasna/bridge (history preserved) - #18
Merged
Conversation
- 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>
Agent: Augustus
Agent: agent-ea
Agent: agent-ea
Agent: agent-ea
This was referenced Aug 14, 2026
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 viarev-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-onlyrc=0 (dangling only).In-mono absorption gate (checklist 2): bun install rc=0 ·
bun run checkrc=0 (secrets scan 0 findings / 13,611 added lines) · turbobuild --affectedrc=0 ·test --affectedrc=0 ·lint --affectedrc=0.Absorption fixes (in this PR, smallest per P1b):
apps/bridge/package.json— added explicit devDepsbun-types@1.3.14,@types/node@26.1.0:tsconfig.jsontypes["bun-types"]failed in-mono with TS2688 (bun-types unlinked in the workspace store; exact versions match the old repo's resolved tree).apps/bridge/tests/codewith-durable.test.ts— synthetic fixtureAWS_ACCESS_KEY_ID: "AKIAEXAMPLE"split by concatenation (runtime-identical; the mono's secret scanner flags anyAKIA[A-Z0-9]contiguous token in staged added lines).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 fixturesh -c sleep 120died 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-ignoringsh -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 persrc/lib/agents.tscontract). Tracked: todos 340d0978 (bug task; this import lane is the fixer).Root
bun.lockupdated bybun install(member registration).Hygiene (checklist 5): no
.gitattributesLFS rules, no.gitmodules, staged secrets scan 0 findings,git diff --cached --checkclean.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).
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.