Stop, restart, pause, archive, message, or refill many sessions at once (part 2)
How the bulk Pause, Stop, Restart, Archive and Broadcast actions are actually wired: which store call each button loops over, the selectors that decide which sessions each view offers, the single terminate-many request Stop sends, the delivery channel a broadcast reuses, and the contracts that lock the whole rule set.
What it is
This is part 2 of the Stop, restart, pause, archive, message, or refill many sessions at once page. It carries the implementation behind those buttons, moved here because a single page is capped at 40,000 characters.
Where to find it
Nothing on this page is a surface — part 1 has the buttons and where they live. What follows is the code behind them, so it is for a reader with the repository open.
How it behaves
Each bulk action loops an existing single-session call rather than adding a parallel path, which is what makes the per-session busy-guard, the undo chokepoint and the routing for non-Claude engines apply here unchanged. Stop is the one exception and sends a single batched request. Below is how each of those is wired, the selectors that choose the rows, and the contracts that lock the rules.
For agents
How it works
Restart, Pause and Archive loop their existing single-session calls (Restart is SESSION_RESTART → relaunchSessionInPlace, which resumes with --resume), so session-bulk-actions-slice.ts's bulkRestartSessions / bulkPauseSessions / bulkArchiveSessions inherit the per-session busy-guard and the routing for non-Claude engines (Codex/Gemini/etc.) for free. Stop is the one exception: bulkTerminateSessions sends one session:terminate-many request. Main refuses ids that are missing, someone else's, or mid-restart, writes the "stopped by you" hold on the rest in one write, answers at once, then stops them in the background at the app's own quit-time pace; a repeat Stop joins the one in progress instead of failing. A refused row reverts to its real status (never a fake "ended"); a Restart flips each row to "Starting" before it makes the relaunch call — not after — so the backend's own "running" signal always lands afterward and the dot settles green (flipping it after the call used to let a session that was already running get repainted a stuck grey "Starting" that never cleared). A row whose relaunch fails is reverted to its prior status (never a stranded fake "Starting"), and the relaunches fire in the list's display order, top to bottom. For a multi-session restart, each relaunch is tagged with the user-cohort lane of the app's spawn gate (spawn-concurrency-gate.ts). WHICH lane is decided by who asked, never by the batch's size: relaunchSessionInPlace reads the trigger it already requires, so a batch a PERSON pressed is user-cohort while the unattended ops sweeper that passes the same paced flag stays on recovery (fail-closed — only a call that explicitly says a human asked gets the user lane). user-cohort takes the same user-configurable Spacing between sessions when many restart at once gap (sessionRestartSpacingSeconds, default ~1.5s, Settings → Performance; 0 disables the gap) and the commit-cliff memory guard, and takes neither busy-based hold: not the box-load hold (isBoxUnderHeavyLoad) and not the capacity hold. That is the-user-is-never-paced-by-capacity (capacity contract AX4), and it is what stops a watched batch paying 30 s + 30 s per session — measured 2026-09-21, 40-70 s per session serially, because on a permanently-busy box both holds burned their full ceiling on every spawn and let it through anyway. That setting is the gap's ceiling: restartRampFactor() scales it down to a floor of ¼ once BOTH app uptime has cleared the boot window AND the box measures calm (CPU + commit headroom), because the startup work the full gap was protecting is by then already done — and it re-widens to the full value the moment load returns. The gap runs on the lane's OWN start line — paceRecoveryStart keeps one cursor per paced lane, so a user-cohort batch is spaced only against its own members and never queues behind the recovery starts already reserved (Sentry 7760505906, 2026-09-28: one shared cursor put a person's batch ~12 minutes behind background restarts, and the restart IPC outlived its 10-minute abandon ceiling). Each batch row is re-checked when its turn comes: a row that was a restart target (isRestartableTarget) when picked carries onlyIfRestartable, and relaunchSessionInPlace leaves it alone — nothing killed, marked or written, counted as done — if it no longer is one (it came back on its own) or another restart already holds it; a came-back row also gets its real status re-pushed, because the batch painted it "Starting" and no launch will correct that. A single restart a person asks for is marked the immediate "interactive" lane outright rather than left unset, so a leftover lane mark (a replay that bailed without spawning) can never slow it. Locked by contract I10 and I28.
Adding Pause and Archive added no store or IPC surface at all — the modal simply calls the same two bulk actions the sidebar right-click menu has always used, so both paths share one busy-guard, one optimistic update, and one per-row failure reconcile. Their Undo goes through registerSessionActionUndo, the single chokepoint that arms the Ctrl+Z entry and shows the Undo toast together (so the two can never drift apart): Pause's inverse is bulkUnpauseSessions, and Archive's captures each Needs-You row's status before archiving so undo restores it exactly rather than dropping it back as a plain idle row. Every archive from this modal is stamped with its own user-manage-sessions attribution, so the Archived view can tell you it was you, from here.
Which sessions each view shows is decided by pure selectors in session-bulk-actions.ts: both the modal's Stop and the quick right-click Stop use the narrow selectStoppableSessions = running + starting; selectPausableSessions reuses the app-wide PAUSE_ELIGIBLE_STATUSES set that the right-click Pause, the mobile selection bar and the inbox-row menu already gate on (imported, never re-derived, so they cannot disagree) — everything except archived / paused / error / terminating, which is what the pause service itself accepts, and which notably INCLUDES a settled ended session so the sidebar's Interrupted pile can be parked; selectArchivableSessions is simply everything not already archived; selectRestartableSessions = error + stalled, plus the needs_you give-up / interrupted-on-close carve-outs, plus stopped ended sessions that have content to resume — a completed turn (numTurns > 0) OR a message you sent (lastUserMessageAt), which keeps a session interrupted mid-first-turn while genuinely blank never-used sessions and every other needs_you sub-state stay excluded), both of which also drop hidden background "silent recipe" sessions so the list always matches what you can see. The modal's Restart view adds one selector on top of that — selectModalRestartSessions, which is selectRestartableSessions PLUS every parked paused session, with no conversation gate (the sidebar's Paused list shows them all, so hiding a blank one here would read as a bug). That is the ONLY selector the Restart view uses — for the list it shows and for the re-check at Apply time — which is exactly why a paused pick survives to Apply instead of being filtered away as unknown at the last moment. The right-click Restart keeps isRestartableTarget untouched. The modal itself (ManageSessionsModal.tsx) is almost entirely presentational — it just owns the current view, your selection, and the filter (there is no list order to own: the list is a picker, and both views render the same one-control row so the session name gets the width), and reuses the app's shared modal, button, and plain-Enter-submit building blocks — the last via useEnterSubmit.ts — and every per-view thing (the list, the selector, the icon, the Apply verb, the empty state, the selection Set) is keyed by action kind, so adding another view is a compile error until it names all of them (and because typecheck is currently switched off on this repo, a test also walks the registry at runtime and opens every view, so a missing entry cannot ship as a blank label); so plain Enter anywhere in the body runs Apply while useCtrlEnterSubmit keeps Ctrl+Enter; the hook deliberately yields Enter to the "first/last" dropdown because a native descendant listener beats React's delegated onKeyDown, so it bails on any Enter-owning control instead of trusting preventDefault (see plain-enter-submit-postmortem.md). The Stop list renders as a plain flat list (running-only is a single kind of session, so there are no group headers). The title filter sits purely on top in the renderer: it derives the visible (matching) rows and points Select-all, the count, the first/last picker, and the applied ids at them — so an empty filter is byte-identical to before and the filter never touches the status buckets, selectors, store, or IPC. The Restart view carries a second filter on that same mechanism — the Interrupted / Paused / Both switch — narrowing the same visible set by each row's own status (a row is Paused iff its status is paused; Both is a filter value, never a bucket a row can be in, and the bucket is read from the row's own status rather than the sidebar's display partition, which would file a reconnecting error row under live and put it in neither option). It is Restart-only, because no other view's rows carry a restart bucket, and it resets to Interrupted on every view switch — so the tab always opens showing exactly the list it showed before paused sessions were included. Because that Enter hook would otherwise fire the paid Stop/Restart on plain Enter from the filter box too (a type="search" input is not an Enter-owning control), the filter box carries its own small native keydown guard that stops plain Enter locally before it reaches the body listener (Ctrl+Enter still passes through and applies). Locked by contract I11. Its one live touch: each row's status dot renders through a small store-connected LiveStatusDot so the dots honor the same recovery/overlay overrides as everywhere else in the app — a session mid-restart shows the green "running" dot here too, not a stale grey "Starting" that reads as suspended (see optimistic-running-dot-contract.md). One app-level owner, useBulkSessionActions.ts, listens for a single open event from both the toolbar and the project-header ⋮-menu item (project-bulk-action-menu-items.ts), feeds the modal the live lists, and — at the moment you press Apply — re-checks your picks against what's still actionable so a session that changed while the modal was open is never acted on by mistake. The full rule set (the status buckets, the per-id failure handling, the order-preserving restart, the narrow-Stop rule and the confirmation that fronts a multi-session Stop/Restart, and which views are undoable) is locked in bulk-session-actions-contract.md.
The broadcast reuses the app's existing agent-to-agent delivery, rather than inventing a second way to put a message into a session. A single new channel, SESSION_DELIVER_MESSAGE, wraps deliverPeerMessage (peer-message-service.ts) — the same code path one agent uses to message another — so every safety rule it already enforces applies unchanged: a session you closed is refused, a session that has asked not to be disturbed is still held, and the arrival renders as the same labelled bubble. The one rule the broadcast deliberately does not inherit is the busy-target hold: an automated sender's message to a mid-turn agent is parked in that agent's queue until the turn ends, and a broadcast from you used to be parked there too — while the app still reported it sent. The broadcast now grants the same interrupt your own Send button has, so a mid-turn recipient takes the message immediately. An outside engine still queues instead (it has no interrupt path), and the modal's consequence line says so rather than promising an interrupt the code cannot perform. A store action, bulkBroadcastMessage, loops that single-session channel sequentially, one recipient at a time, because a delivery can respawn an agent and firing N at once would be a spawn storm. The recipient bucket lives beside the other four as MESSAGEABLE_STATUSES / selectMessageableSessions, with one deliberate difference from its siblings: it drops every silent automation lane, not just the hidden ones. The app's "is this hidden?" rule answers no for a silent lane that's actively running — correct for the views that act on what you can see, but wrong for a broadcast, which would land mid-script. The archived rail's rows are appended to the same list the other views use, so the filter, Select-all and first/last picker govern them with no special cases; the wake flag is decided per recipient, so a session that archived itself while the modal was open is left alone, and one that un-archived is delivered to once, not twice. The same action is available from a terminal as POST /session/:id/deliver-message, deliberately restricted to you as the operator: an agent-scoped token is refused, because otherwise any running agent could wake archives and reach across repos — two things the agent-to-agent route withholds on purpose. Locked by contract I17–I22.
The Queued view is computed in Main and refreshed while open. session:restart-queue-preview runs buildRestartQueueSnapshot (restart-queue-preview.ts), which reads the recovery drip-feed's ordered queue and the durable command-line spawn queue together, then resolves every session name in one batched read and every project name in another. The spawn half comes from listDrivableSpawnSummaries() (queries-pending-cli-spawns.ts), which shares one FROM/JOIN/WHERE/ORDER clause with listDrivableSpawns() — the read the backed-up alert counts — so the view and the alert can never list different rows, and it skips the prompt column (a prompt can run to ~100 KB). startQueue is scoped to the opener's project like the restart queue, while both totals stay global so the view can say what waits elsewhere. The owner hook re-reads the snapshot on open and every 10 s while the modal is open, through one reader whose sequence number drops a slow answer that lands after a newer one; a failed re-read never replaces a list on screen with the error state. The start rows never join sessionsByView, so Select-all, the count and Apply cannot reach them. Locked by contract I25.
Related
- Stop, restart, pause, archive, message, or refill many sessions at once — part 1 of this page, with the surfaces and the user-facing behaviour.
Last verified 2026-10-05