docs(plans): workspace sharing — open questions answered

This commit is contained in:
2026-11-06 10:23:40 +07:00
parent 9d28807f2c
commit e7573f13d9
+51 -24
View File
@@ -1,6 +1,6 @@
# Workspace & session sharing — plan
Status: **draft, waiting for review** (2026-11-06). Nothing here is built.
Status: **reviewed, open questions answered** (2026-11-06). Nothing here is built.
Server mode only. In local mode there are no users, so there is nothing to share
and every piece below is a no-op (`isServerMode()`).
@@ -15,6 +15,11 @@ session. Decisions taken with the repo owner (2026-11-06):
| What may a person it is shared with do? | **Always collaborate** — the same as the owner inside that workspace/session: type into panes, send prompts, start sessions, commit, push. No view-only level. |
| Unit of sharing | **Both** — a whole workspace (every session, present and future) or a single session. |
| Who pays for a session a collaborator starts? | **The person who opens it** — its tokens, cost and spend quota are theirs, not the workspace owner's. |
| Whose AI subscription does a collaborator's run use? | **Their own, or none**: only a pool account of kind `token` that the collaborator may use (passed by env). With a `login`-kind pool or no pool the run is **refused** (`ENOCOLLABACCOUNT`) — a collaborator never spends the owner's subscription. |
| Spend quota | **Only the payer's** (the person who started or resumed the run). The owner's quota is not involved. |
| A collaborator resumes someone else's session | **The resumer pays from then on** — billing is per RUN, not per session (below). |
| Session-only share and git | **Allowed** — commit/push in that session's tree, like everything else. |
| Sharing with an analyst | **Allowed** — a shared workspace counts as their own for writes. |
## The one fact everything else follows from (S3)
@@ -93,12 +98,30 @@ everything else becomes "owner **or** collaborator".
- `spawnRun` takes `startedBy = req.user.id`; writes `dashboard_runs.started_by`.
- `bindRunSession` (`server/routes/hooks.js`) stamps `sessions.owner_id = started_by` when it differs from the lane owner. `sessions.owner_id` already exists (remote clients) and `scope.js`, `session-account.js`, `users.js` honour it.
- `server/routes/users.js` ownership CTE: `sessions.owner_id` must WIN over the lane owner (today it is a UNION — a session could count twice). Same in `user-quota.js` spend (`hourlyTokenRows` + `calculateCost`, never a second formula).
- Spend quota: `assertSpawnAllowed(startedBy)` instead of the lane owner, and `hook-handler.js`'s `UserPromptSubmit` check reads the flag of the session's billed user (the quota file is per Linux account today → becomes per account + billed user id; see open question 2).
- A resume by a collaborator: billed to the session's existing owner (the opener stays the payer), unless decided otherwise (open question 3).
3. **AI login of a collaborator's session.** Which subscription is actually
consumed is separate from who is billed in AgentHub:
- Pool account of kind `token`: passed as `CLAUDE_CODE_OAUTH_TOKEN` through tmux `-e` / the launch file, so the collaborator's **own** pool pick (`pickAccount(startedBy)`) is used — real separation.
- Pool account of kind `login` (or no pool): the login lives in the owner's `~/.claude/.credentials.json`; a second login cannot be placed there while the owner's panes run (existing pool rule). The session then runs on the **owner's** subscription quota, only billed to the collaborator in AgentHub. The session row shows this.
- Spend quota: `assertSpawnAllowed(startedBy)` instead of the lane owner, and `hook-handler.js`'s `UserPromptSubmit` check reads the flag of the RUN's payer (the quota file is per Linux account today → per account + payer user id, passed to the pane as `AGENTHUB_PAYER_ID`).
- **Billing is per run, not per session.** A session can be paid by several
people over time: whoever starts or resumes a run pays for everything that
run does, including what anyone types into its live pane (keystrokes cannot
be attributed; the run's starter can). New table
`session_payers(session_id, user_id, from_at)` — one row per run start whose
payer differs from the previous one. `hourlyTokenRows` books a session's
tokens per UTC hour already; each hour goes to the payer whose `from_at` is
the latest ≤ that hour (a run started mid-hour takes the hour from the next
one on — documented rounding, never a second cost formula). With no row the
session's owner pays, as today. `sessions.owner_id` keeps meaning "who
opened it" (visibility); `session_payers` only moves money.
3. **AI login of a collaborator's run — their own or refused.**
- The collaborator's pool pick (`pickAccount(startedBy)`, their group, pin
and own choice) must be of kind `token`: it goes in as
`CLAUDE_CODE_OAUTH_TOKEN` through tmux `-e` / the launch file, overriding
the owner's home login for that pane only.
- A `login`-kind pick, or no pool account for them: `spawnRun` refuses with
`409 ENOCOLLABACCOUNT` before anything starts, for a new session AND for a
resume. The home's Start button says why. The owner's login is never used
for a collaborator's run.
- Codex / other tools: same rule with their own env-only kinds (Codex API key
`AGENTHUB_POOL_KEY`, Cursor `CURSOR_API_KEY`); a ChatGPT login (written into
the home as `auth.json`) is refused like a Claude `login`.
4. **Pane token.** A collaborator's session gets a pane token of the
collaborator, **scoped to that lane**: `dashboard_runs.pane_token_hash` plus a
`scope_lane_id`, and `userFromPaneToken` refuses anything outside that lane.
@@ -146,9 +169,10 @@ collaborator"), `README.md` feature list.
1. **S1 — Workspace sharing, visibility + live panes.** Tables, scope.js,
broadcastAudience, Share dialog, sidebar group, attach/type, git writes.
Collaborators can join live panes but cannot start sessions yet.
2. **S2 — Collaborator sessions billed to the opener.** `started_by`,
`sessions.owner_id` stamping, users.js / quota precedence, pane token scope,
pool `token` env.
2. **S2 — Collaborator runs on their own account, billed per run.**
`started_by`, `sessions.owner_id` stamping, `session_payers`, users.js /
quota precedence (and the double count fixed), pane token scope, pool
`token` env, `ENOCOLLABACCOUNT`.
3. **S3 — Session-only sharing.** `session_shares`, its scope rules and UI.
4. **S4 — Presence + audit of prompts.**
@@ -157,18 +181,21 @@ with and without a share), `server-mode-review-fixes.test.js` style 404s for a
non-collaborator, users/quota attribution, websocket audience, and the client
dialog/sidebar tests.
## Open questions
## Answered questions (2026-11-06)
1. **Which subscription does a collaborator's session use** when the pool is
`login`-kind (or empty)? Proposal: the owner's, shown on the session; the
alternative (refuse a collaborator session without a `token` pool account) is
stricter.
2. **Spend quota of a collaborator inside someone else's workspace** — enforce
the collaborator's own quota (proposal) or both theirs and the owner's?
3. **A collaborator resumes the owner's ended session** — billed to the session's
owner (proposal) or to whoever resumes?
4. **Session-only share and git**: may a session collaborator commit/push, or
only use the Changes panel read-only? (The table above says commit is
allowed, following "always collaborate".)
5. **Analysts**: does sharing to an analyst let them write in that workspace
(proposal: yes — it becomes "their own" for writes)?
1. A collaborator's run uses **their own `token`-kind pool account, or is
refused** (`ENOCOLLABACCOUNT`) — never the owner's subscription.
2. Spend quota: **only the payer's**.
3. Resume by someone else: **the resumer pays from then on** (per-run billing,
`session_payers`).
4. Session-only share: **commit/push allowed**.
5. Analysts: **sharing gives write** in that workspace.
## Still to settle while building
- The per-hour rounding of `session_payers` (a run started at 10:40 by Bob in a
session Alice paid at 10:05): the hour goes to the earlier payer and Bob pays
from 11:00, or token rows get a finer key. Phase 2 measures how often it
matters before adding a finer key.
- Typing into a live pane started by someone else costs the run's starter. The
pane's header shows "billed to <name>" so nobody is surprised.