Skip to content
Draft
167 changes: 167 additions & 0 deletions .bb/workflows/multiplayer-wave-1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
export const meta = {
name: "multiplayer-wave-1",
description:
"Wave 1: connect gate membership (WS1) and local server claimed-identity plumbing (WS2) in parallel",
phases: [
{
title: "Implement",
detail: "WS1 connect cloud and WS2 server identity, disjoint ownership",
},
],
};

const shared = `
You are one of two parallel workers implementing "multiplayer bb" wave 1 in a
SHARED working tree on branch bb/multiplayer (wave 0 is commit 26722ea2d,
already in your workspace). Another worker is editing a disjoint set of files
concurrently — touch ONLY the files listed under YOUR OWNERSHIP, and scope all
validation commands with turbo --filter so you never run the other package's
suite.

Design invariants (decided, do not relitigate):
- Identity is CLAIMED-ONLY. Clients self-assert identity via the
x-bb-claimed-identity header (see @bb/domain claimed-identity module).
Admission gateways never assert identity into product data.
- The bb server never AUTHORIZES by identity, only attributes. Admission is
enforced entirely at the connect gate (or network boundary).
- Membership is admission-only and owner-managed. The connect audit log is the
system's only verified access record.
- Follow AGENTS.md at the repo root (parse at boundaries, no accepted-but-
ignored fields, no unnecessary optionality, no db mocking in tests).

Rules: no new dependencies; no git commits or git add (leave all changes in
the working tree); pipe slow command output to /tmp files and read the file.
Your final message is a machine-consumed report: outcome, files changed (paths),
key API surfaces, checks run with actual results, blockers/assumptions.
`;

const ws1Prompt = `${shared}
YOUR OWNERSHIP: apps/connect/** only. Do NOT modify packages/connect-db (its
server_member schema + migration 0006 already landed), apps/server,
packages/db, packages/domain.

Objective: make the connect gate admit invited members and give owners a
member-management API.

Wave 0 already provides in packages/connect-db/src/schema.ts: serverMember
(server_id, user_id, added_by_user_id, created_at; PK (server_id,user_id);
index server_member_user_id_idx) exported in the schema map, plus the existing
auditLog table.

Tasks:
1. Gate admission — apps/connect/src/worker.ts (the owner check around lines
405-445 that returns 403 "not your server"): when the session user is not
the server owner, admit them if a server_member row exists for (serverId,
sessionUserId). The desktop-credential path stays owner-only. Inspect
apps/connect/src/session.ts / cache.ts for how label->server resolution and
caching work and keep the membership check consistent with those patterns
(a per-request D1 query is acceptable if that matches existing style).
2. Admission audit — on member admission, append an audit_log row (action
"member-admitted", userId = member, detail JSON with serverId and
subdomain). Debounce so steady-state traffic does not write a row per
request: an in-memory per-isolate map with a ~15-minute window is fine;
at-least-once duplicates across isolate restarts are acceptable. This log
is the system's ONLY verified access record, so make sure the write path
actually lands.
3. Member management API — following the existing worker API conventions (see
how apps/connect/src/servers.ts routes are registered in worker.ts), add
owner-session-authenticated endpoints:
- GET /api/servers/:serverId/members -> array of { userId, handle, name,
image, addedByUserId, createdAt }
- POST /api/servers/:serverId/members with { handle } -> resolve
profile.handle to a user (match existing handle normalization), insert
server_member with addedByUserId = owner; 404 unknown handle, 409 already
a member, 400 if the handle is the owner's own.
- DELETE /api/servers/:serverId/members/:userId -> remove; 404 if absent.
All three return 403 unless the session user owns the server. Add/remove
write audit rows ("member-added" / "member-removed").
4. Tests — colocated *.test.ts next to the code (follow worker.test.ts /
servers.test.ts harness style): member session admitted through the gate,
non-member still 403, owner-only member CRUD (each authz failure), unknown
handle 404, duplicate 409, owner-handle 400, audit rows written.

Validation: pnpm exec turbo run typecheck --filter=@bb/connect and
pnpm exec turbo run test --filter=@bb/connect (pipe to /tmp, read results).
`;

const ws2Prompt = `${shared}
YOUR OWNERSHIP: apps/server/** (except apps/server/src/ws/hub.ts — see below)
plus a NEW file packages/db/src/data/collaborators.ts and its export wiring
and tests. Do NOT modify: packages/db/src/schema.ts, packages/db/src/migrate.ts,
packages/domain/**, apps/connect/**.

Objective: plumb claimed identity through the local server — every API request
and websocket connection resolves to an actor, collaborators are recorded, and
sockets expose their actor for the upcoming presence work.

Wave 0 already provides:
- @bb/domain claimed-identity module: CLAIMED_IDENTITY_HEADER
("x-bb-claimed-identity"), ClaimedIdentity { handle, displayName,
imageUrl: string|null, clientId }, decodeClaimedIdentityHeader(value) ->
identity-with-normalized-handle or null, normalizeHandle,
encodeClaimedIdentityHeader.
- @bb/db collaborators table: handle (PK), display_name, image_url,
first_seen_at, last_seen_at.
- The ws client protocol already tolerates a "typing" message as a no-op.

Tasks:
1. packages/db/src/data/collaborators.ts (new; follow the style of existing
modules in packages/db/src/data/): upsertCollaborator(db, { handle,
displayName, imageUrl }, now) inserting (firstSeenAt = lastSeenAt = now) or
updating display fields + lastSeenAt; getCollaborator(db, handle);
listCollaborators(db). Wire exports the same way sibling data modules are
exported.
2. Request actor resolution in apps/server: a small typed module (place it
where server services conventionally live) that resolves an actor from
request headers via decodeClaimedIdentityHeader, falling back to a default
local-operator identity built once at startup: handle =
normalizeHandle(os.userInfo().username) (fallback "local"), displayName =
the OS username or hostname, imageUrl null, clientId "local". Wire it into
the /api/v1 request path following how apps/server/src/server.ts currently
mounts public routes and passes deps/context into handlers — minimal,
typed, no "as" casts. Attribution consumers land in a later wave; this wave
only needs the actor resolvable per-request and the collaborator recorded.
3. Collaborator recording: upsert via the new data module whenever a request
or ws connection resolves an actor, with an in-memory debounce keyed by
handle (skip the write when displayName/imageUrl are unchanged and the last
write was under 60 seconds ago).
4. Socket actors: resolve the actor from the upgrade request at the GET /ws
accept site in apps/server/src/server.ts and record the socket->actor
association in a new module apps/server/src/ws/socket-actors.ts (register
on open, release on close — the close path runs through
onClientSocketClose in apps/server/src/ws/client-protocol.ts; wire
minimally). Expose getSocketActor(socket). Do NOT modify hub.ts — presence
lands later and will consume getSocketActor.
5. Tests: packages/db test for upsert semantics using in-memory sqlite
(createConnection(":memory:") + migrate(db); never mock the db). apps/server
tests via the existing harness patterns: header -> actor (valid, malformed
-> local default), normalization collision ("Sawyer " and "sawyer" are one
collaborator), debounce skip, socket-actors register/release (unit-test the
module directly if the harness lacks ws coverage).

Validation: pnpm exec turbo run typecheck --filter=@bb/server --filter=@bb/db
then pnpm exec turbo run test --filter=@bb/db and --filter=@bb/server, piped
to /tmp files (the server suite is slow — read the file, do not stream). Two
KNOWN-FLAKY server tests unrelated to you: the install-machine-script 404
fallback test and the public-host-management revoke-machine timeout — if they
fail, rerun that file in isolation before treating it as your regression.
`;

phase("Implement");
const results = await parallel([
() =>
agent(ws1Prompt, {
label: "WS1 connect gate + members",
provider: "codex",
model: "gpt-5.6-sol",
reasoningLevel: "high",
}),
() =>
agent(ws2Prompt, {
label: "WS2 server claimed identity",
provider: "codex",
model: "gpt-5.6-sol",
reasoningLevel: "high",
}),
]);
return { ws1: results[0], ws2: results[1] };
180 changes: 180 additions & 0 deletions .bb/workflows/multiplayer-wave-2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
export const meta = {
name: "multiplayer-wave-2",
description:
"Wave 2: attribution + agent-visible speakers (WS3) and hub-derived presence (WS4) in parallel",
phases: [
{
title: "Implement",
detail: "WS3 attribution and WS4 presence, disjoint ownership",
},
],
};

const shared = `
You are one of two parallel workers implementing "multiplayer bb" wave 2 in a
SHARED working tree on branch bb/multiplayer. Waves 0-1 are committed
(26722ea2d, 9571629e3) and already in your workspace. Another worker is
editing a disjoint set of files concurrently — touch ONLY files under YOUR
OWNERSHIP and scope validation with turbo --filter.

Landed foundation you build on:
- @bb/domain claimed-identity: ClaimedIdentity { handle, displayName,
imageUrl: string|null, clientId }; identity is claimed-only and the server
never authorizes by it, only attributes.
- @bb/domain change-kinds: typingMessageSchema (inbound client ws message),
presenceViewerSchema, threadPresenceMessageSchema,
presenceSummaryMessageSchema (+ lenient counterparts).
- @bb/db: collaborators table + data module; nullable attribution columns
events.actor_handle, threads.created_by_handle,
queued_thread_messages.actor_handle, pending_interactions.resolved_by_handle
(all plain text, NULL = not human-initiated / pre-multiplayer).
- apps/server: every /api/v1 request resolves an actor — use
getRequestActor(context) from apps/server/src/services/actors.ts. Every
client ws socket has an actor — getSocketActor(socket) from
apps/server/src/ws/socket-actors.ts.

Rules: follow AGENTS.md at the repo root; no new dependencies; no git commits
or git add; pipe slow command output to /tmp files and read the file. Two
KNOWN-FLAKY server tests unrelated to this work: the install-machine-script
404-fallback test and the public-host-management revoke-machine timeout — if
one fails, rerun that file in isolation before treating it as your regression.
Your final message is a machine-consumed report: outcome, files changed,
key API surfaces/behavior, checks run with actual results, blockers.
`;

const ws3Prompt = `${shared}
YOUR OWNERSHIP: apps/server/src/routes/threads/**, apps/server/src/services/
threads/**, packages/db/src/data/** (events/threads/queued-thread-messages/
pending-interactions modules), packages/host-daemon-contract/**,
apps/host-daemon/**, and their tests. Do NOT modify: apps/server/src/server.ts,
apps/server/src/ws/**, packages/server-contract/** (the other worker owns
those this wave), packages/db/src/schema.ts, packages/db/src/migrate.ts,
packages/domain/**, apps/connect/**.

Objective: attribution end to end — every human-initiated action records its
actor, and the agent can see who is speaking in multi-human threads.

Tasks:
1. Message sends: POST /threads/:id/send (apps/server/src/routes/threads/
actions.ts, the send handler) resolves the actor via getRequestActor and
threads it through sendThreadMessage (apps/server/src/services/threads/
thread-send.ts) so the client/turn/requested events row is written with
actor_handle (insert path: appendAndQueueSendThreadMessageInTransaction ->
packages/db/src/data/events.ts insertEvents). Queued sends carry
actor_handle on queued_thread_messages and preserve it when the queue is
drained into a real send. Messages sent by other threads or plugins (the
trigger/senderThreadId paths) stay NULL — actor attribution applies to the
human request path only. IMPORTANT CONTRACT RULE: the actor comes ONLY from
getRequestActor, never from any request-body field.
2. Thread create: POST /threads (routes/threads/base.ts ->
services/threads/thread-create.ts) writes threads.created_by_handle for
human-origin creates; agent/plugin origins stay NULL.
3. Stop/interrupt: POST /threads/:id/stop records the acting handle on the
stop it produces — attach it to the lifecycle/system event data the stop
path already emits (inspect requestThreadStopForCurrentState and the run
lifecycle events; add the actor to the event payload, do not invent a new
event type).
4. Approvals: POST /threads/:id/interactions/:interactionId/resolve
(routes/threads/interactions.ts -> pendingInteractions service) records
pending_interactions.resolved_by_handle for the resolving request.
5. Agent-visible speakers: extend the turn command payload built in
thread-send.ts (prepareReadyThreadTurnCommand /
prepareTurnSubmitCommandPayload) with an OPTIONAL speaker
{ handle, displayName } field in packages/host-daemon-contract, populated
ONLY when the thread has 2 or more distinct human actors recorded (count
distinct non-null events.actor_handle for the thread — add a targeted query
to the events data module, no full-table scans in JS). In apps/host-daemon,
render the speaker as a short annotation prefixed to the user message text
handed to the provider session (e.g. "[from @handle] " — inspect how the
daemon builds provider input from the turn payload and match its idioms).
Single-human threads must produce byte-identical provider input to today.
6. Because the turn payload crosses the server<->daemon wire, bump
HOST_DAEMON_PROTOCOL_VERSION in packages/host-daemon-contract/src/
commands.ts from 58 to 59 (repo rule: any wire-visible change bumps it).
7. Tests (in-memory sqlite via createConnection(":memory:") + migrate(db),
never mock the db): send writes actor_handle; queued send preserves it
through the drain; thread create records created_by_handle for human
origin and NULL for plugin origin; resolve records resolved_by_handle;
speaker present only at >=2 distinct human actors; daemon-side annotation
rendering if the daemon has unit-testable prompt assembly.

Validation: pnpm exec turbo run typecheck --filter=@bb/server
--filter=@bb/db --filter=@bb/host-daemon-contract --filter=@bb/host-daemon,
then turbo test for the same filters, piped to /tmp files.
`;

const ws4Prompt = `${shared}
YOUR OWNERSHIP: apps/server/src/ws/** (including hub.ts and client-protocol.ts),
apps/server/src/server.ts (minimal wiring only), a new presence service module
under apps/server/src/services/, new presence route files under
apps/server/src/routes/, packages/server-contract/** (presence route contract),
and their tests. Do NOT modify: apps/server/src/routes/threads/**,
apps/server/src/services/threads/**, packages/db/**, packages/domain/**,
packages/host-daemon-contract/**, apps/host-daemon/**, apps/connect/**.

Objective: hub-derived presence — who is viewing which thread, with typing,
broadcast live and snapshot-queryable.

Design (decided): a client socket with a claimed actor that is subscribed to
thread-detail:<threadId> is "viewing" that thread. Presence is DERIVED from
the hub's existing subscription bookkeeping — no heartbeats, nothing
persisted. Viewers are deduped by handle across sockets/devices.

Tasks:
1. Presence tracking: build a presence service that observes subscribe/
unsubscribe/socket-close for thread-detail targets (hook the hub's
subscribe/unsubscribe/unregisterClient paths; get actors via
getSocketActor). Maintain per-thread viewer sets keyed by handle with
per-handle socket refcounts so closing one of two tabs does not drop
presence.
2. Typing: implement the currently-no-op "typing" case in apps/server/src/ws/
client-protocol.ts — mark the sending socket's actor as typing in that
thread with a ~6 second TTL (timer-based expiry; expiry emits a presence
update). Only meaningful when that actor is a current viewer.
3. Broadcasts: on any viewer-set or typing change for a thread, send
threadPresenceMessageSchema-shaped messages ({ type: "thread-presence",
threadId, viewers: [{handle, displayName, imageUrl, typing}] }) to that
thread's thread-detail subscribers, and presenceSummaryMessageSchema-shaped
({ type: "presence-summary", threads: { [threadId]: handles[] } }) to
thread-list subscribers (summary may carry only changed threads' entries —
document the merge semantics you choose; an empty array removes a thread's
entry). Validate outgoing messages with the strict schemas at the send
boundary, matching how changed-messages are produced today. Suppress
no-op rebroadcasts (unchanged viewer set and typing state).
4. Snapshot: GET /api/v1/presence returning current viewers per thread
({ threads: { [threadId]: viewers[] } }). Add the route to
packages/server-contract following existing public-api conventions and
implement it in apps/server/src/routes/ (register it wherever public routes
are registered). Follow the repo rule: no accepted-but-ignored fields.
5. Self-visibility: include the requesting/viewing actor in viewer sets (the
UI can filter itself out client-side later); do not special-case the local
operator.
6. Tests via the existing server test harness: subscribe -> viewer appears;
dedupe across two sockets with the same handle; unsubscribe/close ->
removed only when the last socket goes; typing TTL expiry; broadcast
payloads validate against the strict schemas; snapshot route returns the
derived state; no-op suppression.

Validation: pnpm exec turbo run typecheck --filter=@bb/server
--filter=@bb/server-contract, then turbo test for @bb/server and
@bb/server-contract if it has tests, piped to /tmp files.
`;

phase("Implement");
const results = await parallel([
() =>
agent(ws3Prompt, {
label: "WS3 attribution + speakers",
provider: "codex",
model: "gpt-5.6-sol",
reasoningLevel: "high",
}),
() =>
agent(ws4Prompt, {
label: "WS4 hub presence + typing",
provider: "codex",
model: "gpt-5.6-sol",
reasoningLevel: "high",
}),
]);
return { ws3: results[0], ws4: results[1] };
Loading
Loading