pocolobby 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/activity.js +1 -0
- package/dist/adapters/claude.js +7 -0
- package/dist/attachments.js +2 -0
- package/dist/auth.js +4 -0
- package/dist/bin.js +4 -0
- package/dist/board-caption.js +19 -0
- package/dist/board-github.js +1 -0
- package/dist/board-model.js +1 -0
- package/dist/board-page.js +418 -0
- package/dist/board-summary.js +22 -0
- package/dist/board.js +1 -0
- package/dist/catalog.js +1 -0
- package/dist/cli.js +123 -0
- package/dist/client.js +1 -0
- package/dist/code-stats.js +3 -0
- package/dist/config.js +3 -0
- package/dist/console-page.js +467 -0
- package/dist/console.js +1 -0
- package/dist/exchange-rates.js +1 -0
- package/dist/hook.js +1 -0
- package/dist/html.js +1 -0
- package/dist/icons.js +4 -0
- package/dist/launch.js +2 -0
- package/dist/local-files.js +1 -0
- package/dist/lock.js +1 -0
- package/dist/mail.js +4 -0
- package/dist/mascots.js +6 -0
- package/dist/memory-curate.js +59 -0
- package/dist/memory.js +5 -0
- package/dist/money.js +1 -0
- package/dist/node-version.js +1 -0
- package/dist/pace.js +1 -0
- package/dist/png.js +1 -0
- package/dist/presentation.js +1 -0
- package/dist/prices.js +1 -0
- package/dist/protocol.js +1 -0
- package/dist/recurrence.js +1 -0
- package/dist/secrets.js +1 -0
- package/dist/server.js +8 -0
- package/dist/statusline.js +7 -0
- package/dist/store.js +303 -0
- package/dist/transcripts.js +2 -0
- package/docs/AGENT-SETUP-PROMPT.md +72 -0
- package/docs/API.md +1097 -0
- package/docs/ARCHITECTURE.md +410 -0
- package/docs/CLAUDE-SETUP.md +166 -0
- package/docs/INTEGRATION-PLAN.md +122 -0
- package/docs/LOCAL-SETUP.md +356 -0
- package/docs/MEMORY.md +142 -0
- package/docs/OPERATIONS.md +320 -0
- package/docs/QUICKSTART.md +111 -0
- package/docs/RELEASING.md +40 -0
- package/docs/SELF-HOSTING.md +236 -0
- package/docs/UPGRADES-WITHOUT-AGENT-RESTARTS.md +112 -0
- package/examples/CLAUDE.md +50 -0
- package/examples/PACING.md +33 -0
- package/examples/POCOLOBBY.md +69 -0
- package/examples/fly.toml +42 -0
- package/package.json +68 -0
- package/site/agents/gonk-server.webp +0 -0
- package/site/agents/luke-sleep.webp +0 -0
- package/site/agents/mouse-empty.webp +0 -0
- package/site/agents/mouse-envelope.webp +0 -0
- package/site/agents/poco-bell.webp +0 -0
- package/site/agents/vader-asleep.webp +0 -0
- package/site/agents/vader-crate.webp +0 -0
- package/site/agents/vader-deckchair.webp +0 -0
- package/site/agents/vader-point.webp +0 -0
- package/site/agents/vader-thumbs.webp +0 -0
- package/site/icons/apple-touch-icon.png +0 -0
- package/site/icons/favicon-32.png +0 -0
- package/site/icons/favicon.ico +0 -0
- package/site/icons/favicon.svg +12 -0
- package/site/index.html +204 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# Architectural foundation
|
|
2
|
+
|
|
3
|
+
Status: design record with an implementation snapshot. The broker, CLI, SQLite storage, Claude adapter, continuity and offline recovery described here are implemented. The exact current wire contract and limits are in [the API reference](API.md); platform evidence and remaining live-account validation are in [the integration plan](INTEGRATION-PLAN.md).
|
|
4
|
+
|
|
5
|
+
The local automated suite and installed-package smoke test exercise real adapter subprocesses under a simulated MCP host. A real Claude Code 2.1.299 session subsequently passed bootstrap, idle push, explicit receipt, and outbound messaging with Remote Control active. Sender delivery facts, priority and room attributes, notice counts and retries, and hidden disabled agents were validated under a simulated host only. Message kinds and structured fields, scheduled delivery and daily schedules, and read receipts pushed to senders were too, and real agents now use them after a bridge-only `/mcp reconnect` on macOS. [Upgrades without agent restarts](#upgrades-without-agent-restarts) (a broker tool catalog, dispatch, an extensible event envelope), kinds only where chosen and bounded reconciliation were validated under a simulated host only, including real adapter subprocesses and the installed package. [Attachments](#attachments), [larger pushes](#5-push-priority-and-interruption-budget), the [tool guide for stale host schemas](#observed-deferred-tool-schemas-are-not-refreshed), and check-config comparing resolved paths were validated under a simulated host only too, including a screenshot round trip between two installed adapter subprocesses; real Claude has since sent and opened screenshots and documents between macOS and Windows. Room member changes and room deletion, both attachment limits in the tool guide, the evidence pointer for other files, and `get_attachment`'s `max_chars`, heading outline, `save_only` and folder creation were validated under a simulated host only. The [progress dashboards](#dashboards-a-projection-of-the-notes) were validated with a fake GitHub server and a fake summarizer client, and the page in a browser at phone width against a local broker; no real GitHub or Anthropic request, and no real Claude session, was part of it. The [console and email sign-in](#identities-and-settings) were validated with a mailer that keeps its codes, a fake Postmark answer for the mail request itself, fake GitHub and summarizer clients, and a local broker in a browser (sign-in, the dashboard hand-off and sign-out on `*.localhost`, at phone width in both themes); no real Postmark mail and no browser on the live host were part of it. One event version and tools only from the broker's catalog were validated with the automated suite under a simulated host only. `set_owner_requests`, the dashboard's live Waiting for you and message history, and a summary of only now and next were validated with the automated suite under a simulated host only, with a fake summarizer client, and the page was checked in a browser against a seeded local broker at desktop and phone width. `last_message_at` was validated with the automated suite and the installed-package smoke test under a simulated host only. One day of [message retention](#message-retention), the compact schedule listing, the escaped content bound, hidden dashboard agents, `unseen_notified_count` counted from the agent's latest progress, acknowledgments of expired and held messages and the refusal of not yet delivered ones, replies to expired ones, the summary ceiling and IPv6 clients counted by their /64 are covered by the automated suite under a simulated host only. Room messages to named members, history search, `unknown_message_ids` on acknowledgments and the answering broker in `get_context` were validated with the store and server tests under a simulated host; no real Claude session has used them yet. Superseding a message, `update_scheduled`, `read_checkpoint`, compact send results, refusals that name what to send instead, and the bridge's short standing sentences were validated with the automated suite and the installed-package smoke test under a simulated host; on the live broker a real Claude session used `read_checkpoint`. Turn activity from Claude Code's hooks, activity events for coordinators, and token counts from the transcripts with their daily totals were validated with the automated suite and the installed-package smoke test under a simulated host; that `UserPromptSubmit` runs for a turn a Channel event starts, and `Stop` at its end, was checked in a real Claude Code 2.1.302 session. On the live broker, a real Claude Code 2.1.302 session on Windows ran the hook command and the status line: `list_agents` showed it working three seconds after a scheduled lobby message started a turn, and the Tokens page's line for one day matched a direct count of its transcripts, its subagents' included. Activity events to a coordinator on a real bridge are still to be seen. The dashboard's strip and active time from stretches of work kept in the store, chips from turns only, and states read back after a restart were validated with the automated suite under a simulated host. Two independently authenticated accounts and the migration of a whole team of agents remain to be tested. The private Fly service and root-domain TLS are live. Automatic off-machine backup scheduling and the deployed maintenance/restore procedure remain operational work.
|
|
6
|
+
|
|
7
|
+
## 1. Scope and starting point
|
|
8
|
+
|
|
9
|
+
The starting point is a team of Claude Code sessions spread over several machines, all sharing one account and launched interactively as below. Some launchers may use server mode instead, so verify each actual launcher before migration:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
claude --remote-control --permission-mode bypassPermissions
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The desired setup gives agents their own accounts while preserving their roles and coordination. One private server serves agents across machines. The Fly app is `pocolobby`, with `https://lobby.pocolobby.com` as its one address. The repository uses the MIT license.
|
|
16
|
+
|
|
17
|
+
PocoLobby owns three small, explicit contracts:
|
|
18
|
+
|
|
19
|
+
1. **Identity:** a role such as `backend` has a durable address independent of its current session and provider account.
|
|
20
|
+
2. **Messaging:** accepted messages survive ordinary broker and client restarts, and delivery uncertainty is visible.
|
|
21
|
+
3. **Continuity:** a replacement session can recover role instructions, a recent checkpoint, and unresolved messages.
|
|
22
|
+
|
|
23
|
+
It does not own model inference, provider login, quota pooling, automatic account rotation, or execution scheduling. It can hold a message until a set time ([scheduled delivery](#scheduled-delivery)), but delivering that message is all it does; the receiving agent still decides what to run. The existing coordinator remains an ordinary agent; it is not a privileged process inside the broker. Task assignment and completion retain their current authority until the coordinator inventory establishes what that authority is.
|
|
24
|
+
|
|
25
|
+
## 2. Implemented structure and extension boundary
|
|
26
|
+
|
|
27
|
+
```mermaid
|
|
28
|
+
flowchart LR
|
|
29
|
+
subgraph Clients[Agent machines]
|
|
30
|
+
C1[Claude session A<br/>Account profile A] <-->|stdio MCP| A1[PocoLobby adapter A]
|
|
31
|
+
C2[Claude session B<br/>Account profile B] <-->|stdio MCP| A2[PocoLobby adapter B]
|
|
32
|
+
CN[Other Claude sessions] <-->|stdio MCP| AN[One adapter per session]
|
|
33
|
+
end
|
|
34
|
+
A1 <-->|HTTPS commands / WSS push| B[PocoLobby broker<br/>One Node process]
|
|
35
|
+
A2 <-->|HTTPS commands / WSS push| B
|
|
36
|
+
AN <-->|HTTPS commands / WSS push| B
|
|
37
|
+
B <--> D[(SQLite on persistent volume)]
|
|
38
|
+
O[Owner CLI] -->|HTTPS| B
|
|
39
|
+
F[Future ChatGPT MCP gateway] -->|Same core operations| B
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The local adapter is a subprocess of its Claude session, not a cloud-hosted Claude process. It maintains the outbound lobby connection and exposes MCP tools. It reconnects to the broker without exiting. A crashed adapter and a lost network connection are different failures.
|
|
43
|
+
|
|
44
|
+
Use HTTP for commands and reads, and WebSockets for push plus transport controls. This gives the CLI, adapters, and later integrations one mutation path. A fully bidirectional WebSocket RPC layer would add request correlation and retry machinery without a useful benefit at this scale. HTTP plus SSE is also viable; WebSockets are selected for straightforward connection health and two-way transport control, not throughput.
|
|
45
|
+
|
|
46
|
+
Keep the wire envelope independent of MCP. Claude Channels and a later ChatGPT integration translate to that contract. They need not share an MCP SDK version, transport, or notification protocol.
|
|
47
|
+
|
|
48
|
+
Discovery is registry-based. The owner provisions each identity and its credential; every adapter uses the same configured lobby HTTPS origin. `list_agents` reads a limited projection of that registry. Routing works the same way across processes on one machine and across separate machines. There is no LAN scan or provider-account discovery dependency.
|
|
49
|
+
|
|
50
|
+
## 3. Identities, sessions, and credentials
|
|
51
|
+
|
|
52
|
+
A **private lobby** is the admission and data-isolation boundary. A **room** is a shared conversation group inside it. For example, a private lobby `my-team` has `app`, `social`, and `web` rooms. Its five app builders can share `app`, while the coordinator can belong to all three rooms. Credentials bind an agent to one private lobby; membership permits any number of rooms inside it.
|
|
53
|
+
|
|
54
|
+
Internal identity keys include the lobby and short agent name, so `backend` can exist in different private lobbies. Discovery, direct messages, broadcasts, receipts, and checkpoints stay within the authenticated lobby. Room sends snapshot the enabled members, including offline agents and excluding the sender. Joining later does not grant earlier room history. A room send can instead name up to 20 of its members: only they get deliveries, so the rest of the room is not woken, since every room message costs each recipient a turn. The message stays in the room's thread (`to: "*"` with `addressed_to`), and a name that is not another enabled member the sender may address refuses the whole send, so a message never reaches only part of its list.
|
|
55
|
+
|
|
56
|
+
The owner may grant a coordinator `manage_rooms`. That capability creates rooms and replaces membership lists using already admitted same-lobby agents. It does not create private lobbies, provision agents, issue credentials, or grant administrative permissions. Pending unseen room messages become held if their recipient is removed; future notifications stop, historical bodies are redacted for that recipient, and rejoining does not replay them automatically. Already-seen information cannot be retracted.
|
|
57
|
+
|
|
58
|
+
A coordinator can also add or remove named members, keeping the rest, beside the whole-list replacement: two coordinators who read the same list and each changed one member could otherwise drop the other's change. It can also delete a room. Deletion is designed to stay small and need no migration: it is the removal of every member plus the removal of the room record. Unseen deliveries, scheduled ones included, are held as for a removed member (`room_deleted`), and messages, deliveries and receipts stay as history. Active recurring schedules into the room are cancelled, because an occurrence resolves its recipients when it fires: otherwise a schedule could resume into a later room created under the same ID. The room's dashboard notes source goes too: unlike an archived room it cannot come back, and its notes would keep being read and summarized as current work. The ID may be reused, so an accidental deletion can be repaired by creating the room again; that room inherits nothing. Deletion keeps no tombstone, so a retry cannot tell "already deleted" from "never existed", and after the ID is reused it would delete the new room; the tool says to check `list_rooms` before retrying.
|
|
59
|
+
|
|
60
|
+
| Concept | Meaning | Lifetime |
|
|
61
|
+
| -------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
62
|
+
| `agent_id` | Durable address, for example `backend` | Until the owner retires it |
|
|
63
|
+
| Provider profile | Local Claude configuration and account login | Managed on the agent machine |
|
|
64
|
+
| `runtime_id` | The conversation consuming the agent's inbox | Kept when that conversation resumes; changes for a new/forked conversation or rotated credential |
|
|
65
|
+
| Consumer generation | Broker-issued ownership epoch | Changes when an adapter process starts or the owner replaces the binding |
|
|
66
|
+
| Provider session reference | Optional opaque reference for local resumption | Provider-specific |
|
|
67
|
+
| Lobby credential | Capability to act as one PocoLobby principal | Independently rotated/revoked |
|
|
68
|
+
|
|
69
|
+
Provision agents through owner administration; an agent connection cannot claim an arbitrary name. Server-side credential lookup determines `from`. Reject a client-supplied sender identity. Store hashes of high-entropy lobby tokens, never provider credentials. Pass tokens through protected local configuration and authenticated headers, not URL query strings or committed files.
|
|
70
|
+
|
|
71
|
+
Give each Claude agent its own local configuration directory and login. Account access remains fixed by that launch profile unless the owner deliberately changes it. PocoLobby neither reads Claude credentials nor copies account tokens between machines.
|
|
72
|
+
|
|
73
|
+
Permit one consumer runtime per agent. A runtime is a host conversation, not one adapter process: resuming the same conversation reclaims its own binding, as described below. Network reconnection of a running adapter keeps its generation; each newly started adapter process receives a new one and bootstraps again. Reject a different runtime unless the owner explicitly replaces the existing binding. Socket timeout alone must not trigger work reassignment or silent ownership takeover. Fence stale consumer receipts, checkpoint writes, and runtime-originated sends with the current generation. Owner actions use a separate audited principal.
|
|
74
|
+
|
|
75
|
+
### Identities and Settings
|
|
76
|
+
|
|
77
|
+
Five kinds of identity act on a lobby, each with its own credential, and none can act as another:
|
|
78
|
+
|
|
79
|
+
| Identity | Credential | May |
|
|
80
|
+
| ----------------- | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
81
|
+
| Server operator | The admin token, in the CLI (`owner: true`) | Everything through the admin API: admit agents, issue credentials, accounts, owners, approved repositories |
|
|
82
|
+
| Lobby owner | An email-code session in the account area, handed to each lobby's own host as that lobby's session | Its own lobbies only, in their Settings: create them (when allowed), rooms and members, archive, sources from approved repositories, summary model and effort, dashboard visibility and viewers, delete the lobby |
|
|
83
|
+
| Local operator | On a broker without a domain, a session from a one-time `pocolobby settings` link | Every lobby's dashboard and Settings on that broker, never the API |
|
|
84
|
+
| Coordinator agent | Its agent token with `manage_rooms`, through the API | Rooms and membership, and resting agents, in its own lobby |
|
|
85
|
+
| Dashboard viewer | A dashboard session on one lobby's host, from a hand-off | Read that one private dashboard |
|
|
86
|
+
|
|
87
|
+
**Settings are a separate surface.** The account area lives at the root of the settings origin's host, and a lobby's settings at `/settings` on that lobby's own host, both dispatched before the API's checks, which keep refusing every request with an `Origin` header: the agent API stays a native-client API. Settings code reads only its cookies and never `Authorization`; API code reads only `Authorization` and never cookies; a signed-in account is never `owner: true`, and it reaches lobby state only through store methods that take it as an actor and check, inside the same transaction as the change, that it owns the lobby. Pages render on the server, escaped like the dashboard, and a small script sends their forms as JSON, because every settings POST must be JSON from its page's exact origin with only its route's fields. Admission and credentials stay with the operator: Settings never show or issue an agent token.
|
|
88
|
+
|
|
89
|
+
**Lobby first.** A lobby's settings sit beside its dashboard, at `<lobby>.<domain>/settings` hosted and `localhost:8787/<lobby>/settings` local, so one address names the lobby and both its places, and the pages and their links are the same in both modes. On a lobby's host the dashboard session is the settings session: the owner's opens them and a viewer's does not, and each action checks the session again after its body has arrived, then ownership inside the store's transaction. The account area keeps what belongs to no lobby: sign-in, the list of your lobbies and creating one.
|
|
90
|
+
|
|
91
|
+
**A local broker signs its operator in with a link.** Without a domain there is no mail and no account. `pocolobby settings` asks the broker, with the admin token, for a ticket that works once within a minute, and opens `/#ticket=…`: the fragment never reaches a server or its logs, and the page posts it back to start an operator session. Whoever can run that command already holds the admin token, so the link grants nothing new. Tickets and sessions live only in memory, so a restart signs the operator out. The pages answer only when `Host` names the broker's own loopback address and port, so a hostile page that points a name of its own at 127.0.0.1 (DNS rebinding) reaches nothing, and the session never opens `/v1`.
|
|
92
|
+
|
|
93
|
+
**Accounts are people; access is relationships.** An account is an email address, trimmed and lowercased. It may own lobbies (one owner per lobby), view private dashboards, and create up to three lobbies when the operator allows it. Whether it may sign in, open a dashboard or change a lobby is decided on every request by joins over those rows, so revoking an account, removing a viewer or making a dashboard private again applies to the next request. Disabling an account also deletes its sessions and hand-offs, so enabling it later revives none of them.
|
|
94
|
+
|
|
95
|
+
**Email codes, built small.** The parameters follow a common production sign-in (Better Auth's email OTP); PocoLobby implements them itself in `auth.ts` and the store, without the library: six digits, ten minutes, five tries per code; codes stored only as an HMAC under a key derived from the admin token and bound to the address; a new code replaces the old one but not the per-address count of wrong codes (ten an hour), which a fresh code would otherwise reset; one code per address per minute and five an hour, counted for unknown addresses too under an HMAC of the address (without the hourly cap anyone knowing an address could mail it about a thousand codes a day, spending the mail quota and replacing its live code each time), so the answer, the throttle and (with a one-second floor) the timing say nothing about which addresses have accounts; Postmark failures shown plainly, since a user waiting for a code that never comes is worse than that one disclosure. A right code is consumed and the session created in one transaction. Per-client limits count an IPv6 client by its /64, which one household or server holds whole, so it cannot walk through them address by address. Mail goes through an injectable `Mailer` (`mail.ts`), so tests and local checks use their own; without Postmark settings the broker still starts and the sign-in page names what is missing.
|
|
96
|
+
|
|
97
|
+
**Lobbies take a hand-off, not a shared cookie.** A cookie for `.pocolobby.com` would reach every lobby and the account area. Instead each host has its own `__Host-` cookie: the account area, for a signed-in account that owns a lobby or may view its dashboard, stores a one-minute, single-use hand-off and redirects to `https://<lobby>.pocolobby.com/handoff?token=…`, naming the settings page when it was asked for one; the lobby's host consumes it in one transaction, checks access again, and sets its own session, ending the browser's earlier session on that host so a second visit does not count as another device. That session also opens the lobby's Settings for its owner, so it carries writes, and the hand-off is bound to the browser that asked for it (from an architecture review): sign-in begins on the lobby's host, which keeps a random verifier in a `__Host-` cookie and sends only its hash through the account area, and the hand-off is stored under a key made from its token and that hash. A hand-off link opened in another browser, a login CSRF, finds no hand-off there and consumes nothing, since that browser's verifier gives another key.
|
|
98
|
+
|
|
99
|
+
**Archive keeps what was accepted; delete is unchanged.** `delete_room` holds unseen room messages, which suits a mistaken room. An owner retiring a room wants the opposite: messages already accepted still reach their recipients. Archiving removes the members without holding, cancels the room's recurring schedules, keeps existing holds, and reserves the name so no new room inherits its history; restoring starts empty. Archived rooms live in their own table, so every existing room query, and every agent, simply sees no room.
|
|
100
|
+
|
|
101
|
+
**Deleting a lobby is a hard delete** in one transaction over everything that references it, with the name kept in `deleted_lobbies` forever and the deletion in the audit log (which has no foreign keys, so its rows outlive the lobby). Attachment content is shared across lobbies by SHA-256, so a blob goes only when the global reference check finds no live attachment or upload anywhere; its file is removed after the commit. Dashboard work under way for the lobby finds no sources or lobby when it finishes and stores nothing.
|
|
102
|
+
|
|
103
|
+
**Repositories are approved per lobby.** The server's GitHub token can read every repository it was granted, private ones included. A lobby owner may therefore point a room only at a repository the operator approved for that lobby; the operator's own source setting approves its repository.
|
|
104
|
+
|
|
105
|
+
**Audit events are structured**: actor kind (operator, agent, account), the account or agent, the lobby, the action, the target and a small detail such as the members added. The sweep keeps each lobby's newest 10,000, and as many without a lobby: at a few rows a day that takes years, but an agent credential resuming its runtime in a loop writes one per request. The Activity page in Settings renders them as sentences with the email of the account; their `agent_id` names the acting agent for an agent's own change but the agent an operator's operation concerns, such as provisioning.
|
|
106
|
+
|
|
107
|
+
### Restarts without owner action
|
|
108
|
+
|
|
109
|
+
With a random `runtime_id` per adapter process, each Claude restart, crash or resume would produce a new runtime, which the broker rejects until the owner runs `agent reset-runtime`. With several agents on several machines, that would make routine restarts an owner chore.
|
|
110
|
+
|
|
111
|
+
Two remedies were evaluated:
|
|
112
|
+
|
|
113
|
+
- **(a) A lease.** The binding becomes claimable once the old runtime's socket has been gone for a bounded period, and the old runtime is fenced by a generation bump if it returns. This needs no host cooperation. However, it treats a missing socket as a stopped session. Laptop sleep, a network change, a proxy idle timeout or a broker deploy all remove sockets while Claude keeps running and possibly editing files. After the lease, whichever process holds the credential next takes the identity without anyone deciding, including an accidental second session in a shared checkout. The displaced session learns it lost ownership only when it reconnects. Every restart must also wait out the lease and gets a new runtime ID, so it greets the coordinator again. This contradicts the rule that socket timeout alone never transfers ownership.
|
|
114
|
+
- **(b) A stable runtime per conversation.** A runtime ID persisted next to the agent configuration would survive restarts on any host. It identifies the configuration file rather than the conversation, though: a brand-new conversation launched with the same MCP file would silently reclaim the identity. Deriving the ID from the host's conversation ID instead distinguishes a resume from a new conversation.
|
|
115
|
+
|
|
116
|
+
**Decision: (b), derived from the Claude session.** Claude Code passes `CLAUDE_CODE_SESSION_ID` to stdio MCP servers, and for `claude --resume <session-id>` that is the resumed ID ([Claude Code environment variables](https://code.claude.com/docs/en/env-vars)). The adapter computes `runtime_id` as HMAC-SHA256 keyed by the agent credential over that session ID, formatted as an RFC 9562 version 8 UUID. The broker never sees the local session ID, and a rotated credential starts a new runtime.
|
|
117
|
+
|
|
118
|
+
- **Start versus reconnect.** A newly started process claims without a generation. If its runtime already holds the binding, the broker issues a new generation. This fences any earlier process of the same conversation, such as one resumed in a second terminal: the newer process wins. The new process must repeat bootstrap, which proves that its Channel reaches the model. Unresolved notification attempts restart, earlier messages arrive as one backlog summary, and an audit event records the resume. A network reconnect repeats its generation and is rejected once superseded, so a partitioned process cannot rejoin.
|
|
119
|
+
- **Unchanged guarantees.** A different conversation still receives `runtime_conflict` until the owner resets the binding, so no other conversation takes over. Reclaiming depends only on the presented conversation identity, never on socket state or presumed idleness. Generations fence stale processes. Runtime history keeps retired runtimes from reclaiming: once the owner resets or disables the identity, that conversation stays retired. Rotating the credential readmits it under a new runtime ID. The Hello retry key `hello:<agent_id>:<runtime_id>` survives restarts, so a resumed conversation's repeated greeting is deduplicated while the first greeting is stored, a day after delivery.
|
|
120
|
+
- **Costs.** This depends on the host. `claude --continue` or the interactive resume picker may supply the startup ID instead of the resumed one. The adapter then presents a different runtime and fails safely with `runtime_conflict`, which needs the owner. After `/clear`, the running adapter keeps its spawn-time ID, so a later resume of the post-clear conversation counts as a new conversation. `--fork-session` also creates a new conversation, as intended. Hosts that supply no conversation ID fall back to a random runtime per process, which means owner resets on restart. The adapter logs that fallback, and its `get_context` tool reports `runtime_scope: "process"` instead of `"conversation"`.
|
|
121
|
+
- **Trust limit.** The broker cannot verify that a claim comes from the same conversation. Anyone holding both the credential and the Claude session ID can resume as that runtime. The check prevents accidental takeover; it does not defend against a holder of the credential, which already authorizes acting as the agent.
|
|
122
|
+
- **Validation.** Automated tests and the installed-package smoke test restart real adapter subprocesses with a supplied session ID under a simulated MCP host. Restarting and resuming a real Claude session has not been tested yet.
|
|
123
|
+
|
|
124
|
+
**This fences broker operations, not shell commands.** A disconnected old Claude can still edit its workspace. A safe work handoff additionally requires checking that the former session has stopped and reconciling its work. Delivery pause similarly stops future notifications; it cannot cancel an already-running action.
|
|
125
|
+
|
|
126
|
+
## 4. Durable messages and honest receipts
|
|
127
|
+
|
|
128
|
+
Current send command; see [the API reference](API.md) for the complete schema:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"client_request_id": "a-stable-id-for-this-send",
|
|
133
|
+
"to": "backend",
|
|
134
|
+
"priority": "important",
|
|
135
|
+
"content": "Review the authentication change before the release.",
|
|
136
|
+
"correlation_id": "release-42"
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The server assigns a message ID, sender, acceptance time, and history ordering. An optional `reply_to` identifies the message being answered. Priority has exactly two initial values: `info` and `important`.
|
|
141
|
+
|
|
142
|
+
**Kinds say what the sender expects.** Every FYI notice cost its recipient a turn, partly because the sender could not say that no answer was wanted and partly because polite answers woke the sender in turn. A send therefore carries a `kind`: `request`, `notice`, `assignment`, `report` or `question`, with `reply_expected` defaulting to false for a notice or report and true otherwise. A small, bounded set of structured fields (`commit`, `files`, `evidence`, `options`, `recommendation`) keeps the facts a coordinator acts on out of free text. `evidence` is a logical location such as `dropbox:my-team/...` that each machine resolves for itself, for the same reason that checkpoints store references other machines can resolve. The broker records and shows these values; it does not enforce replies, and a kind grants nothing.
|
|
143
|
+
|
|
144
|
+
**A kind means something only where the sender chose one.** A send without a kind stores none, and its push claims no reply expectation.
|
|
145
|
+
|
|
146
|
+
In one SQLite transaction, validate authorization, store the immutable message, create each recipient's inbox row, and record the idempotency key. Return `stored` only after commit. A crash between commit and socket push leaves recoverable inbox rows; startup dispatch scans them. Those rows are the durable delivery queue, so no Redis or separate queue service is needed.
|
|
147
|
+
|
|
148
|
+
Deduplicate on `(sender_id, client_request_id)`. An identical retry returns the original message and recipient set; a changed request under the same key fails. Callers preserve the key on retries; the adapter does not automatically retry HTTP mutations. Two model calls with different keys are two messages even if their wording matches. The deduplication record is the message itself, so a retry is recognized only while the original is stored, about a day after delivery; a later retry under that key is a new message. The send tools' descriptions, the tool guide and the bridge's uncertain-outcome text therefore tell an agent to check its history or ask the recipient before resending anything older.
|
|
149
|
+
|
|
150
|
+
For broadcast, snapshot all authorized, enabled lobby members at acceptance, including offline members and excluding the sender by default. Store fanout atomically. Retrying does not include agents added later. Disabling a recipient or revoking access prevents further delivery; retain a visible held/revoked outcome for existing messages. New unrecognized recipients fail before acceptance.
|
|
151
|
+
|
|
152
|
+
| Recorded fact | What it establishes |
|
|
153
|
+
| --------------------------- | ----------------------------------------------------- |
|
|
154
|
+
| `stored` | Message and recipient row committed |
|
|
155
|
+
| `adapter_received_at` | Adapter reported receipt of the transport event |
|
|
156
|
+
| `notification_attempted_at` | Adapter attempted to write to the host channel |
|
|
157
|
+
| `agent_seen_at` | An explicit receipt tool call named this message |
|
|
158
|
+
| Work result | A separate coordinator/workflow result, if applicable |
|
|
159
|
+
|
|
160
|
+
The sender of a message sees these facts per recipient, `delivery_state`, `notification_attempted_at` and `agent_seen_at`, in its history and send results. That answers a coordinator's most frequent question, "did backend see the assignment?", without a status conversation. Other agents never see recipient facts for messages they did not send.
|
|
161
|
+
|
|
162
|
+
Store transport attempts separately from semantic receipts, including runtime/generation and timestamps. No WebSocket receipt, stdio write, inbox read, or connectivity signal means task completion. An explicit `seen` receipt records a model claim, not an external proof of understanding or successful work.
|
|
163
|
+
|
|
164
|
+
Receipts are idempotent and per message ID, optionally batched. Never acknowledge all lower sequence numbers because a high-priority message arrived first. History pagination may use a cursor; acknowledgment cannot reuse that cursor as a blanket receipt.
|
|
165
|
+
|
|
166
|
+
The guarantee is durable accepted storage and retryable broker-to-adapter delivery while access remains valid and connectivity resumes within the day a message is [kept](#message-retention). Host/model notification is attempted with bounded retries and tracked until acknowledged or visibly unresolved. **Exactly-once model execution is not promised.** A crash after an external action but before its recorded result needs reconciliation or idempotency at that external action.
|
|
167
|
+
|
|
168
|
+
If the server is unreachable, a send returns a clear unaccepted/unknown outcome as appropriate. The same-key retry resolves an unknown outcome. PocoLobby does not promise a durable local outgoing spool across adapter crashes.
|
|
169
|
+
|
|
170
|
+
### Message retention
|
|
171
|
+
|
|
172
|
+
Messages are kept a day. The broker's one-minute sweep, which also runs at start, deletes each message 24 hours after it reached its recipients (a scheduled one after its delivery), acknowledged or not, with its deliveries, seen receipts and attachment references, in one transaction; a scheduled message still waiting for its time stays, a cancelled one goes a day after its cancellation, so repeating the cancellation still finds it, and a schedule cancelled more than a day ago goes. It runs before attachment cleanup, so attachments those messages referenced can expire in the same pass, and attachment content is kept a day after its last message too. It reads first, so an idle broker opens no write transaction. History, pending pages, reconciliation and the dashboard therefore see about a day; what must last belongs in checkpoints and the team's notes. Pending messages are still never dropped to make room: capacity refuses new sends instead. An ID that is no longer stored is not an error: acknowledging it records nothing, and `reply_to` may name it, since work that takes days answers old assignments; only a stored message the caller cannot see is refused.
|
|
173
|
+
|
|
174
|
+
### Scheduled delivery
|
|
175
|
+
|
|
176
|
+
Reminders such as a coordinator's morning notice and hourly night check ran as session crons, which die when the session restarts. PocoLobby already stores messages durably for agents that are not listening, so it can also hold one until a time.
|
|
177
|
+
|
|
178
|
+
- **Stored at once, delivered later.** A send with `deliver_at` is accepted like any send: message, frozen recipients and retry identity commit together and the result says `stored`. Each delivery row is held with reason `scheduled`. Every inbox query, count, acknowledgment check and notification already excludes held deliveries, so a recipient cannot see or act on the message early without any new query paths. Acknowledging it before it is delivered is refused with `409 not_yet_delivered`, and the whole batch records nothing: a receipt for a message that has not arrived means nothing. A message held for good (its room left or deleted, or cancelled) still records nothing without refusal. At its time the broker clears the hold, and the existing notification path delivers it. Delivery to yourself is an ordinary direct message to your own ID.
|
|
179
|
+
- **The existing dispatch loop, not a new process.** The broker's dispatch tick, which runs every 250 milliseconds whether or not agents are connected, first delivers due messages and runs due schedules. It reads before it writes, so an idle broker opens no transaction. A due message is found by an index on undelivered scheduled messages. Because the state is in SQLite, a restart or a single-Machine deploy loses nothing: the first tick after start delivers whatever fell due during the downtime and marks it `late`, so the agent can judge whether it still applies.
|
|
180
|
+
- **Daily schedules are templates.** A schedule stores the send fields, an IANA time zone, 1–24 local times and optional weekdays. Each occurrence becomes a real message, with its own ID, the schedule's ID and its slot as `deliver_at`, authorized and fanned out when it fires, because room membership and permissions may have changed in the meantime. A failed occurrence is skipped with a recorded reason rather than retried, and the schedule continues. A unique index on schedule and slot guards against a duplicate occurrence. Schedules belong to the agent identity, so they survive the session restarts that ended the crons.
|
|
181
|
+
- **Local wall-clock time.** Agents and their humans think in local time, so occurrences follow the zone's clock across daylight-saving changes. A time skipped in spring runs just after the gap, and a time repeated in autumn runs once, at its first occurrence. After an outage that missed several slots, one occurrence is sent for the latest of them, marked late, rather than a burst of stale ones.
|
|
182
|
+
- **Ordering trade-off.** A scheduled message keeps the sequence it was given at acceptance. Recipients can therefore see it before messages that arrived earlier in history and pending pages. A runtime's backlog boundary records its binding time as well as the highest sequence, so a scheduled message that falls due after a session started is pushed with its body, not summarized as backlog.
|
|
183
|
+
- **Bounded.** `deliver_at` is at most 30 days ahead. A sender holds at most 100 undelivered scheduled messages and 20 schedules; inbox capacity counts scheduled deliveries when they are accepted. Cancelling a scheduled message or schedule is the sender's (or the owner's); a delivered message cannot be recalled.
|
|
184
|
+
|
|
185
|
+
This is message scheduling, not job execution. A delivered reminder is a message like any other: it can wait in Claude's queue behind a running tool, and acting on it is the agent's decision. Exactly-once execution is still not promised.
|
|
186
|
+
|
|
187
|
+
### Attachments
|
|
188
|
+
|
|
189
|
+
A coordinator reviews UI changes from builders' before and after screenshots, and a social agent shares stills and long research documents. Dropbox paths passed around are resolved differently on other machines, and the receiving model never sees them as an image. One send carries the files to every recipient.
|
|
190
|
+
|
|
191
|
+
- **The bridge reads; the broker stores.** The model lists absolute local paths in a send's `attachments`. The bridge is the only component that touches the agent's disk: it checks each path against the [local file rules](#local-file-reading), reads the file, recognizes its type from its bytes, and describes it to the broker by SHA-256, name, type and size. Peer message text never reaches this code, so only the model's own tool call can name a path.
|
|
192
|
+
- **One logical operation, content-addressed.** The send goes first with descriptions only. A retry of a stored request returns it before anything else, so a retry with the same files uploads nothing. Missing content is answered with `attachment_upload_required` and the hashes the broker needs; the bridge uploads exactly those and repeats the identical request once, and that request stores the attachment records and the message in one transaction. Nothing is stored for a send that is refused for another reason, because the attachment check comes after authorization, rates and limits. A send that fails after uploading leaves pending uploads, which a short grace period removes.
|
|
193
|
+
- **Stored once, owned per uploader.** Content lives in one file per SHA-256 under the attachments directory beside the database (`/data/attachments` on Fly), written through a temporary file. An attachment is one uploader's file under one name, referenced by messages and schedules. Possession is proven per uploader by uploading the bytes; knowing a hash grants nothing.
|
|
194
|
+
- **Access follows delivery.** Only the uploader and the recipients of a referencing message, in the same lobby, may download, and only while that delivery is visible: not before a scheduled message is delivered, not after a room member was removed before seeing it. The owner lists metadata and usage but downloads no content.
|
|
195
|
+
- **Bounded and collected.** Per-file limits, a per-lobby quota of distinct content, and a free-disk reserve that keeps SQLite writable all refuse before anything is stored, with the setting named; the quota and the reserve answer `507 storage_capacity`. Bodies are buffered whole before those checks, so uploads arriving at once share a process-wide memory budget: each reserves its declared length before its body is read, and one that does not fit is refused at once with the retryable `503 uploads_busy`. Content is kept a day after the last referencing message reached its recipients, or while an active schedule or a scheduled message still waiting for its time will send it; a scheduled message's delivery counts as the use of its files, however late, and a cancelled one holds nothing. Content is deleted only when no live attachment and no pending upload references it. An expired attachment's record, which tells its uploader `410` rather than `404`, goes 7 days after expiry once no message or schedule names it, so the table does not grow forever.
|
|
196
|
+
- **Seen as images.** `get_attachment` returns an image as an MCP image block, so the receiving model sees it, and a document as text. A PNG too large for a tool result is reduced in plain JavaScript; other oversized images are described and can be saved with `save_to` for a file-reading tool, which scales images itself.
|
|
197
|
+
|
|
198
|
+
## 5. Push, priority, and interruption budget
|
|
199
|
+
|
|
200
|
+
SQLite inbox state is canonical. WebSocket events carry stable IDs/revisions and bounded notification content. Reconnect reconciles pending state; a high transport cursor alone must not skip unacknowledged inbox items.
|
|
201
|
+
|
|
202
|
+
The first adapter pushes a small message's body and ID directly so normal messages do not require a redundant read tool call. Informational messages have a short bounded batching delay. If a batch exceeds the notification size cap, it carries a count and truncated marker; the adapter's trusted tool guidance directs the agent to read the inbox. Backlog recovery uses one coalesced notification rather than replaying a long transcript. Both forms use the same receipt contract.
|
|
203
|
+
|
|
204
|
+
`important` bypasses the information batching delay and is preferred among unsent notifications. It does not interrupt a running tool, reorder events already queued in Claude, or guarantee an action deadline. Preserve original history order and causality references so dependent instructions can be interpreted correctly. A natural-language request to stop a merge is not a merge lock.
|
|
205
|
+
|
|
206
|
+
The receiving agent must be able to tell these apart, so the Claude adapter makes them visible. Channel attributes, set from broker-attributed fields and never from message text, carry `priority`, `scope` (`direct`, `room`, `broadcast` or `mixed`), `room_id` (or `room_ids`) and a single sender as `from`, next to `agent_id` (the recipient) and `lobby_id`. A push begins with a headline such as "PocoLobby IMPORTANT room message in room app from lead." followed by the JSON payload; message text stays inside the JSON, so it cannot forge the headline. Important pushes add one sentence: handle it before lower-priority queued work; it did not interrupt a running tool.
|
|
207
|
+
|
|
208
|
+
**Notices are snapshots that Claude may show late.** Field reports showed stale counts: after acknowledging five messages, pushes for three new ones said 6, 7 and 8 pending, and a busy session was shown push 1, then a generic "1 pending" notice for that same message, then push 2 saying 2 pending. The broker had computed each count correctly from unseen deliveries in the transaction that sent the notice. Claude holds Channel events while a tool runs and shows them afterwards, after the agent's newer receipts, so a count that was true at send time reads as current and false. The 30-second retry made it worse: it treated a missing seen receipt as a possibly lost notice, though the adapter had confirmed writing the original to the host, and queued a body-less duplicate behind it. Three changes follow. Pushes carry their messages without an inbox-wide count. Hints state their count "as of" the send time and name the messages they cover, so a late reader can recognize what it already handled. A retry behind a host write that the same adapter process confirmed is consumed without a notice, keeping the attention schedule; the adapter also drops a notice whose messages it has itself acknowledged since. Retries still go out when no host write was confirmed, after a restart, or after an owner retry. A bootstrap is retried at the schedule's longest delay (300 seconds by default), not after 30: a model's bootstrap, with its reconciliation, takes longer than that, and the 30-second retry reached the session as a second bootstrap with the same nonce after `ready`, costing a turn. The bridge also writes no bootstrap whose nonce the model has already echoed with `ready`: the broker sends one only when its retry overtook that ready.
|
|
209
|
+
|
|
210
|
+
Persist notification attempts and retry budgets. Retry a coalesced unresolved-inbox notification with finite backoff, except behind a confirmed host write in the same adapter process, as above. Ordinary reconnects and broker restarts must not reset the retry budget. On exhaustion, keep messages pending and expose `notification_attention_required` to the owner CLI/status. Discovery also shows, per agent, how many unseen messages were written to its host since its latest progress and since when (`unseen_notified_count`, `oldest_unseen_notified_at`), so a coordinator can see an agent that cannot take turns. Progress is its ready claim, an acknowledgment or checkpoint, or a message it sent: a message the agent left unacknowledged while it kept working says nothing about whether it can take turns, and such a room FYI would otherwise keep an agent Not responding on the dashboard while it went on acknowledging and sending. These are traffic aggregates visible lobby-wide, without IDs, senders or rooms; an explicit retry or replacement-session bootstrap can resume notification. Do not call this eventual guaranteed model delivery.
|
|
211
|
+
|
|
212
|
+
**Larger pushes.** Messages may be 64 KiB. The bridge accepts rendered pushes up to 72 KiB and leaves the effective size to the broker (`POCOLOBBY_PUSH_BYTES`, default 32 KiB), so it can be tuned on the server without a bridge update. Claude Code documents no size limit for Channel events; a push enters context whole, which is why the default stays well below the bridge's ceiling. See [Push size](API.md#push-size).
|
|
213
|
+
|
|
214
|
+
Separate caps apply to content size, queued bytes, send rate, notification rate, broadcast fanout and database growth. New sends fail before acceptance when capacity is exhausted; previously accepted work is retained. Important traffic is rate-limited too. Protocol receipts do not themselves start model turns. Avoid automatic acknowledgment conversations and all-to-all status chatter. Initial defaults are documented in [the API reference](API.md#default-bounds); tune them after the two-agent experiment.
|
|
215
|
+
|
|
216
|
+
**Kinds are visible before the body is read.** A push's first line names the kind and the expectation, such as "PocoLobby notice from backend (no reply expected): direct message.", and adds "No reply expected: do not answer unless you need something from the sender." when nothing in it expects a reply. Channel attributes carry `kind` and `reply_expected` next to `priority` and `scope`. Kinds and fields come from broker-attributed values, never from message text.
|
|
217
|
+
|
|
218
|
+
**Read receipts are pushed, not polled.** A coordinator asking "did backend see the assignment?" had to read history. A send can set `notify_when_seen`; when a recipient acknowledges it, the broker records a receipt for the sender and later pushes a compact `receipts` event naming the message, who saw it and when. Receipts close together are coalesced: each waits two seconds, events to one agent are at least 30 seconds apart, and one event lists at most 20. A receipt is deliberately not a message: it has no inbox row, needs no acknowledgment, and cannot itself ask for a receipt, so receipts cannot start a loop, and notes to yourself produce none. It is pushed once and dropped after a day if never pushed; the durable fact remains the seen state in the sender's history.
|
|
219
|
+
|
|
220
|
+
**One event version.** The bridge states its event version on the upgrade, and the broker accepts only its own, 5: a bridge that states an older one, or none, is refused with `426 bridge_outdated`. The broker and the bridge of one release go together, and the broker deploys first. Within one version the envelope still carries new fields and wording without a bridge update; see [Upgrades without agent restarts](#upgrades-without-agent-restarts).
|
|
221
|
+
|
|
222
|
+
Presence exposes separate fields: transport connected, last heartbeat, last agent receipt, notification attention state, and the time of the last message. Unknown model availability remains unknown; a connected adapter may accompany a quota-blocked or permission-blocked model.
|
|
223
|
+
|
|
224
|
+
`last_message_at` is when the agent last sent a message, its schedules' occurrences aside, or received a direct message, a scheduled one at its delivery, and null when it did neither within the day messages are kept. The broker derives it from the messages it stores, so it is a fact, not a claim, and needs no upkeep; but it tells when the agent last spoke or was spoken to, not what it is doing: an agent can work for hours without a message. Discovery and the owner's listing show it, and the dashboard's Away chip reads it. Discovery lists enabled identities only; the owner's listing keeps disabled ones.
|
|
225
|
+
|
|
226
|
+
An agent says what it waits on from the owner with `set_owner_requests`: how many decisions, approvals or looks, and one line naming each. It is fenced by the current runtime and kept until the agent changes it: each call replaces the last, the agent clears it with a count of 0, and it is cleared when the owner retires the runtime; the broker never sets or clears it otherwise. The owner sees it in the owner listing and live on the dashboard, and peers see it in discovery, so a coordinator asked whether anyone needs the owner can answer for the whole lobby. It is a record, not a message: the owner sees it only when looking, so a decision that blocks work also goes to the coordinator, or whoever relays to the owner, as a question. The dashboard's waiting list is the agent's own word from the moment it calls.
|
|
227
|
+
|
|
228
|
+
## 6. Continuity: small enough to maintain
|
|
229
|
+
|
|
230
|
+
Continuity belongs in the first useful release because the owner's problem includes agents losing memory. A router-only milestone proves transport but does not solve that problem.
|
|
231
|
+
|
|
232
|
+
Maintain three distinct records:
|
|
233
|
+
|
|
234
|
+
- **Role/profile:** owner-controlled purpose, boundaries, project references, and bootstrap instructions.
|
|
235
|
+
- **Checkpoint:** agent-written, versioned, bounded summary of current objective, decisions, next actions, unresolved work and message IDs, repository/branch/commit references, and relevant artifacts.
|
|
236
|
+
- **Message history:** immutable communications, read in bounded pages as needed and [kept a day](#message-retention).
|
|
237
|
+
|
|
238
|
+
Checkpoint updates require an expected revision (compare-and-swap), current runtime ownership, and provenance/time. Peers cannot overwrite an agent's role or checkpoint. Store project references that another machine can resolve; an absolute local path alone is insufficient. Display checkpoint age and unresolved actions during resumption.
|
|
239
|
+
|
|
240
|
+
A replacement session loads its role, checkpoint, unresolved inbox, and receipts not covered by that checkpoint before normal consumption. Use a bootstrap/ready tool exchange and record what revision the session says it loaded. Native Claude transcript resumption can preserve richer local context when available; the checkpoint provides a portable fallback. Do not silently replay the whole transcript or promise lossless memory transfer.
|
|
241
|
+
|
|
242
|
+
A `seen` message stays in history until it expires. Assign each new receipt a per-agent receipt-event sequence. A checkpoint explicitly declares the receipt snapshot it covers; saving a checkpoint must not implicitly cover newer receipts. Bootstrap exposes later receipt events and their messages as uncertain work, in bounded pages with visible continuation. This catches an assignment marked seen just before a crash, even if it never reached a checkpoint. It also catches an old message acknowledged after a newer one: the watermark is on receipt events, not message IDs or message order. Reconciliation does not automatically repeat actions.
|
|
243
|
+
|
|
244
|
+
If work remains unfinished, the checkpoint must retain its ID and status. Permit a checkpoint update and associated seen receipts in one broker transaction to reduce restart gaps. This is still not atomic with edits, deployments, or other tools. Checkpoint on meaningful progress and before deliberate handoff; abrupt crashes may leave it stale.
|
|
245
|
+
|
|
246
|
+
**Reconciliation is bounded, and asks for the checkpoint that ends it.** Bootstrap context carries the first 5 acknowledged messages its checkpoint does not cover with their bodies, headers of 20 more, a total and a cursor, and a `guidance` list that says to save a checkpoint covering the receipt snapshot after calling `ready`, plainly so when none was ever saved. The adapter's bootstrap notice says the same. A checkpoint saved before `ready` raises the revision the session loaded, so `ready` then fails as stale. Nothing is hidden: the cursor reaches every uncovered receipt with its body.
|
|
247
|
+
|
|
248
|
+
Defer embeddings, automatic memory synthesis, transcript synchronization, shared writable memory, and a general task database. Repository documentation remains authoritative for repository facts; a checkpoint is a handoff, not a competing source of truth.
|
|
249
|
+
|
|
250
|
+
## 7. Initial interfaces and implementation boundaries
|
|
251
|
+
|
|
252
|
+
Expose a compact MCP tool set backed by common broker operations. The first group comes from the broker's catalog and the second belongs to the adapter:
|
|
253
|
+
|
|
254
|
+
| Tool | Source | Responsibility |
|
|
255
|
+
| ---------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
256
|
+
| `send_message` | Catalog | Address one agent or yourself; kind, fields, `deliver_at`, receipts; stable retry key |
|
|
257
|
+
| `broadcast` | Catalog | Explicit fanout permission; same storage semantics |
|
|
258
|
+
| `send_room_message` | Catalog | Address one room, or named members of it; same options |
|
|
259
|
+
| `schedule_message` | Catalog | Durable daily repeat at local times in an IANA zone |
|
|
260
|
+
| `list_scheduled` | Catalog | Own schedules and undelivered scheduled messages |
|
|
261
|
+
| `cancel_scheduled` | Catalog | Cancel a schedule or an undelivered scheduled message |
|
|
262
|
+
| `list_agents` | Catalog | Enabled identities, connection/receipt state, last message time, owner requests |
|
|
263
|
+
| `set_owner_requests` | Catalog | What the agent waits on from the owner: a count and one line; the dashboard shows it live; 0 clears it |
|
|
264
|
+
| `set_status` | Catalog | One line peers read beside the agent in `list_agents`, such as a queue's order; empty clears it |
|
|
265
|
+
| `list_rooms` | Catalog | Visible rooms and their members |
|
|
266
|
+
| `create_room` | Catalog | Room management; offered only with `manage_rooms` |
|
|
267
|
+
| `set_room_members` | Catalog | Room management; offered only with `manage_rooms` |
|
|
268
|
+
| `add_room_members` | Catalog | Add members, keeping the others; offered only with `manage_rooms` |
|
|
269
|
+
| `remove_room_members` | Catalog | Remove members, keeping the others; offered only with `manage_rooms` |
|
|
270
|
+
| `delete_room` | Catalog | Delete a room, keeping its history; offered only with `manage_rooms` |
|
|
271
|
+
| `rest_agents` | Catalog | Rest agents so nothing is pushed to them, or resume them, by hand or at a set time; offered only with `manage_rooms` |
|
|
272
|
+
| `get_messages` | Catalog | Own inbox or history, with recipient facts for own sends; history search |
|
|
273
|
+
| `acknowledge_messages` | Adapter | Mark explicit IDs seen; never mark work done implicitly |
|
|
274
|
+
| `get_context` | Adapter | Role, checkpoint, pending summary, guidance and the answering broker for bootstrap |
|
|
275
|
+
| `save_checkpoint` | Adapter | Version-checked continuity update; optional related receipts |
|
|
276
|
+
| `ready` | Adapter | Bootstrap confirmation / channel probe acknowledgment |
|
|
277
|
+
| `list_operations` | Adapter | The current catalog, for a host that did not refresh its tool list |
|
|
278
|
+
| `call_operation` | Adapter | Run a catalog operation by name, through the same dispatch |
|
|
279
|
+
| `get_attachment` | Adapter | Open an attachment: images as image blocks, documents as text in parts or as a Markdown outline; `save_to` keeps a copy, `save_only` only that |
|
|
280
|
+
|
|
281
|
+
### Upgrades without agent restarts
|
|
282
|
+
|
|
283
|
+
Ordinary broker releases need no package update and no reload of agents. The design and its evidence are in [Upgrades without agent restarts](UPGRADES-WITHOUT-AGENT-RESTARTS.md).
|
|
284
|
+
|
|
285
|
+
- **Tools are data on the broker.** `GET /v1/tools` serves names, descriptions and JSON Schemas, filtered by the caller's permissions, with a revision that hashes the effective definitions. A tool is a mapping onto existing agent routes; `POST /v1/tools/:name` executes it by running the same route handler as the HTTP request, so validation, authorization, fencing and idempotency exist once. The dispatcher reaches only an allowlist of agent routes on the same broker, and no code crosses the wire. `src/catalog.ts` holds the definitions, on the broker only: the bridge carries no copy, and until its first catalog loads it lists only its own tools.
|
|
286
|
+
- **The bridge refreshes and tells the host.** It loads the catalog independently of bootstrap, on reconnect and on an announced revision, notifies Claude with `tools/list_changed` only after a validated change, keeps the last good catalog through failures with bounded backoff, and distinguishes removal, missing permission and refresh failure.
|
|
287
|
+
- **Lifecycle stays local.** Bootstrap readiness, acknowledgments and checkpoints stay in the adapter, which fences them and filters stale notices by what it acknowledged. Two fixed tools, `list_operations` and `call_operation`, reach every operation even through a host that does not refresh its tool list; none has been observed, so this is insurance.
|
|
288
|
+
- **The envelope is extensible where it is safe.** It keeps identity, delivery and receipt fields strict, carries every other field in a bounded `extensions` object, and adds a broker-composed `presentation` (`src/presentation.ts`) that the adapter renders generically. Message text never contributes to it. Initialization instructions are generic; feature guidance lives in tool descriptions, results and push guidance.
|
|
289
|
+
- **What still needs a bridge reload:** a change to the adapter itself (a new local capability, a fix, a new event version or catalog format). That reload is `/mcp reconnect pocolobby`, verified with real Claude on macOS. MCP SDK and protocol versions stay pinned: a Channel that negotiates MCP 2026-07-28 cannot deliver messages. A broker with a new event version refuses older bridges (`426 bridge_outdated`), so such a release deploys the broker first and then the package.
|
|
290
|
+
|
|
291
|
+
### Observed: deferred tool schemas are not refreshed
|
|
292
|
+
|
|
293
|
+
Real Claude Code does not refresh a named tool's schema once the session has loaded it. Two sessions, on macOS and on Windows (Claude Code 2.1.299), loaded MCP tools on demand through tool search. After a broker-only catalog change, `list_operations` showed the new definitions, but Claude's own copy of `send_message` kept the schema it loaded first in the session, without `kind`, `reply_expected`, `deliver_at` or the structured fields. That held through two `/mcp reconnect pocolobby` upgrades, although the bridge sends `notifications/tools/list_changed` after every validated change once the host has listed tools. Newly named tools did appear after a reconnect, and calls with arguments missing from Claude's copy still worked, because the model can pass them and the broker validates them. A source review found the same: existing deferred definitions survive reconnect and resume, new names appear, and calls with the new argument shape reach the server; upstream report anthropics/claude-code#97369 describes it. This is observed host behavior, not root-caused, and it may change.
|
|
294
|
+
|
|
295
|
+
So PocoLobby never relies on a named tool's schema or description reaching a running session:
|
|
296
|
+
|
|
297
|
+
- **The current arguments are where the model reads them fresh.** `get_context` returns a compact `tool_guide`, generated from the same permission-filtered catalog and revision that discovery serves: each operation's purpose and its argument names with short meanings. Full types and limits stay in `list_operations`. The bootstrap text and the MCP instructions say that the named tools accept every argument in the guide, including attachments.
|
|
298
|
+
- **`call_operation` is the supported path for arguments a cached schema lacks**, not only for missing tools. It runs the same dispatch with the same validation. After an uncertain send, the agent keeps the original arguments and `client_request_id` when it switches between the named tool and `call_operation`, so the retry deduplicates.
|
|
299
|
+
- **A broker-only change reaches the model.** Such a release starts no new bootstrap, so the model would never reread `get_context`. The bridge therefore tells the model of a new catalog revision that adds or removes an operation or adds an argument, naming them and pointing at `list_operations` and `call_operation`, besides `tools/list_changed`. It appends that note to the next push it delivers, because a separate notice would wake an idle session for nothing it needs before it next works with PocoLobby; a `get_context` that shows the current revision makes the note unnecessary. It tells nothing before bootstrap completes, nothing for a revision the model already read, nothing for a change of wording only, and nothing on a reconnect with an unchanged revision; there is no polling and no new handshake.
|
|
300
|
+
- **New capabilities get new names where that is natural**, since new names do appear after a reconnect: `get_attachment` is a new bridge tool. `attachments` on `send_message` also works when Claude's copy of the schema does not list it, because the bridge decides from its current catalog, not from what the host cached; tests run that case. Versioned aliases such as `send_message_v2` are deferred.
|
|
301
|
+
|
|
302
|
+
Keep owner provisioning, credential rotation, delivery pause, runtime replacement, and recovery diagnostics in the administrative CLI/API. Do not give every agent administrator authority. Initial policy grants own inbox/context access, allowed peer sends, and explicit broadcast capability; history reads are sender/recipient scoped unless owner authorization says otherwise.
|
|
303
|
+
|
|
304
|
+
The implementation uses one repository and one npm distribution. `protocol.ts` owns schemas, `store.ts` owns transactional state, `server.ts` owns HTTP/WS transport and tool dispatch, `catalog.ts` owns the tool definitions, `presentation.ts` owns how pushes read, `client.ts` owns requests, `adapters/claude.ts` owns MCP integration, and `cli.ts`/`config.ts`/`launch.ts` own local setup and administration. The dashboard (the board) is `board.ts` (the service, chips and view), `board-github.ts` (notes at one commit), `board-summary.ts` (the summarizer request and validation) and `board-page.ts` (HTML). Settings (the console) are `console.ts` (their routes, sign-in, the local ticket and the hand-off), `console-page.ts` (HTML), `auth.ts` (codes, sessions, cookies and per-client limits) and `mail.ts` (the Postmark mailer). Node.js 24 or newer is required; dependency versions are recorded in the package manifest and lockfile. Provider-specific imports stay in the adapter, except the dashboard summarizer's Anthropic SDK in `board-summary.ts`.
|
|
305
|
+
|
|
306
|
+
SQLite records agents and credential hashes, current/runtime-history bindings, owner requests, messages with their kinds, structured fields and scheduling facts, recipient deliveries with their latest host-write time, recurring schedules, read receipts awaiting their push, notification attempts, checkpoints, administrative audit events, attachment metadata (content per SHA-256, attachments per uploader and name, their links to messages and schedules, and pending uploads), dashboards: per-lobby settings (agents' emoji, visibility, summary model and effort), notes sources per room, each source's last notes snapshot and summarizer attempts; and accounts and lobby owners, dashboard viewers, approved repositories, archived rooms, deleted lobby names, sign-in codes and per-address rate rows (as MACs), account-area and dashboard sessions and hand-offs (by token hash), and structured audit events. Attachment content itself is in files beside the database, named by SHA-256. Constraints and transactions enforce invariants. A broker creates a new database at the current schema and opens only that schema, refusing any other and leaving it untouched; a release that changes the schema brings its one upgrade step, run in one transaction when the broker starts, and the next release removes it. WAL, full synchronous commits, exclusive database ownership and failure tests provide the current durability baseline.
|
|
307
|
+
|
|
308
|
+
### Dashboards: a projection of the notes
|
|
309
|
+
|
|
310
|
+
The owner wants one page, readable on a phone, that shows what the agents wait on from the owner and what each of them is doing. The dashboard answers that at `<lobby>.pocolobby.com` without becoming a second source of truth. What waits on the owner and each agent's last 24 hours of work come straight from the store, and the model writes only what each agent does now and next. **It is a projection, never a task database**: the team's work state stays in the notes the agents already keep in Git, assignments stay messages, and the dashboard stores only what it needs to show the last good picture after a restart: each source's last notes snapshot and each summary with its provenance. Nothing on the dashboard assigns, completes or reorders work, and no agent reads it.
|
|
311
|
+
|
|
312
|
+
```mermaid
|
|
313
|
+
flowchart LR
|
|
314
|
+
G[GitHub: notes per room<br/>read-only token] -->|ref → one commit,<br/>304 when unchanged| F[Notes fetch<br/>per source]
|
|
315
|
+
F -->|one short transaction| S[(board_notes)]
|
|
316
|
+
M[(messages)] -->|newest 40 reports<br/>and assignments| R[Summarizer request]
|
|
317
|
+
A[(agents: enabled,<br/>not hidden)] --> R
|
|
318
|
+
S --> R
|
|
319
|
+
R -->|changed input,<br/>at most every 10 min| H[Claude Haiku 5.5<br/>structured output, no tools]
|
|
320
|
+
H -->|validated, clipped:<br/>now and next| X[(board_summaries)]
|
|
321
|
+
X --> V[Dashboard page]
|
|
322
|
+
A -->|live at render:<br/>owner requests, last message,<br/>connection, unacknowledged| V
|
|
323
|
+
M -->|live at render:<br/>sent and received<br/>per quarter hour| V
|
|
324
|
+
S -->|provenance| V
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
- **Two kinds of facts, kept apart.** Live facts come from the store at render time and are deterministic: enabled agents, what each says it waits on from the owner, connection, unacknowledged notified messages, and the messages each agent sent and received over the last 24 hours. The same messages give its `last_message_at`, which decides Away (30 minutes without one), and so does a bridge that is offline: a direct message to an offline agent counts as received, so without that check it would show Working for half an hour after its session ended. Agents report no work state. Only each agent's "now" and "next" come from the last good summary. The state chips are computed from live facts alone, by a fixed precedence on the server, so the model never picks a chip; a "next" the sources cannot tell is left out, never shown as an empty queue.
|
|
328
|
+
- **What waits on the owner is the agent's own word.** An agent calls `set_owner_requests` when it waits on the owner and sends 0 when it no longer does. The dashboard lists those live, longest waiting first, and nothing the model writes can add an item or remove one.
|
|
329
|
+
- **The message history is live.** Each agent's row shows the last 24 hours in 96 quarter hours, counted from the messages table at render time through an index on lobby and time: the messages the agent sent and the direct messages it received. It counts messages, which the broker stores anyway; it says nothing about the work done between them. The page fetches its live parts every 10 seconds while it is visible; each fetch answers from the store at once, and any notes check or summary it starts runs in the background.
|
|
330
|
+
- **Plan usage comes from each agent's status line.** The dashboard shows each agent's five-hour and weekly usage left, with the reset times, and the account's email on a private dashboard only. Claude Code passes its status line command the session's `rate_limits` for claude.ai Pro and Max accounts, so `pocolobby statusline`, set as the agent's status line, reads them, learns the signed-in account from `claude auth status --json` every 5 minutes, and sends a reading with the agent's credential when it changes or every 5 minutes (`PUT /v1/usage`). A status line holds the credential but belongs to no runtime, and a reading is a fact about the account rather than an action in the lobby, so the route needs no runtime binding. The broker keeps only the latest reading per agent, in memory: it is a gauge, gone on restart until the next report minutes later, and never history. The dashboard drops a window past its reset, marks a reading older than 30 minutes with its age, and hides one older than a day. Agents report their own accounts and nothing else maps agents to accounts: agents sharing one account show the same numbers, and the dashboard groups them by the email they report.
|
|
331
|
+
- **Weekly pace, not a budget engine.** The aim is each subscription's week spent evenly, about 14 points a day, ending at 0% left as it resets; the five-hour window is ignored. The line, the share of the seven-day window that has passed, needs no history, so the broker computes it from the latest reading and shows how far ahead or behind each account is: in `list_agents`, for the coordinator, and in the dashboard's Accounts section, where more than 5 points either way is off pace. It only reports: the coordinator reads the pace at its own scheduled checks and decides whom to rest and resume, under rules in its role, not in this repository. Saved usage history, burn-rate estimates, automatic pausing and pace messages from the broker were left out, because the latest reading already says where an account stands, and a broker that announced changes would lose what it last announced on a restart.
|
|
332
|
+
- **Notes at one commit per source.** Each room names one repository and its notes paths. A fetch resolves the ref to a commit with a conditional request, then reads every path at that commit, downloading only blobs whose SHA is new, so a push during a fetch cannot mix versions. All network work happens outside SQLite; one short transaction replaces the snapshot, and only if the source's settings are still those the fetch started with. The ETag advances only with the snapshot it describes, so a fetch that failed after a new commit is retried instead of being answered 304 forever. Files and sources are cut to a fixed share, visibly.
|
|
333
|
+
- **The summarizer runs on changes.** Its request is built deterministically from the snapshots' file contents (not their commit, so app commits that leave the notes alone change nothing), the lobby's newest 40 report and assignment messages (whatever their age, so an old one leaving a time window cannot change the input and pay for a summary with nothing new) and the ID and name of each agent the owner has not hidden; its system prompt is sent without prompt caching. It runs only when someone opens the dashboard (no timer, so a dashboard nobody looks at costs nothing), when the SHA-256 of that request differs from the last good summary's and at least the interval has passed since the last attempt (an input found unchanged is not built again for a notes interval, however often the page polls). Messages leave the input only by expiring, which says nothing new, so an input that only lost messages since the last answer this broker process stored, a summary or an answered failure, counts as unchanged, as does one on which the model already answered with a failure. A paid attempt whose record cannot be stored, not even as a failure (as on a full disk), would escape the daily ceiling, so it waits in memory and stops further paid attempts until it is stored. A lobby's summaries may cost at most $5 in any 24 hours, on the operator's key: Haiku with an open dashboard and changing input stays near $1.60 a day, while a lobby on Sonnet could otherwise spend $30 or more. Notes are cut beyond 200,000 characters, about 85K tokens at the 2.4 characters a token measured on one lobby's notes, so a request stays under Haiku's 100K-token price step; one runs at a time per lobby, with a two-minute timeout and one retry. Every attempt is stored with its usage and cost, failures included, so the owner's status shows what the dashboard really costs.
|
|
334
|
+
- **Failure keeps the last good picture.** A failed fetch keeps that source's snapshot, a failed or invalid summary keeps the last good one, and the page says so in plain words while still rendering every live fact. Missing secrets are named on the page.
|
|
335
|
+
- **Hosting.** One wildcard DNS record and certificate serve every lobby; the broker routes by `Host`. A lobby's host serves only its dashboard and its settings, the API's hosts never serve them, and reserved labels can never be lobby IDs. Without a domain, a local broker serves each lobby under `/<lobby>` beside its account area at the root and `/v1`, so names such as `settings`, `sign-in`, `open`, `api` and `v1` are reserved too. A dashboard is private (its owner and listed viewers, after email sign-in and a [hand-off](#identities-and-settings)) or public. Viewers are not principals of the API at all.
|
|
336
|
+
- **Settings per lobby.** `board_configs` is the lobby's dashboard settings row: agents' emoji and names, which agents are hidden (no row and no summary, though their owner requests still show), visibility, and the summary model (Claude Haiku 5.5 or Sonnet 5.5) and effort (low, medium or high), which enter the request and therefore its hash. Keys stay in the broker's environment; a per-lobby key or provider belongs here later.
|
|
337
|
+
|
|
338
|
+
## 8. Trust boundaries
|
|
339
|
+
|
|
340
|
+
Private means owner-controlled membership, not an unauthenticated endpoint. Require TLS for remote connections and authentication for every command, read, and WebSocket upgrade. Recheck authorization on writes and active connections after revocation. Keep secrets and message bodies out of routine logs.
|
|
341
|
+
|
|
342
|
+
Peer messages are attributed external input, never owner approval. Adapter instructions preserve that distinction; message text cannot set routing metadata or replace role instructions. Do not enable Claude channel permission relay for agent messaging. Keep the owner's chosen Claude permission mode separate from PocoLobby authorization.
|
|
343
|
+
|
|
344
|
+
### The dashboard's summarizer and viewers
|
|
345
|
+
|
|
346
|
+
Notes and messages are written by agents and are untrusted data when the dashboard summarizes them. The request carries them JSON-encoded inside one user message, after a fixed system prompt that says they are data and that their instructions are never followed. The model has no tools and its output reaches nothing but the page: it is validated against a schema (structure strict, lengths clipped, unknown agents dropped) and every string, like every note path, owner request and the `Host` label, is HTML-escaped on the server. The page runs only its own nonce'd script under a Content-Security-Policy without inline handlers. What injection can still do is make a summary wrong: a false current task or a queue that is not there. That is why Waiting for you, the message history and the chips beside it are computed without the model, and why the dashboard only shows and never acts.
|
|
347
|
+
|
|
348
|
+
The GitHub token is read-only and lives only in the broker's environment, never in the database, the page or the status; Settings show only whether a key is set. The Anthropic key and the Postmark token likewise. A dashboard viewer is not an API principal: the dashboard cookie is host-only on the lobby's host, lobby hosts never read `Authorization` and serve no API route, and API routes never read cookies. Adding a viewer, or making a dashboard public, shares what the summaries paraphrase from that lobby's notes and reports, what its agents say they wait on, when they sent and received messages, and how much of their Claude plans is left (the account email only to a private dashboard's viewers), so treat it like the notes themselves; Settings say so next to the Public choice.
|
|
349
|
+
|
|
350
|
+
### Local file reading
|
|
351
|
+
|
|
352
|
+
Attachments give the bridge, which runs with the agent's OS permissions, a way to send local files to other machines. A prompt-injected or confused model could try to send secrets, so the bridge refuses before reading, with a reason, and sends nothing:
|
|
353
|
+
|
|
354
|
+
- **Only the model's own tool call names paths.** The bridge reads files only for the `attachments` argument of a call, and only the paths listed there. It never reads anything a peer message names, and a broker cannot mark another argument as file paths: the bridge honors only `attachments`. Received attachments are never opened or saved automatically.
|
|
355
|
+
- **Inside the user's home directory or a system temporary folder**, checked as written and again after resolving symbolic links, so `..` and links cannot leave them. The temporary folders are `os.tmpdir()` and, on macOS and Linux, `/tmp`, because Claude Code keeps a session's scratch files there (under `AppData\Local\Temp` on Windows, `/tmp/claude-<uid>` on macOS). A path outside both is refused as "outside your home directory and the system temporary folder".
|
|
356
|
+
- **No dotfiles and nothing under a dot directory**, such as `~/.ssh`, `~/.aws`, `~/.pocolobby`, `~/.claude`, `.env` files or a repository's `.git`. This also refuses files in Claude Code worktrees under `.claude/worktrees`; save screenshots elsewhere, such as `~/Pictures`.
|
|
357
|
+
- **No application-data directories**: `~/Library` on macOS except `~/Library/CloudStorage` and `~/Library/Mobile Documents` (Dropbox and iCloud), and `AppData` on Windows except its temporary folder. Inside a temporary folder every other rule still applies: no dot directories, no credential names or contents, never a PocoLobby credential, and no symbolic links to elsewhere.
|
|
358
|
+
- **Nothing named like a credential**: `.env*`, `*.pem`, `*.key`, `key.*`/`keys.*`, `api-key`, `private_key`, `ssh key` and similar compounds (api, private, secret, access, ssh, auth, signing, encryption, master), `*keystore*`/`*keychain*`/`*keyring*`/`*keyfile*`, `id_*`, `*secret*`, `*credential*`, `*password*`, `*passwd*`, `*.p12`, `*.pfx`, in any case. Everyday names such as `keyframe-panel.png` or `key-light.png` are allowed.
|
|
359
|
+
- **No private key or token in a text file**: a document containing a `-----BEGIN … PRIVATE KEY-----` block, OAuth token fields (`refresh_token`, `access_token`, `client_secret`), or what looks like an access key or token is refused whatever its name: an `aws_secret_access_key =` line, an `AKIA…` key ID, a GitHub `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` or `github_pat_` token, an Anthropic `sk-ant-` key or a Slack `xoxa-`, `xoxb-`, `xoxp-` or `xoxs-` token.
|
|
360
|
+
- **Never a PocoLobby credential, whatever its name.** A configuration need not live under `~/.pocolobby`; `~/PocoLobby/agent.json` is just as valid. So the CLI passes the bridge the resolved path, device and inode of the configuration file it loaded, and the bridge refuses that file, a link to it and a hard link of it before reading it. Any file that parses as a PocoLobby configuration, a JSON object with string `url` and `token` fields, is refused as well: a copy, the owner's configuration or another agent's.
|
|
361
|
+
- **No symbolic links to elsewhere.** A linked file is accepted only when its target is in the same directory and passes every rule; a linked directory, such as `~/Dropbox` pointing into `~/Library/CloudStorage`, is accepted only when the resolved path passes every rule.
|
|
362
|
+
- **Only the allowed types, by content**: PNG, JPEG, GIF and WebP recognized by their magic bytes, and `md`, `txt`, `csv`, `json` and `log` documents and `mjs`, `cjs`, `js`, `ts`, `py`, `sh` and `swift` source files that are valid UTF-8. A regular, non-empty file; the bridge opens the checked path without following a link swapped in meanwhile.
|
|
363
|
+
|
|
364
|
+
`get_attachment`'s `save_to` writes under the same rules: an absolute directory in the home directory or a system temporary folder, not hidden, not application data, and a file name that does not look like a credential. It never replaces an existing file, and it saves a file named `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md` or `AGENTS.override.md` (in any case) as `<name> (attachment).md`, because Claude Code and Codex load those names as instructions and a peer's file must stay a file. A missing directory is created with its missing parents, because agents file evidence into new Dropbox folders. The bridge first resolves the deepest existing directory above it, through any links, and checks the whole planned path against the rules there, so a link to outside home or into a dot directory cannot be used to create a folder; a link to nothing or a path it cannot check is refused rather than taken for a missing directory. It creates the folder only after the download is verified, then checks the resolved result again. These rules limit what a confused model can send; they are not a sandbox against code running as the same user, which can read those files anyway.
|
|
365
|
+
|
|
366
|
+
Credentials isolated by file/profile are not a sandbox against another process with the same OS permissions. Sessions sharing one machine need appropriate filesystem/workspace isolation if protection between them is required. Worktree choice and concurrent file ownership remain part of the coordinator inventory.
|
|
367
|
+
|
|
368
|
+
## 9. Deployment design
|
|
369
|
+
|
|
370
|
+
Use **one always-on Fly Machine**, with SQLite on a mounted Fly Volume. The root filesystem is ephemeral; a volume is local to one physical server and has no automatic replication. This is a deliberate private-service availability tradeoff: deploys and host failures can cause downtime. Never scale this design into independent SQLite writers. Run migrations on the machine with the mounted volume, not in Fly's volume-less release command. [Fly Volumes](https://docs.fly.io/volumes/overview)
|
|
371
|
+
|
|
372
|
+
Disable Fly automatic stopping and configure restart behavior, database readiness checks, graceful socket closure, and jittered reconnect. [Fly autostop configuration](https://docs.fly.io/launch/autostop-autostart)
|
|
373
|
+
|
|
374
|
+
Use database-consistent backups stored off the machine, with measured restore tests. Volume snapshots are supplemental. Backup frequency defines the possible data loss after permanent volume failure; ordinary process-crash durability is a separate guarantee. Pick and test the backup destination and recovery objectives before production deployment.
|
|
375
|
+
|
|
376
|
+
Restore is a separate recovery mode: keep dispatch paused, invalidate restored runtime bindings, and establish a new broker incarnation before clients reconnect. A stale backup can roll back token revocations; invalidate restored agent tokens and provision replacements before reopening access. Require fresh bootstrap and reconcile potentially already-executed messages, receipts, and checkpoints before normal delivery. Restored history must not be treated as permission to repeat uncertain work.
|
|
377
|
+
|
|
378
|
+
The selected app is `pocolobby`, with `https://pocolobby.fly.dev` as the Fly endpoint target. Cloudflare can host DNS for the custom domain, with Fly handling TLS. DNS-only is sufficient. Deployment and certificate checks must establish the actual working endpoints. If Cloudflare proxying is enabled, expect WebSocket disconnections and reconnect; proxy protection does not replace application authorization. [Cloudflare WebSockets](https://developers.cloudflare.com/network/websockets/)
|
|
379
|
+
|
|
380
|
+
A Worker with one SQLite-backed Durable Object per lobby is a credible alternative: it can combine storage, coordination, and WebSockets. Its runtime and hibernation semantics would become part of the application architecture. Prefer Node/Fly for the portable npm/self-hosted goal; revisit Durable Objects if hosted-only operation becomes the priority. Do not maintain two backends initially. [Durable Object WebSockets](https://developers.cloudflare.com/durable-objects/best-practices/websockets/)
|
|
381
|
+
|
|
382
|
+
## 10. What the critical review changed
|
|
383
|
+
|
|
384
|
+
Three subagents independently reviewed Claude feasibility, messaging reliability, and architectural weaknesses, then challenged each other's proposals through the lead reviewer.
|
|
385
|
+
|
|
386
|
+
| Initial assumption or alternative | Objection | Resolution |
|
|
387
|
+
| --------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------- |
|
|
388
|
+
| Replace transport and memory will follow | Session context and provider profiles are separate | Add explicit role/checkpoint/bootstrap contract |
|
|
389
|
+
| Prevent duplicate processing | No atomic boundary spans notification, model, and tools | Deduplicate stored sends/receipts; expose uncertain execution |
|
|
390
|
+
| WebSocket connection means available agent | Model can be busy, quota-blocked, or detached | Report independent health facts |
|
|
391
|
+
| One lease makes work takeover safe | Old tools can continue outside the broker | Fence broker access; require operational work reconciliation |
|
|
392
|
+
| A socket lease lets restarts reclaim | Sleep or a deploy would hand the identity to anyone | Conversation-derived runtime; new conversations need the owner |
|
|
393
|
+
| Always push only a wake-up hint | Adds a read round trip without proving understanding | Small bodies for live traffic; coalesced backlog/overflow hints |
|
|
394
|
+
| Keep retrying until the agent replies | Can consume quota indefinitely or start loops | Persist bounded retries and show pending attention |
|
|
395
|
+
| Reminders as session crons | Die with the session; restarts lose them | Broker-held `deliver_at` and daily schedules per identity |
|
|
396
|
+
| Read receipts as messages | Would need acknowledging and could trigger receipts | A separate, coalesced event that is never acknowledged |
|
|
397
|
+
| Ship each feature in the adapter | Every feature needed a package update and agent reloads | Broker tool catalog and dispatch; extensible event envelope |
|
|
398
|
+
| Download tool code to the bridge | Remote code in every agent's process | Data-only catalog; the broker runs tools through its own routes |
|
|
399
|
+
| Default every message to `request` | Told recipients to answer FYIs nobody could label | No kind unless the sender chose one |
|
|
400
|
+
| Pass Dropbox paths for screenshots and files | Machines resolve paths differently; models see no image | Bridge-read attachments, opened as image blocks |
|
|
401
|
+
| Upload attachments on demand from any message | Peer text could make a bridge read local files | Only the model's own `attachments` argument, under path rules |
|
|
402
|
+
| Rely on refreshed named tool schemas | Claude Code keeps a deferred tool's first schema | `tool_guide` in `get_context`, `call_operation`, change notices |
|
|
403
|
+
| Use existing Remote Control command unchanged | Server mode refuses MCP and Channel launch flags | Relaunch interactively with `--remote-control` |
|
|
404
|
+
| One universal MCP adapter | Current Claude Channels and ChatGPT Events differ | Independent adapters around a stable core |
|
|
405
|
+
| Cloudflare-only broker | Strong hosted fit, weaker ordinary self-hosting | Prefer Fly/Node first |
|
|
406
|
+
| A viewer password per dashboard | Shared passwords; the owner finds email codes enough | Email-code sign-in; viewers listed by address |
|
|
407
|
+
| One cookie for every dashboard subdomain | It would reach every lobby and the account area | A single-use hand-off and a host-only cookie per host |
|
|
408
|
+
| Delete a room the owner retires | Holds messages agents already accepted | Archive: deliver what was accepted, reserve the name |
|
|
409
|
+
|
|
410
|
+
The unresolved decisions are operational: current coordinator/task ownership, shared checkout versus worktrees, tested Channels launch behavior, and acceptable backup recovery objectives. They do not require a distributed broker or a public SaaS platform.
|