Skip to content

Relay handoff answers home, show working channels, and open the app to the keyboard - #290

Merged
davidmckayv merged 17 commits into
mainfrom
guido/experience
Aug 31, 2026
Merged

Relay handoff answers home, show working channels, and open the app to the keyboard#290
davidmckayv merged 17 commits into
mainfrom
guido/experience

Conversation

@guidovizoso

Copy link
Copy Markdown
Collaborator

A batch of experience work: handoff answers stop landing in a second conversation, channels show when a Bot is working in them, open transcripts pick up turns that ran with no browser attached, first sign-in gets an onboarding wizard, and Shift+N starts a chat from anywhere.

Handoff answers come home

A forward hop used to answer in the addressed Bot's own channel with the person — two conversations for one question, and the answer landed somewhere they never asked anything. Now the hop runs in a scratch thread minted per hop, never mapped to a channel and never shown, and what the Bot said is relayed back into the conversation that asked, in the asking Bot's voice, attributed. The relay rides the same durable queue as the turn that produced it, answers are clipped so a Bot that returns a book cannot swamp the relaying run's prompt, and the answerIn marker that already stopped failure notices recursing stops a relay relaying.

Because a person now waits through every leg, offering work also fires pg_notify so a sweep starts immediately instead of at its next two-second poll. The poll stays as the backstop; a lost notification costs one interval, never the work.

Channels show work happening

A transient busy flag on the activity socket — announced, never persisted. The server signals it from the runtime's thread-lock acquire/release (covering headless hops and runs whose tab navigated away), and the browser reports its own turns over a new membership-checked POST /:channelId/busy. The roster badges the channel's avatar with three bouncing dots, patching only the busy field so the preview and ordering are untouched.

An open transcript also picks up turns nobody streamed: it watches the roster's own cache, and when a channel's lastMessageAt advances to a Bot-authored moment it re-reads durable history and appends messages by id — so a relayed answer appears without leaving and coming back.

Onboarding and keyboard

  • First sign-in is gated behind a per-user onboarding wizard.
  • Shift+N starts a new chat from anywhere in the signed-in app, the new-channel composer focuses its recipient picker on arrival, and a read-only shortcut list on settings is drawn from the same registry the listeners match against.

Verification

  • bun run typecheck, biome format and lint: clean.
  • Full suite (bun run test:ci) locally: all handoff unit and integration tests pass, including the new relay, batching, and event-patch cases. (The handoff integration tests share the queue's bot.message kind with a running dev server; locally they need the dev server stopped, which is expected.)

Two columns on users say where somebody is in first-run onboarding and
when they finished; /api/me carries the status and one POST moves it.
The app redirects an unfinished user to /onboarding — an animated
three-step wizard — and an address the build does not know now goes
home through the same gate instead of a bare 404.
# Conflicts:
#	bun.lock
#	server/drizzle/meta/0015_snapshot.json
#	server/drizzle/meta/_journal.json
#	server/src/app.ts
#	server/src/index.ts
… next poll

The handoff queue was swept every two seconds, and a person is waiting through
every hop, so each leg of a handoff cost up to a full interval doing nothing.
Offering work now fires pg_notify with the kind as payload, inside the offering
transaction where there is one so it fires on commit and never before, and any
replica can listen on a dedicated connection and kick its sweep immediately.

A notification is a latency optimisation, never a delivery mechanism: one lost
in transit costs up to one poll interval, not the work, and the queue's tables
stay the only truth.
A transient busy flag on the channel activity event, announced and never
written: busy is a moment, not a fact about the channel, and a missed signal
costs at most a stuck-looking dot until the next real event, never data.

Two ways in. The server signals by thread (signalBusy) for the runs it can see
— the runtime's lock acquire and release now carry an onRunBusy seam, so every
run the platform processes lights its channel, including one whose tab has
navigated away — and a scratch thread maps to no channel and signals nowhere,
which is the point of a scratch thread. The browser signals by channel
(POST /:channelId/busy) for the one thing the server cannot see, a person's own
turn beginning, with a membership check so belonging to a channel is not
something an outsider can probe for.
A forward hop used to answer in the addressed Bot's own channel with the
person: two conversations for one question, and the answer landed somewhere
they never asked anything. Now the hop runs in a scratch thread of the
addressed Bot's own — minted per hop, never mapped to a channel, never shown —
and what it said comes home through a second queued hop that has the asking
Bot relay the answer, attributed, in the conversation the person is watching.

The delivery gathers the Bot's words from the stream as it goes past, because
the runner publishes the turn to the platform and the events are the one
chance to hear it. The relay rides the same durable queue as the turn that
produced it, so a pod dying between the two loses the relay to a retry rather
than for ever; the answerIn marker that stops a failure notice recursing stops
a relay relaying. Answers are clipped at 12k characters so a Bot that comes
back with a book cannot swamp the relaying run's prompt, and a backwards hop
reads only the tail of the asking conversation so relaying never grows slower
with the channel.

Wired in index.ts: the scratch thread replaces the direct-channel answerIn,
the roster announcement resolves thread to channel and happens only when the
turn said something, the asking channel is lit while a forward hop runs, and
the queue's new offered-work notification kicks the sweep so a person is not
waiting out a poll interval per leg.
Three bouncing dots badged on the channel's avatar, driven by the busy flag on
the activity socket. Socket-only and transient on the summary type: the roster
query never returns it, so it is undefined until a busy event arrives and drops
whenever the roster refetches — the acceptable failure for a hint about a
moment. The event patcher flips only the busy field: the spread that serves
ordinary activity would carry the event's null message onto the row and wipe
the preview, and busy is not activity, so the row does not re-sort either.

The channel reports its own turns over the new busy endpoint, fire-and-forget,
and deliberately does not clear on unmount: a turn keeps running server-side
after the person leaves the channel, and the server clears it when the run's
lock is released.
A relayed handoff answer runs on the server and lands in the thread with no
browser attached; the transcript restored history once, on mount, and would
show the new turn only after leaving and coming back. The chat now watches the
roster's own channel-list cache — the sidebar updating and the transcript
refreshing are one signal and cannot drift apart — and when this channel's
lastMessageAt advances to a moment a Bot authored, the durable history is read
again and messages whose ids the transcript has never seen are appended.
Appended by id, not compared by length, because the stored read keeps only what
the platform can parse and can be shorter than the screen while still holding
the news. Retried briefly, since the roster is patched when the turn is on
record with the runner and the platform's read can be a beat behind.

The chat also reports its own turns to the busy endpoint, keyed on whether a
turn is in flight, so the roster's working dots cover the one run the server
cannot see begin.
A small hotkey registry, one place on purpose: the binding a listener matches
against and the combo the settings page shows are the same record, so the list
under Preferences is what the keys actually do rather than what somebody
remembered they did — each key drawn as its own keycap, symbols on a Mac and
names elsewhere. Matching is exact rather than at-least, so Shift+N does not
fire on Cmd+Shift+N and shadow whatever the browser means by it, and a plain
Shift+letter combo is left alone while the focus is anywhere editable.

Bound in _authed rather than _app, so a person on settings or admin can start
a chat without first clicking back into the app frame. The new-channel
composer's recipient picker takes focus on arrival, so Shift+N then typing a
name is one motion.
The wizard shipped unformatted; biome's own output, no hand edits.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed and driven on a real EKS deployment (single user, sandbox computers), not just read. The headline change works: I asked General Assistant to put a question to Knowledge and the answer came home into the asking conversation as "Knowledge replied: CORMORANT", attributed, under the ▸ Asked Knowledge marker. Confirmed in a fresh channel and in a 65-message one, and confirmed durable by reading the thread back from the platform rather than trusting the screen.

Also verified on the deployment: the busy badge appears on the roster avatar while a hop runs and clears afterwards; the roster bumps and previews the relayed answer; the onboarding gate redirects to /onboarding and holds against a direct navigation to /admin/audit; migration 0025 correctly stamps an existing user as onboarded, so an upgrade shows no wizard (checked in the database); and Shift+N works while correctly not firing from inside an input, which is the part that matters for a plain Shift+letter combo.

Suite green here too: 2067 tests, typecheck and lint clean.

What I would fix before merging

No changelog entry, and this reverses what 0.0.5 shipped saying. This is the one I would hold on. The released 0.0.5 notes say, in these words:

The asking Bot does not relay text on its behalf, so what you read is the answer that Bot actually gave rather than another Bot's summary of it.

This PR makes the asking Bot relay exactly that, as a summary, in its own voice. Somebody who read the 0.0.5 notes and upgrades gets the opposite of what they were told. There are five user-visible changes in here (relay, busy channels, transcript catch-up, onboarding, Shift+N) and no entry for any of them, so the fix is an Unreleased section that also corrects the 0.0.5 paragraph rather than leaving it standing.

app/src/routes/_authed/onboarding.tsx:104 has max-w-lgoverflow-hidden. Two classes run together, so neither max-w-lg nor overflow-hidden applies. It is visible: the roster cards render wider than every other step of the wizard.

Lower confidence, worth a look

  • Empty roster after finishing onboarding. I landed at / with no channels in the sidebar. The API had all five, and a reload restored them, so nothing was lost. Seen once and I could not cleanly re-run it, so a lead rather than a confirmed defect. invalidateCurrentUser only invalidates the current-user key, which may be the thread to pull.
  • One relay rendered truncated. In the long channel the transcript showed Knowledge: ALBAT and stayed that way, while the stored message was the complete Knowledge replied: ALBATROSS. Render only, and it did not reproduce in a fresh channel.
  • onboarding_step is currently dead weight. The column, the migration, setStep, the endpoint field and advanceOnboardingMutationOptions are all unused. onboarding.tsx:146 says that is deliberate while the wizard is being designed, which is fair, but people/onboarding.ts documents step as "where the wizard resumes if they left halfway through" and it never resumes. I watched it stay 0 through a full run. Either drop the claim from that comment or wire it up.
  • Placeholder agents are indistinguishable from real ones. "Support Agent" sat beside General Assistant and Knowledge in identical styling on a deployment that has no such Bot. The comment says the invented names illustrate a roster "without claiming any of these exist here", but nothing on screen marks them as illustrations, so it does read as a claim.

One note on my own testing

My browser harness cannot deliver a shift-modified keystroke (pressing it into a focused textarea typed nothing), so Shift+N looked broken twice before I dispatched a real KeyboardEvent and saw it navigate. Flagging it so nobody repeats the false alarm.

Design reading: relaying through the queue with answerIn doing double duty as the loop guard, the answer clipped, and a relay failure logged rather than failing the hop into a second turn, all look right to me. The scratch thread explanation for why the addressed Bot cannot speak in the asking thread is convincing, and the busy signal covering only the forward leg (because the backwards leg already lights through the thread lock) is the detail I would have got wrong.

@kevin9327

Copy link
Copy Markdown
Contributor

Reading the relay half of this, and the scratch thread makes a lot of sense — a leaked lock on a thread nobody is ever shown costs nothing. One thing about the backward leg I wanted to raise, because I think this change moves it from rare to common, and I would rather ask than assume I have read it right.

deliver takes the run lock at server/src/agents/handoff-delivery.ts:261 and gives it back in the finally at :395. The history read at :296 sits between the two: the outer try/finally added here (:253 / :412) pairs setBusy, not the lock. And copilotRuntime.history goes through historyOrEmpty (copilot.ts:1222), which answers a missing thread with nothing and rethrows everything else on purpose — a 500 is an outage or a bad key, not an empty conversation.

For a forward hop that costs nothing, as above: where.threadId is the scratch thread.

For a relay it is the conversation the person is sitting in. relay() sets both threadId and answerIn to work.threadId (handoff-runner.ts:169, :172), so the lock is taken on the asking conversation and the read that can throw is a read of that same conversation. If the platform answers it with a 500:

  • the lock is held for THREAD_LOCK_TTL_SECONDS = 120 (copilot.ts:993) with the heartbeat never armed, so the person cannot start a run in their own conversation for two minutes;
  • the retry 60 s later (handoff-runner.ts:448) collides with the lock the hop is still holding itself, gets null from acquire, throws "busy", and spends an attempt on it;
  • and a relay that runs out of attempts sends no notice — :430 gates tell on !work.answerIn — so the answer never arrives and nothing says why.

Before this PR the answerIn leg was only a failure notice, which is rare. After it, every answered hop relays.

Would hoisting the read above the acquire be reasonable here? It reads a conversation the lock was not protecting anyway, and it would shorten the window on the happy path too. I had put the same reordering up against main as #305 before I read this one, so it is easy either way — glad to close that if you would rather carry it here, and equally glad to be told I have misread the flow.

The history read is the one call in a delivery that throws on a platform
error, and it sat after the lock was acquired but before the try whose finally
gives the lock back: a 500 from the platform leaked the lock for its full TTL.
On a forward hop that lock is a scratch thread and the leak costs nothing; on
a relay it is the asking conversation itself, so the person could not type for
two minutes, the retry a minute later collided with the hop's own leftover
hold and spent an attempt on it, and a relay that ran out of attempts vanished
without a notice.

The read is of the conversation that asked, which the lock — taken on the
conversation being answered in — never protected. Hoisted above the acquire it
fails before anything is held, and the happy path holds the lock for less of
the turn too. Raised by kevin9327 on #290, who had the same reordering up as
#305 against main; a test now pins the ordering.
Finishing the wizard navigates straight into the app, and the channel list —
cached from before the wizard, or from a fetch that ran while it held the
screen — could greet the person with an empty sidebar their reload then fixed.
Seen once on a real deployment during review of #290 and not cleanly
reproduced, so this is a hardening of the seam rather than a confirmed fix:
both invalidations use refetchType "all" for the same reason, nothing is
observing these queries while the wizard is up.
Two review findings on the wizard's roster step. The invented agents that top
up a sparse deployment rendered identically to real ones, so "Support Agent"
read as a Bot the deployment has; they are dimmed and labelled Example now.
And max-w-lg had run together with overflow-hidden into a class that applied
neither, which is why the roster cards drew wider than every other step.

Also stops the onboarding store's comment promising that step is where the
wizard resumes: it is stored and served but nothing resumes from it yet, and
the comment now says so instead of describing a behaviour that does not
exist.
…stance

Five user-visible changes were on this branch with no entry: the relay, the
working indicator, the transcript catch-up, the onboarding wizard, and
Shift+N. Worse, 0.0.5's notes promised the asking Bot does not relay text on
the addressed Bot's behalf, and this branch makes it do exactly that — so
somebody upgrading off those notes got the opposite of what they were told.
The Unreleased section now names the reversal outright, and the 0.0.5
paragraph carries a pointer forward rather than standing uncorrected.
@guidovizoso

Copy link
Copy Markdown
Collaborator Author

@kevin9327 You read it right, and it was in fact one notch worse than "heartbeat never armed": the read sat after acquire but above the try whose finally releases, so a throwing read did not just hold the lock quietly — it leaked it outright until the TTL, with exactly the retry-collision and silent-exhaustion consequences you describe. On a relay that lock is the person's own conversation, so agreed that this PR moved the blast radius from rare to common.

Carried the hoist here as 56700ad: the history read now runs before acquire — it reads the asking conversation, which the lock (taken on the conversation being answered in) never protected — and a test pins the ordering by asserting a throwing read takes no lock at all. Feel free to close #305, and thanks for putting the failure chain together so precisely; the silent exhaustion of a relay (tell gated on !answerIn) is a real remaining gap that this makes much harder to hit, but I'd take a follow-up that gives an exhausted relay a voice.

@guidovizoso

Copy link
Copy Markdown
Collaborator Author

@davidmckayv Thanks for driving it on a real deployment — the busy-badge and migration checks especially. All addressed as of a15b934:

The two holds:

  • Changelog (a15b934): an Unreleased section now covers all five user-visible changes, leads with the relay entry saying outright that this reverses what 0.0.5 shipped, and the 0.0.5 paragraph itself carries a pointer forward ("Reversed since: see Unreleased") rather than standing uncorrected.
  • max-w-lgoverflow-hidden (1fe4cd9): unstuck into the two classes it was meant to be; the roster cards line up with the other steps again.

The leads:

  • Empty roster after finishing (9bc7d11): completing onboarding now invalidates the channel list alongside the current user, refetchType: "all" for the same nothing-is-observing reason. Committed as hardening of the seam rather than a confirmed fix, and the commit says so.
  • onboarding_step dead weight (1fe4cd9): took the honest half for now — the store's comment no longer promises a resume that doesn't happen and says the column exists so resuming stays a frontend change. Wiring it up belongs to whatever the wizard settles into.
  • Placeholder agents (1fe4cd9): dimmed and labelled Example, so an invented name no longer reads as a Bot the deployment has.
  • The truncated relay render (Knowledge: ALBAT): leaving open — render-only, stored message complete, and no repro here either. The transcript catch-up appends by id from the durable read, so my best guess is a race with an in-flight streamed render rather than the append itself; will watch for it.

Also carried kevin9327's lock-ordering point in 56700ad with a pinning test. Suite, typecheck and lint green here including the new test.

guidovizoso and others added 2 commits August 31, 2026 14:38
`startWorkOfferedListener` holds a Postgres connection for the life of the process and returns a
`stop()` for giving it back, and the return value was dropped on the floor. The shutdown beside it
stops the two listeners either side and says why in its own comment: "so a watch-mode restart does
not leave two behind on every reload". This one was the third, and it was the one not stopped, so
every reload of `bun run dev` on a deployment with handing work between Bots switched on left a
connection behind.

Held at module scope because the listener is started inside the gate that may never run, and the
shutdown is at the top level: undefined there means a deployment with the capability off, which
never started one.

Checked rather than assumed: `pg_stat_activity` goes from one row to two when the listener starts
and back to one after `stop()`.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 5e40b6b. Everything I raised is addressed, and the changelog fix went further than I asked: it says plainly what the new behaviour costs ("the asking Bot's account of the answer rather than the answer verbatim; very long answers are clipped"), and it annotates the standing 0.0.5 paragraph in place rather than leaving a released note contradicting the code. That is the right shape.

Checked each one:

  • Changelog. Four entries in Unreleased, and the 0.0.5 paragraph now carries a forward pointer.
  • max-w-lgoverflow-hidden. Now two classes.
  • Placeholder agents. Dimmed and labelled Example, so an invented name cannot read as a Bot the deployment has.
  • Empty roster after onboarding. completeOnboardingMutationOptions now invalidates channelKeys.list() alongside the user, with refetchType: "all" for the same reason. The comment describes the exact symptom I hit.
  • onboarding_step. Not wired, which is fine, and the store comment now says so outright instead of claiming the wizard resumes. That was the actual defect: the claim, not the column.

On the lock-ordering fix

Good catch by @kevin9327, and the fix is right. I confirmed the test pins it: reverting just the hoist fails a history read that fails > takes no lock, so nothing is leaked for the retry to collide with, and it passes with it.

One thing to watch when this and #305 both land. #305 is still open against main with the same fix, touching the same three files, but it hoists the read outside the try, while this PR hoists it to the top of the try. Against main that difference is invisible. Against this branch it is not: this PR adds setBusy(true) before the try and clears it in the outer finally, so a read that throws from outside that try would leave the asking channel lit as busy with nothing to turn it off. The placement here is the correct one for this branch.

So if #305 merges to main first, the rebase conflict in handoff-delivery.ts needs resolving toward this branch's placement (read inside the try), not toward #305's. Both PRs also touch CHANGELOG.md, so expect a conflict there too.

Pushed one fix

startWorkOfferedListener holds a Postgres connection for the life of the process and returns a stop() for giving it back, and the return value was dropped. The shutdown handler a few hundred lines below stops the two listeners either side of it and says why in its own comment: "so a watch-mode restart does not leave two behind on every reload". This was the third listener and the one not stopped, so every reload of bun run dev with handing work between Bots switched on left a connection behind. Pushed as 61e2065: held at module scope because the listener starts inside a gate that may never run.

Checked rather than assumed: pg_stat_activity goes from one row to two when the listener starts and back to one after stop().

What I checked

Re-deployed to a real single-user EKS cluster and drove it:

  • The relay still works after the lock hoist: "Knowledge replied: KESTREL" landed in the asking conversation.
  • The roster does repopulate after finishing the wizard. Worth saying plainly because I nearly filed this as still broken: my first screenshot after "Get started" showed an empty sidebar and the channels only appeared on a later frame. It arrives, just not instantly.
  • The wizard's example labelling and the class fix both render as intended: cards constrained, "Support Agent" dimmed with an Example line under it, the two real Bots not.

Also ran here: typecheck, biome format and lint clean; full suite 2113 tests, 0 fail once the local dev worker is stopped (the bot.message queue collision the PR body warns about, so that note is worth keeping).

The framing existed only in a code comment ("OpenBot exists to be forked"), which is the wrong place
for the first thing somebody needs to know: that there is nothing to sign up for, nothing to install
as a dependency, and that the example tenant package is a worked example to replace rather than a
default to keep. Read without it, "Quick start" looks like the way you use OpenBot rather than the
way you try the starting point before making it yours.

Placed above the alpha and laptop callouts because it frames both.
@davidmckayv

Copy link
Copy Markdown
Contributor

Also added a README callout, since this came up separately: the repository never said it is a template to clone. That framing only existed in a code comment in bot.tsx ("OpenBot exists to be forked"), which is the wrong place for the first thing somebody needs to know.

It now sits above the alpha and laptop callouts, because it frames both: there is no hosted version to sign up for, nothing is published as a package to depend on (every workspace here is private: true, which I checked before writing it), and the example tenant package under examples/ is a worked example to replace rather than a default to keep.

Without it, "Quick start" reads as the way you use OpenBot rather than the way you try the starting point before making it yours.

…om the README

Reported as hard to set up even with an assistant helping, and the reason is legible in
`.env.example`: it ships ten empty keys, and only three of them are a person's to fill. The start
script defaults two and generates the third, so an assistant reading the file alone will walk
somebody through seven values that fill themselves.

`prompt.txt` says which three, what each start-up refusal means in the words the server actually
prints, and what an assistant must not do on somebody's behalf: run the browser sign-in, echo their
keys back, or change the Intelligence URLs, which are correct as shipped and not self-serve to
replace.

Every claim in it was checked against this repository rather than written from memory: the pinned
Bun version, the port defaults, the five refusal messages quoted verbatim, and that
TENANT_PACKAGE_DIR is relative to `server/` rather than the root, which is why the default carries
a `../` that is easy to drop.
@davidmckayv

Copy link
Copy Markdown
Contributor

Added prompt.txt at the top level and linked it from Quick start, after a report that OpenBot is hard to set up even with an AI helping.

The reason is visible in .env.example: it ships ten empty keys, and only three are a person's to fill. scripts/start.sh defaults COMPUTER_TOKEN and WORKER_SHARED_SECRET and generates AGENT_TOOL_TOKEN back into .env. An assistant reading the file on its own has no way to know that, so it walks somebody through seven values that fill themselves.

So the prompt names the three (INTELLIGENCE_API_KEY, COPILOTKIT_LICENSE_TOKEN, OPENAI_API_KEY), quotes each start-up refusal in the words the server actually prints, and says what an assistant must not do on somebody's behalf: run the browser sign-in, echo their keys back, or touch the Intelligence URLs.

Checked against the repository rather than written from memory: the pinned Bun version, the port defaults, all five refusal messages verbatim, and that TENANT_PACKAGE_DIR is relative to server/ rather than the root, which is why the default carries a ../ that is easy to drop.

@guidovizoso
guidovizoso enabled auto-merge (squash) August 31, 2026 21:46
@guidovizoso

Copy link
Copy Markdown
Collaborator Author

All checks are green and auto-merge (squash) is armed. Branch protection needs one approving review from someone other than @davidmckayv (last pusher — his three follow-up commits, including the wake-up listener shutdown fix, all look right to me) and other than me as author. First approval merges it.

@davidmckayv
davidmckayv merged commit fbbe2c6 into main Aug 31, 2026
13 checks passed
@davidmckayv
davidmckayv deleted the guido/experience branch August 31, 2026 21:59
guidovizoso added a commit that referenced this pull request Sep 1, 2026
* Gate first sign-in behind a per-user onboarding wizard

Two columns on users say where somebody is in first-run onboarding and
when they finished; /api/me carries the status and one POST moves it.
The app redirects an unfinished user to /onboarding — an animated
three-step wizard — and an address the build does not know now goes
home through the same gate instead of a bare 404.

* Say work is offered aloud, so a sweep can start now instead of at its next poll

The handoff queue was swept every two seconds, and a person is waiting through
every hop, so each leg of a handoff cost up to a full interval doing nothing.
Offering work now fires pg_notify with the kind as payload, inside the offering
transaction where there is one so it fires on commit and never before, and any
replica can listen on a dedicated connection and kick its sweep immediately.

A notification is a latency optimisation, never a delivery mechanism: one lost
in transit costs up to one poll interval, not the work, and the queue's tables
stay the only truth.

* Let a channel tell its members a turn is running in it

A transient busy flag on the channel activity event, announced and never
written: busy is a moment, not a fact about the channel, and a missed signal
costs at most a stuck-looking dot until the next real event, never data.

Two ways in. The server signals by thread (signalBusy) for the runs it can see
— the runtime's lock acquire and release now carry an onRunBusy seam, so every
run the platform processes lights its channel, including one whose tab has
navigated away — and a scratch thread maps to no channel and signals nowhere,
which is the point of a scratch thread. The browser signals by channel
(POST /:channelId/busy) for the one thing the server cannot see, a person's own
turn beginning, with a membership check so belonging to a channel is not
something an outsider can probe for.

* Relay a handoff answer back into the conversation that asked

A forward hop used to answer in the addressed Bot's own channel with the
person: two conversations for one question, and the answer landed somewhere
they never asked anything. Now the hop runs in a scratch thread of the
addressed Bot's own — minted per hop, never mapped to a channel, never shown —
and what it said comes home through a second queued hop that has the asking
Bot relay the answer, attributed, in the conversation the person is watching.

The delivery gathers the Bot's words from the stream as it goes past, because
the runner publishes the turn to the platform and the events are the one
chance to hear it. The relay rides the same durable queue as the turn that
produced it, so a pod dying between the two loses the relay to a retry rather
than for ever; the answerIn marker that stops a failure notice recursing stops
a relay relaying. Answers are clipped at 12k characters so a Bot that comes
back with a book cannot swamp the relaying run's prompt, and a backwards hop
reads only the tail of the asking conversation so relaying never grows slower
with the channel.

Wired in index.ts: the scratch thread replaces the direct-channel answerIn,
the roster announcement resolves thread to channel and happens only when the
turn said something, the asking channel is lit while a forward hop runs, and
the queue's new offered-work notification kicks the sweep so a person is not
waiting out a poll interval per leg.

* Show a channel working on the roster while a turn runs in it

Three bouncing dots badged on the channel's avatar, driven by the busy flag on
the activity socket. Socket-only and transient on the summary type: the roster
query never returns it, so it is undefined until a busy event arrives and drops
whenever the roster refetches — the acceptable failure for a hint about a
moment. The event patcher flips only the busy field: the spread that serves
ordinary activity would carry the event's null message onto the row and wipe
the preview, and busy is not activity, so the row does not re-sort either.

The channel reports its own turns over the new busy endpoint, fire-and-forget,
and deliberately does not clear on unmount: a turn keeps running server-side
after the person leaves the channel, and the server clears it when the run's
lock is released.

* Keep an open channel current with turns nobody here streamed

A relayed handoff answer runs on the server and lands in the thread with no
browser attached; the transcript restored history once, on mount, and would
show the new turn only after leaving and coming back. The chat now watches the
roster's own channel-list cache — the sidebar updating and the transcript
refreshing are one signal and cannot drift apart — and when this channel's
lastMessageAt advances to a moment a Bot authored, the durable history is read
again and messages whose ids the transcript has never seen are appended.
Appended by id, not compared by length, because the stored read keeps only what
the platform can parse and can be shorter than the screen while still holding
the news. Retried briefly, since the roster is patched when the turn is on
record with the runner and the platform's read can be a beat behind.

The chat also reports its own turns to the busy endpoint, keyed on whether a
turn is in flight, so the roster's working dots cover the one run the server
cannot see begin.

* Start a new chat from anywhere with Shift+N

A small hotkey registry, one place on purpose: the binding a listener matches
against and the combo the settings page shows are the same record, so the list
under Preferences is what the keys actually do rather than what somebody
remembered they did — each key drawn as its own keycap, symbols on a Mac and
names elsewhere. Matching is exact rather than at-least, so Shift+N does not
fire on Cmd+Shift+N and shadow whatever the browser means by it, and a plain
Shift+letter combo is left alone while the focus is anywhere editable.

Bound in _authed rather than _app, so a person on settings or admin can start
a chat without first clicking back into the app frame. The new-channel
composer's recipient picker takes focus on arrival, so Shift+N then typing a
name is one motion.

* Format the onboarding route

The wizard shipped unformatted; biome's own output, no hand edits.

* Say whether this deployment can make a built-in coworker

A coworker created with no endpoint runs on the deployment's managed Bot, and
a deployment without one refuses the create — after somebody has already
filled in the form. GET /api/agents/capabilities states it up front, static
per process because it is configuration rather than data, and registered above
the parameterised route so "capabilities" can never be read as an agent id.
The browser caches it forever for the same reason, through a new
agentCapabilitiesQueryOptions.

* Create a coworker in a multi-step questionnaire dialog

Three steps rather than one form: who it is (name, title, role), who can see
it, and where it runs — the fork where a built-in coworker needs nothing more
and a managed one reveals the endpoint and key fields. Built on the shadcn
questionnaire primitives for the choice cards and fieldset semantics, with
navigation driven by this dialog's own Continue/Back: the primitive's submit
path refuses items it does not consider answered and cannot see these fields.
Panes slide like onboarding — popLayout with a measured, height-following
frame whose 'relative' is what keeps the exiting pane clipped inside it.

Each step validates its slice of the shared form schema on Continue, and the
Built-in card is shown but disabled, with the reason, on a deployment whose
capabilities say it cannot back one. The visibility cards in AgentFields move
to the same radio-card idiom, on a radio-group primitive added for it.

* Show a coworker in a sectioned dialog with a sidebar of its own

The profile carries four distinct concerns — who it is, where it runs, what it
may hand work to, and what can be done to it — and the side panel stacked them
into one column that buried the later ones. Each is a section now, behind a
dialog-internal sidebar headed by the coworker's avatar and name.

General leads with name, title, role and visibility as items that edit in
place: Edit opens that field and that field alone, validated against the same
limits the server enforces, and visibility writes on pick because two named
choices leave no draft worth holding. Below them, the shortcuts — start
channel, duplicate, delete — each as an item with its button. Deleting
confirms in a dialog stacked over this one, with the name in the question and
a backdrop of its own; DialogContent grows overlayClassName for it, which also
forces the backdrop to exist, because Base UI skips backdrops on nested
dialogs and a stacked dialog is precisely the caller asking for one.

* Open coworkers in dialogs from the agents screen

The roster keeps both states in the URL — ?new opens the multi-step create
dialog, ?agent=<id> the coworker's own — so Back still closes them and a link
still lands on them. The DetailPanel goes, and with it the old NewAgent pane
it slid in; the channel screen's use of the side-panel profile is untouched.
The card grids also stop stretching their tracks: auto-fill over the card's
own width, so the gutter is the gutter rather than whatever 1fr left over.

* Sort two files' imports the way biome's assist asks

* Read the asking conversation before taking the run's lock

The history read is the one call in a delivery that throws on a platform
error, and it sat after the lock was acquired but before the try whose finally
gives the lock back: a 500 from the platform leaked the lock for its full TTL.
On a forward hop that lock is a scratch thread and the leak costs nothing; on
a relay it is the asking conversation itself, so the person could not type for
two minutes, the retry a minute later collided with the hop's own leftover
hold and spent an attempt on it, and a relay that ran out of attempts vanished
without a notice.

The read is of the conversation that asked, which the lock — taken on the
conversation being answered in — never protected. Hoisted above the acquire it
fails before anything is held, and the happy path holds the lock for less of
the turn too. Raised by kevin9327 on #290, who had the same reordering up as
#305 against main; a test now pins the ordering.

* Refetch the roster as well as the user when onboarding completes

Finishing the wizard navigates straight into the app, and the channel list —
cached from before the wizard, or from a fetch that ran while it held the
screen — could greet the person with an empty sidebar their reload then fixed.
Seen once on a real deployment during review of #290 and not cleanly
reproduced, so this is a hardening of the seam rather than a confirmed fix:
both invalidations use refetchType "all" for the same reason, nothing is
observing these queries while the wizard is up.

* Say which wizard agents are examples, and unstick its two classes

Two review findings on the wizard's roster step. The invented agents that top
up a sparse deployment rendered identically to real ones, so "Support Agent"
read as a Bot the deployment has; they are dimmed and labelled Example now.
And max-w-lg had run together with overflow-hidden into a class that applied
neither, which is why the roster cards drew wider than every other step.

Also stops the onboarding store's comment promising that step is where the
wizard resumes: it is stored and served but nothing resumes from it yet, and
the comment now says so instead of describing a behaviour that does not
exist.

* Say in the changelog what changed, including reversing 0.0.5's relay stance

Five user-visible changes were on this branch with no entry: the relay, the
working indicator, the transcript catch-up, the onboarding wizard, and
Shift+N. Worse, 0.0.5's notes promised the asking Bot does not relay text on
the addressed Bot's behalf, and this branch makes it do exactly that — so
somebody upgrading off those notes got the opposite of what they were told.
The Unreleased section now names the reversal outright, and the 0.0.5
paragraph carries a pointer forward rather than standing uncorrected.

* Say whether a coworker runs on the deployment's own Bot, and whether it may hand work on

The agent DTO gains builtIn, computed by comparing the stored endpoint
against the managed endpoint the server was configured with, and the
handoff answer gains grantable, asked of the plugin store's own notion
of where an agent runs. Both exist so screens can stop offering
controls the server would only refuse: a built-in coworker was being
nagged for a callback token it will never need, and a remote coworker
was offered handoff switches that bounced.

The endpoint comparison guards on typeof string because two undefineds
compare equal, which quietly made every endpoint-less stub built-in.

* Show routines per coworker, as rows that wear their state

The routine DTO now names its agent, so the list can be scoped to one
coworker's dialog while the Routines page keeps showing them all — one
owner-scoped query either way, the scope a filter rather than a second
endpoint.

The row itself is rebuilt as a muted item whose footer is a set of
chips: the channel it posts to (a link, since it is a place), the last
run with a colored dot carrying the tone, and either the next run,
Paused when switched off, or a pulsing Due when the stamp is already in
the past — which used to render as the nonsense 'Next 5 hours ago'.
Empty states go through the shared Empty component.

* Rebuild the handoff panel on items, and say once when granting is impossible

Each candidate Bot is a muted item with its avatar and a switch, under
a header that answers 'how many of them' at a glance. A coworker that
cannot be granted the handoff tool — it runs as its own agent, outside
this deployment's loop — gets one explanation item instead of a column
of switches that can only bounce off the server's refusal; its stale
grants stay visible so they can still be revoked.

* Grow the coworker dialog: access, routines, a truthful connection tab, and a phone strip

The dialog gains an Access section (which connectors and skills this
coworker has been granted, grouped from its plugin refs), a Routines
section (the scoped list, replacing the global sidebar entry — the
/routines route still answers direct links), and a Connection tab that
tells a built-in coworker the truth: it runs on this deployment's own
Bot, nothing to connect and nothing to authenticate, instead of nagging
for a callback token.

Below the md breakpoint the sidebar gave way to nothing and most
sections were unreachable; a scrollable strip of section buttons now
takes its place, with the coworker's name above it and room left for
the close button.

* Slim the channel's coworker panel to a card that opens the dialog

The panel beside a conversation answers 'who am I talking to' — avatar,
name, role, two buttons: start a new channel with this coworker, or
open the management dialog. It used to duplicate the dialog's whole
surface (edit form, tokens, grants, delete), which was two places to
maintain and a sidebar that scrolled past the conversation it sat
beside.

agent-fields and the radio-group primitive go with it: the edit form
was their last caller, and the create wizard draws its own choices.

* Say in the changelog what the dialogs change for the person running OpenBot

* Let the handoff explanations finish their sentence

`ItemDescription` clamps to two lines, which is right for a roster row whose description is a
subtitle and wrong for an item whose whole job is to explain. Both explanations here run to three,
and in each case the line that gets cut is the useful one: "It can still be asked by Bots that can"
is exactly what a person reading "this coworker cannot hand work on" needs next, and it was
invisible on screen while sitting in the DOM.

Seen on a deployment rather than in the file: the text reads complete in the accessibility tree and
truncated with an ellipsis in the browser.

---------

Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
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.

3 participants