diff --git a/docs/plans/workspace-sharing/README.md b/docs/plans/workspace-sharing/README.md index 71e0460a..4568a10f 100644 --- a/docs/plans/workspace-sharing/README.md +++ b/docs/plans/workspace-sharing/README.md @@ -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 " so nobody is surprised.