You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The bot hard-codes a single consumer: TELEGRAM_ALLOWED_USER_ID is one integer, auth is one equality check, and one global currentSession pointer serves the process. Shared-machine orgs (co-founder, partner, builder) hit this immediately — the second operator is silently rejected:
[WARN] Unauthorized access attempt from user ID: 123456789 (redacted)
We run two production orgs in exactly that shape and had to solve it. Sharing one tape between humans turned out to be the wrong goal anyway: interleaved steering contexts confuse both the model and the people. The right primitive is N authorized operators × 1 bot token = N independent session tapes, each with its own /new, context, and notification stream.
Proposed design (implemented and battle-tested)
Three layers, ~150 lines total, zero changes to existing call sites:
Allowlist — TELEGRAM_ALLOWED_USER_IDS (comma-separated) absorbs the legacy single var. Derivable from an access file's allowFrom so there's one source of truth.
Per-user session scope — an AsyncLocalStorage set in authMiddleware tags each update with its sender; the settings store resolves sessions through it (userSessions[userId] map, persisted). All ~70 getCurrentSession() call sites become per-user transparently.
Ownership-aware event routing — server events arrive outside any user scope; delivery guards resolve the target chat via a session→chat binding (partial streams, completions, tool cards, permissions, questions, errors, background notifications).
Backward compatibility guarantee: a solo operator (allowlist length 1) adopts the legacy session on first scoped lookup — upgrading never orphans an existing conversation. Single-user deployments see zero behavior change.
Live-found pitfalls (from rolling this out)
Completion handlers comparing against the session pointer then clearing state and returning on mismatch produce silent drops with no error surfaced — optional-chaining variants of the guard evade naive sweeps.
Compact-mode (compactOutputMode: true) branches carry their own inner session check; fixing only the outer guard restores half the behavior.
Interactive turns use POST /session/{id}/prompt_async, not /message — debugging against the wrong route 'works' while production fails.
Happy to open a PR with our reference implementation if the shape interests you — otherwise this doc plus the design notes above may save the next shared-org user some pain.
Rollout addendum (live-deployed since posting)
Three more pitfalls surfaced while running this in production — each a silent failure with a healthy-looking process:
Incomplete refactoring creates crash-loops in callbacks: removing a declaration while converting guards left orphaned references inside the permission/question callbacks. Every permission event then crashed its callback before rendering — so an agent asking for permission froze forever with zero visible errors until log diving. Rule: after guard surgery, grep for every remaining reference to any removed symbol; JS won't save you.
Per-session flush keys: toolMessageBatcher.flushSession / toolCallStreamer.flushSession inside permission/question handlers were keyed off the (now legacy) session pointer instead of request.sessionID / the event's own session id. Same class as feat: add option to hide subsessions in sessions list #4 — ownership-aware routing must extend to every session-keyed helper call, not just send targets.
Compact progress attribution: updateWaitingForPermission/updateThinking must attribute to the requesting session's own id, or secondary operators' status lands on the wrong lane's card.
Also now implemented: a backward-compat seed — solo operators (allowlist length 1) adopt the legacy session on first scoped lookup, so upgrading never orphans an existing conversation. Net effect for existing single-user deployments is zero behavior change.
The gap
The bot hard-codes a single consumer:
TELEGRAM_ALLOWED_USER_IDis one integer, auth is one equality check, and one globalcurrentSessionpointer serves the process. Shared-machine orgs (co-founder, partner, builder) hit this immediately — the second operator is silently rejected:We run two production orgs in exactly that shape and had to solve it. Sharing one tape between humans turned out to be the wrong goal anyway: interleaved steering contexts confuse both the model and the people. The right primitive is N authorized operators × 1 bot token = N independent session tapes, each with its own
/new, context, and notification stream.Proposed design (implemented and battle-tested)
Three layers, ~150 lines total, zero changes to existing call sites:
TELEGRAM_ALLOWED_USER_IDS(comma-separated) absorbs the legacy single var. Derivable from an access file'sallowFromso there's one source of truth.AsyncLocalStorageset inauthMiddlewaretags each update with its sender; the settings store resolves sessions through it (userSessions[userId]map, persisted). All ~70getCurrentSession()call sites become per-user transparently.Backward compatibility guarantee: a solo operator (allowlist length 1) adopts the legacy session on first scoped lookup — upgrading never orphans an existing conversation. Single-user deployments see zero behavior change.
Live-found pitfalls (from rolling this out)
compactOutputMode: true) branches carry their own inner session check; fixing only the outer guard restores half the behavior.POST /session/{id}/prompt_async, not/message— debugging against the wrong route 'works' while production fails.Happy to open a PR with our reference implementation if the shape interests you — otherwise this doc plus the design notes above may save the next shared-org user some pain.
Rollout addendum (live-deployed since posting)
Three more pitfalls surfaced while running this in production — each a silent failure with a healthy-looking process:
toolMessageBatcher.flushSession/toolCallStreamer.flushSessioninside permission/question handlers were keyed off the (now legacy) session pointer instead ofrequest.sessionID/ the event's own session id. Same class as feat: add option to hide subsessions in sessions list #4 — ownership-aware routing must extend to every session-keyed helper call, not just send targets.updateWaitingForPermission/updateThinkingmust attribute to the requesting session's own id, or secondary operators' status lands on the wrong lane's card.Also now implemented: a backward-compat seed — solo operators (allowlist length 1) adopt the legacy session on first scoped lookup, so upgrading never orphans an existing conversation. Net effect for existing single-user deployments is zero behavior change.
Reference implementation available on request.