lab: run multiple concurrent instances per project #50

Closed
opened 2026-05-27 12:22:48 +02:00 by dominik.polakovics · 0 comments

Problem Statement

On the lab server I can only run one Claude session per project at a time — the tmux session name is the project name, so Start is capped at exactly one claude --remote-control per project. When one instance is busy with a long-running task, I'm blocked: I can't spin up a second session to investigate something else in the same project until the first is free.

Solution

lab lets me run several concurrent claude --remote-control instances of the same project. The index page groups instances under their project. I start an extra instance with one click (optionally giving it a label so I can tell which is which), open each instance in claude.ai independently, and stop them one at a time or all at once — up to a global safety cap. My primary work and my side-investigation run side by side against the same code.

User Stories

  1. As a developer, I want to start a second instance of a project while the first is busy, so that I can investigate something without interrupting in-flight work.
  2. As a developer, I want every instance of a project to run in that project's working directory, so that all instances see the same code I'm working on.
  3. As a developer, I want my first/only instance to keep the plain project name, so that the common single-session case stays simple and any link I already have keeps working.
  4. As a developer, I want extra instances to be auto-numbered, so that I can start one quickly without inventing a name.
  5. As a developer, I want to optionally label an extra instance, so that I can tell at a glance which instance is doing what.
  6. As a developer, I want an instance's label to appear in its tmux session name, so that tmux ls and tmux attach are self-describing when I drop to a shell.
  7. As a developer, I want instances grouped under their project on the index page, so that I see all instances of a project together and the list stays as short as the project list.
  8. As a developer, I want each running instance to show its own "Open in claude.ai" link, so that I land in the right conversation.
  9. As a developer, I want to stop an individual instance, so that I can free resources for just the one I'm done with.
  10. As a developer, I want a per-project "Stop all", so that I can clear a project's instances in one click.
  11. As a developer, I want a stopped instance to disappear from the list immediately, so that the UI only shows what is actually running.
  12. As a developer, I want the project row to remain after all its instances stop, so that I can start it again — it is still a project.
  13. As a developer, I want a global cap on concurrent instances, so that I don't accidentally exhaust the dev VM's memory.
  14. As a developer, I want Start / New instance disabled with a clear hint once I hit the cap, so that I understand why I can't start more.
  15. As a developer, I want the project recency ordering preserved, so that recently-used projects stay near the top even with multiple instances.
  16. As a developer, I want starting any instance to refresh its project's recency, so that an active project sorts to the top.
  17. As a developer, I want freed instance numbers reused, so that numbers stay small as I start and stop instances over a session.
  18. As a developer, I want labels sanitized to safe characters, so that a label can never break the session-name scheme or tmux targeting.
  19. As a developer, I want "Stop all" to affect only the targeted project, so that an instance of a similarly-named project (e.g. foo vs foobar) is never killed by mistake.
  20. As a developer, I want each instance's deep link forgotten when it stops, so that stale links don't linger in the UI.
  21. As a developer, I want lab to recover gracefully after a restart, so that still-live sessions are shown and links for dead sessions are pruned.
  22. As a developer, I want the Claude login flow unaffected, so that authentication keeps working exactly as before.
  23. As a developer, I want the login session to not count against the cap, so that being mid-login doesn't consume an instance slot.
  24. As a developer, I want to filter the project list by name, so that I can find a project quickly even with several instances running.

Implementation Decisions

  • Shared working directory (deliberate). All instances of a project run in the same checkout, working tree, and git state — no git worktrees and no clones. Concurrency safety is the operator's responsibility. This is a conscious simplicity trade-off, accepting that instances run in --permission-mode auto and can edit the same files. (The maintainer explicitly declined an ADR; the rationale is recorded here instead.)
  • New deep module — instance identity (pure, no I/O). Owns the entire session-name scheme: compose (project, number, label) → name; parse name → (project, number, label); allocate the lowest free slot (≥1 for an unlabeled instance, ≥2 when a label is given, where slot 1 is the bare project name); and belongsTo(name, project) using exact-or-~-prefix matching. This is the riskiest logic, isolated behind a small interface.
  • Session-name scheme. Instance #1 / lone instance = the bare project name. Extra instances = project~N or project~N~label. The separator is ~ because the project-name sanitizer only ever emits [A-Za-z0-9._-] (so ~ can never collide with a real project's derived name — instance detection is unambiguous even against a project literally named foo-2), and because ~ is the one separator with no special meaning in tmux's target-pane parser, on which the existing bare-name capture/send path depends (@ % $ : . = are all reserved by tmux).
  • Labels are sanitized with the same rules as project names and live only in the session name (recovered by parsing on each render). A label is fixed at creation.
  • Store. The per-instance claude.ai deep link is keyed by full session name (instance #1's name coincides with the project name, so existing stored entries stay valid). The project-level last-opened timestamp remains keyed by project name, drives the recency sort, and is stamped whenever any instance of the project starts. Deep links are ephemeral: cleared when an instance stops and pruned on startup for any session no longer alive.
  • HTTP surface. Starting takes a project plus an optional label and allocates a slot. Stopping targets a single instance by name. A new stop-all action targets a whole project. The page model carries a global "at cap" flag.
  • Global cap. lab counts live instances (excluding the login session) from the tmux listing it already performs each render, and disables Start / New instance once the count reaches a configurable limit, surfaced as a -max-instances flag defaulting to 6.
  • UI. The index groups instances under each project. Each live instance renders with its own Open + Stop. The project shows a single Start when idle, a "+ New instance" control with an optional label field when it has at least one live instance, and a "Stop all". Stopped instances are not rendered. The existing name filter continues to operate on project rows.
  • tmux sessions wrapper stays essentially unchanged — it already operates on a session name; that name is now an instance name.

Testing Decisions

Good tests assert on externally observable behavior, not private fields: the session name produced, which pane actually receives keystrokes, what the store returns across operations, and the page rows/flags the handlers render. All four modules below are in scope for tests.

  • Instance identity (pure unit tests). Compose/parse round-trip for the bare-#1, numbered, and numbered+label forms; slot allocation including reuse of a freed number and "a label forces slot ≥2"; belongsTo prefix-safety (foo vs foobar, foo~2 vs foobar~2). Prior art: the existing sessionName sanitization unit tests.
  • Sessions coexistence (integration, real tmux). With foo and foo~2 alive at once, capture / send / stop each hit the correct pane via tmux's exact-match preference, and stopping one leaves the other running. Prior art: the existing tmux-backed sessions tests (a real tmux is provided during the check phase).
  • Store keying (unit). Per-instance deep link set / forget / prune-on-startup keyed by session name; the project recency timestamp survives a stopped #1 while an extra instance is still running. Prior art: the existing store persistence tests.
  • Handlers grouping/cap (unit). Snapshot groups live sessions into one project row with nested instances in slot order; the at-cap flag disables Start / New once the live count reaches the limit; stop-all stops only the targeted project's instances. Prior art: the existing handler tests.

Note: lab's Go tests do not run in the repo's pre-commit hook (it is nix-eval-only). Run go test ./..., go vet, and a build locally before opening the PR.

Out of Scope

  • Isolated working trees (git worktrees) or per-instance clones — instances deliberately share one checkout.
  • Detecting or preventing concurrent-edit conflicts between instances of the same project.
  • Labeling the first / lone instance (slot 1 is always the bare project name), and renaming a label without stopping and restarting the instance.
  • Per-project instance caps (only a single global cap is in scope).
  • Sorting instances within a project by anything other than slot number, or any per-instance recency tracking.
  • Any change to the Claude login/auth flow beyond ensuring the login session does not count against the cap.
  • Persisting stopped-instance state or history.

Further Notes

  • An ADR was explicitly declined by the maintainer; the ~-separator rationale and the shared-directory decision are captured in this PRD instead.
  • Sharp edges to handle (not redesign): tmux prefix-matching is safe only because lab targets a session name immediately after starting it and relies on tmux's exact-match preference — the coexistence test guards this. The existing SeedTrust race stays at its accepted single-user level (same-directory starts write the same idempotent trust value).
  • The dev microvm has 12 GB RAM / 4 vCPU and an OOM is contained to the VM; the default cap of 6 is a guardrail, not a precise resource budget — tune -max-instances if needed.
  • Scope is confined to the lab module on the dev microvm (hosted under fw); per the lab vendoring ADR, only fw dry-builds when lab changes.
## Problem Statement On the lab server I can only run one Claude session per project at a time — the tmux session name *is* the project name, so Start is capped at exactly one `claude --remote-control` per project. When one instance is busy with a long-running task, I'm blocked: I can't spin up a second session to investigate something else in the same project until the first is free. ## Solution lab lets me run several concurrent `claude --remote-control` instances of the same project. The index page groups instances under their project. I start an extra instance with one click (optionally giving it a label so I can tell which is which), open each instance in claude.ai independently, and stop them one at a time or all at once — up to a global safety cap. My primary work and my side-investigation run side by side against the same code. ## User Stories 1. As a developer, I want to start a second instance of a project while the first is busy, so that I can investigate something without interrupting in-flight work. 2. As a developer, I want every instance of a project to run in that project's working directory, so that all instances see the same code I'm working on. 3. As a developer, I want my first/only instance to keep the plain project name, so that the common single-session case stays simple and any link I already have keeps working. 4. As a developer, I want extra instances to be auto-numbered, so that I can start one quickly without inventing a name. 5. As a developer, I want to optionally label an extra instance, so that I can tell at a glance which instance is doing what. 6. As a developer, I want an instance's label to appear in its tmux session name, so that `tmux ls` and `tmux attach` are self-describing when I drop to a shell. 7. As a developer, I want instances grouped under their project on the index page, so that I see all instances of a project together and the list stays as short as the project list. 8. As a developer, I want each running instance to show its own "Open in claude.ai" link, so that I land in the right conversation. 9. As a developer, I want to stop an individual instance, so that I can free resources for just the one I'm done with. 10. As a developer, I want a per-project "Stop all", so that I can clear a project's instances in one click. 11. As a developer, I want a stopped instance to disappear from the list immediately, so that the UI only shows what is actually running. 12. As a developer, I want the project row to remain after all its instances stop, so that I can start it again — it is still a project. 13. As a developer, I want a global cap on concurrent instances, so that I don't accidentally exhaust the dev VM's memory. 14. As a developer, I want Start / New instance disabled with a clear hint once I hit the cap, so that I understand why I can't start more. 15. As a developer, I want the project recency ordering preserved, so that recently-used projects stay near the top even with multiple instances. 16. As a developer, I want starting any instance to refresh its project's recency, so that an active project sorts to the top. 17. As a developer, I want freed instance numbers reused, so that numbers stay small as I start and stop instances over a session. 18. As a developer, I want labels sanitized to safe characters, so that a label can never break the session-name scheme or tmux targeting. 19. As a developer, I want "Stop all" to affect only the targeted project, so that an instance of a similarly-named project (e.g. `foo` vs `foobar`) is never killed by mistake. 20. As a developer, I want each instance's deep link forgotten when it stops, so that stale links don't linger in the UI. 21. As a developer, I want lab to recover gracefully after a restart, so that still-live sessions are shown and links for dead sessions are pruned. 22. As a developer, I want the Claude login flow unaffected, so that authentication keeps working exactly as before. 23. As a developer, I want the login session to not count against the cap, so that being mid-login doesn't consume an instance slot. 24. As a developer, I want to filter the project list by name, so that I can find a project quickly even with several instances running. ## Implementation Decisions - **Shared working directory (deliberate).** All instances of a project run in the same checkout, working tree, and git state — no git worktrees and no clones. Concurrency safety is the operator's responsibility. This is a conscious simplicity trade-off, accepting that instances run in `--permission-mode auto` and can edit the same files. (The maintainer explicitly declined an ADR; the rationale is recorded here instead.) - **New deep module — instance identity (pure, no I/O).** Owns the entire session-name scheme: compose `(project, number, label) → name`; parse `name → (project, number, label)`; allocate the lowest free slot (≥1 for an unlabeled instance, ≥2 when a label is given, where slot 1 is the bare project name); and `belongsTo(name, project)` using exact-or-`~`-prefix matching. This is the riskiest logic, isolated behind a small interface. - **Session-name scheme.** Instance #1 / lone instance = the bare project name. Extra instances = `project~N` or `project~N~label`. The separator is `~` because the project-name sanitizer only ever emits `[A-Za-z0-9._-]` (so `~` can never collide with a real project's derived name — instance detection is unambiguous even against a project literally named `foo-2`), and because `~` is the one separator with no special meaning in tmux's target-pane parser, on which the existing bare-name capture/send path depends (`@ % $ : . =` are all reserved by tmux). - **Labels** are sanitized with the same rules as project names and live only in the session name (recovered by parsing on each render). A label is fixed at creation. - **Store.** The per-instance claude.ai deep link is keyed by full session name (instance #1's name coincides with the project name, so existing stored entries stay valid). The project-level last-opened timestamp remains keyed by project name, drives the recency sort, and is stamped whenever any instance of the project starts. Deep links are ephemeral: cleared when an instance stops and pruned on startup for any session no longer alive. - **HTTP surface.** Starting takes a project plus an optional label and allocates a slot. Stopping targets a single instance by name. A new stop-all action targets a whole project. The page model carries a global "at cap" flag. - **Global cap.** lab counts live instances (excluding the login session) from the tmux listing it already performs each render, and disables Start / New instance once the count reaches a configurable limit, surfaced as a `-max-instances` flag defaulting to 6. - **UI.** The index groups instances under each project. Each live instance renders with its own Open + Stop. The project shows a single Start when idle, a "+ New instance" control with an optional label field when it has at least one live instance, and a "Stop all". Stopped instances are not rendered. The existing name filter continues to operate on project rows. - **tmux sessions wrapper** stays essentially unchanged — it already operates on a session name; that name is now an instance name. ## Testing Decisions Good tests assert on externally observable behavior, not private fields: the session name produced, which pane actually receives keystrokes, what the store returns across operations, and the page rows/flags the handlers render. All four modules below are in scope for tests. - **Instance identity (pure unit tests).** Compose/parse round-trip for the bare-#1, numbered, and numbered+label forms; slot allocation including reuse of a freed number and "a label forces slot ≥2"; `belongsTo` prefix-safety (`foo` vs `foobar`, `foo~2` vs `foobar~2`). Prior art: the existing `sessionName` sanitization unit tests. - **Sessions coexistence (integration, real tmux).** With `foo` and `foo~2` alive at once, capture / send / stop each hit the correct pane via tmux's exact-match preference, and stopping one leaves the other running. Prior art: the existing tmux-backed sessions tests (a real tmux is provided during the check phase). - **Store keying (unit).** Per-instance deep link set / forget / prune-on-startup keyed by session name; the project recency timestamp survives a stopped #1 while an extra instance is still running. Prior art: the existing store persistence tests. - **Handlers grouping/cap (unit).** Snapshot groups live sessions into one project row with nested instances in slot order; the at-cap flag disables Start / New once the live count reaches the limit; stop-all stops only the targeted project's instances. Prior art: the existing handler tests. Note: lab's Go tests do **not** run in the repo's pre-commit hook (it is nix-eval-only). Run `go test ./...`, `go vet`, and a build locally before opening the PR. ## Out of Scope - Isolated working trees (git worktrees) or per-instance clones — instances deliberately share one checkout. - Detecting or preventing concurrent-edit conflicts between instances of the same project. - Labeling the first / lone instance (slot 1 is always the bare project name), and renaming a label without stopping and restarting the instance. - Per-project instance caps (only a single global cap is in scope). - Sorting instances within a project by anything other than slot number, or any per-instance recency tracking. - Any change to the Claude login/auth flow beyond ensuring the login session does not count against the cap. - Persisting stopped-instance state or history. ## Further Notes - An ADR was explicitly declined by the maintainer; the `~`-separator rationale and the shared-directory decision are captured in this PRD instead. - Sharp edges to handle (not redesign): tmux prefix-matching is safe only because lab targets a session name immediately after starting it and relies on tmux's exact-match preference — the coexistence test guards this. The existing `SeedTrust` race stays at its accepted single-user level (same-directory starts write the same idempotent trust value). - The dev microvm has 12 GB RAM / 4 vCPU and an OOM is contained to the VM; the default cap of 6 is a guardrail, not a precise resource budget — tune `-max-instances` if needed. - Scope is confined to the lab module on the dev microvm (hosted under `fw`); per the lab vendoring ADR, only `fw` dry-builds when lab changes.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/nixos#50
No description provided.