-
Notifications
You must be signed in to change notification settings - Fork 0
Spawn Flow
The extension has two commands. The dashboard owns the entire spawn lifecycle (confirms, kill+spawn, in-flight toast) even when invoked through Start Dev Server.
The natural shape was: Start command checks Finder, fetches running servers, shows confirms, spawns, navigates. That cost 700ms–1.3s of wall-clock time during which the user stared at a blank loading view.
Current shape: the launcher resolves a target list (~70ms) and hands off via launchContext.spawn. The dashboard then handles the rest from its own lifecycle. User lands on the dashboard immediately; confirms overlay a real view.
Both entry points probe Finder through probeFinderSelection, which resolves the selection to project roots only when Finder is the app behind the Raycast window. What differs is what a resolved selection earns:
- From Raycast root search (no launchContext): a selection that resolves is spawned. This is the command's headline feature: pick folders, hit the hotkey, they start. Nothing resolvable means the picker (recents + Choose Folder), and a selection that resolved to nothing startable also says so on a toast, since the user did propose something.
-
From the dashboard (
⌘Non a row, the empty-state action, or a menu bar item):launchContext { forcePicker: true }renders the picker, and the same probe's result is offered in it as aSelected in Findersection: one row naming the folders, started on↵. It never spawns on its own.
Why the split, rather than one behavior: the question is who proposed the start. Reaching the command from Finder, acting on the selection is the request. Arriving from the dashboard, the user asked to choose, and a selection sitting behind the Raycast window is at best a suggestion. Silently spawning it there used to surface an "already running, restart?" alert for something the user never picked, which is what forcePicker was invented to prevent.
forcePicker therefore survives the frontmost-app gate rather than being made redundant by it, but its job is narrower than it looks: it no longer decides whether we look at Finder, only whether looking is permission to act. The gate handles staleness (a selection made an hour ago, with Finder long since in the background); the flag handles intent (Finder is genuinely right there, and the user still came here to choose). Neither covers the other's case.
The row is a proposal, so it says exactly what it would do before it does it: Start 3 Selected Folders with the names beneath it, or Start Explan with its path when there is one.
confirmMultiStart now has exactly one live caller: the root-search Finder spawn. Every row in the picker passes confirmMulti: false, the Finder row included, and that is the rule rather than an exception list.
The preference guards being surprised, not the act of starting several things. From root search a hotkey spawns a Finder selection having shown the user nothing, so the alert is the first sight of what is about to run and earns its keystroke. A picker row was already that sight: it named these exact folders, so the alert could only print the same list again. The row is also bound to the snapshot taken when the picker opened, not to whatever Finder holds at ↵, so it cannot misstate what spawns.
This is the reasoning Menu Bar already settled for Kill All N Servers, an item that ships without a confirm because it names its own blast radius. That argument was accepted for a destructive action; starting servers is reversible, so it holds here comfortably.
The already-running confirm (confirmRestartBatch) is untouched and still fires on this path. Whether one of the selected folders is currently running is precisely the thing the picker row could not know, so that alert carries real information rather than a restatement.
In src/index.tsx:
idle → pending → confirming → spawning → done
-
pending:
launchContext.spawnpresent, waiting for firstfetchServersso confirms reflect current state -
confirming: multi-folder confirm (if N>1 and
confirmMultiStartpref is on), then batch restart confirm -
spawning: toast shown, pending rows created, kill+spawn done, watching
serversstate for every expected cwd - done: terminal. Toast either flipped to Success (auto-hides after 2.5s) or hidden after the 15s timeout, which fails the pending rows instead
Three small effects: flow (driven by phase), watch (driven by servers), timeout. There's no separate setInterval poll; the dashboard's existing polling drives matching, with cadence accelerated to 1s only during spawning. Earlier it also fast-polled pending/confirming, but confirming blocks on a confirmAlert that can sit open indefinitely, and 1s polling there produced a recurring "rendering a lot" warning (see Raycast Quirks). Nothing is spawning in those phases, so fast-polling them bought nothing.
Every start and every restart gets a synthetic row, held in a pendingStarts map keyed by cwd (src/index.tsx). While in flight the row spins; on timeout it turns red and carries the remedies as row actions. Selection moves onto the row when it is created and follows it through to the server row it becomes.
The row lives in its own project's section, not a separate "Starting" one. A section header names a project, and a project does not stop being itself while one of its servers boots. The separate section went stale the instant a row failed, since the header was then describing a row that had given up, and it moved the row across the list at the one moment nothing else was moving. In its project's section the row is already where it is going to land, so resolving moves nothing. Projects with something in flight sort first; within a section, pending rows sit above the servers.
Because the header carries the project name, the row spends its title on what is happening: Starting…, Restarting…, or the diagnosed cause itself once it fails. Server rows title on host and port rather than repeating the project, and the toast-copy rule that a cause belongs in the field that always survives applies just as well to a row.
Pending entries therefore carry a projectKey, taken from a running server for that cwd when there is one (so a restart lands among its siblings) and falling back to the cwd, which is what projectKey resolves to for a non-git project anyway.
Three rules make it work:
-
Visible rows are derived, never stored. A pending entry renders only while
resolvingServerfinds nothing for it, computed in render from the same array the server rows come from. That makes the handoff atomic: the synthetic row can only disappear in the render that lists the real one, so there is no frame with neither row for selection to fall through. A separate cleanup effect trims resolved entries out of state afterward, which is bookkeeping and cannot affect correctness. -
It is its own state slice, not synthetic entries in the
useCachedPromisedata. Every kill and restart handler runs anoptimisticUpdatefilter over that array typed asDevServer[], and a fake entry would flow through all of them. -
Ids are namespaced
starting:${cwd}(pendingRowId), so they can never collide with server rows, whose ids are bare pids.
Selection moves onto the pending row at spawn. Carrying it to the server row is done by deriving effectiveSelectedItemId during render: if selectedItemId names a pending row whose cwd now has a server, the List is handed that server's pid instead.
This has to happen in render. An effect runs after the commit that dropped the pending row, so for one frame selectedItemId names a row that does not exist. Raycast responds by choosing a row itself and reporting it back through onSelectionChange, which writes to the same state the effect is about to set, and the report wins because it arrives later. Resolving during render means the id never dangles, so there is nothing to correct. This is the concrete form of the documented unknown around "selectedItemId pointing at a row that isn't rendered".
The watch effect still pins the first started server, but skips when the cursor is already on a pending row (checked through selectedIdRef, so selection stays out of that effect's dependencies). Otherwise a multi-target start would drag the user off the row they were watching onto the first of the batch.
The List also sets filtering={{ keepSectionOrder: true }} so a search query cannot reorder sections and demote a project that is mid-start.
Raycast has no animated icons and no per-row isLoading, so the spinner is frames we swap ourselves: spinnerFrame(frame) builds an SVG data URI holding a faint full-circle track with a quarter-circle arc over it (a stroke-dasharray trick, the same shape a CSS spinner draws), and an interval steps that arc around one frame at a time. Eighteen frames at 45ms puts one revolution at 810ms.
Smoothness is the size of each step, not the frame rate. The two knobs pull apart, and that is the useful thing to know when tuning it: more frames to a revolution at a fixed rate buys a smaller step (20 degrees rather than 30) and spends it on a slower turn, not a choppier one. Reach for that lever first.
The rate is near its ceiling. The frames are a fixed set, so SPINNER_ICONS builds them once at module load and the animation is an array index; the interval only runs while something is starting, so it is bounded by the 15s watchdog. But every frame is still a render round trip to Raycast, and the toast's own spinner is native and smoother than anything reachable from here, so matching it exactly is not on the table.
Four things are deliberate:
-
The frame counter lives in
PendingItem, not the dashboard. Ten re-renders a second have to stay inside one row. Hoisting the clock re-runs the whole list, which is the exact churnsameServersand the polling cadence were written to avoid (see Raycast Quirks). -
The color is a hardcoded hex. Raycast gives a data-URI SVG no surrounding color context, so a
currentColoricon renders as a black square. That is the same trapisMonochromeSvgexists to dodge for favicons. -
The color is neutral grey, not the tag's yellow. The
Starting…tag already carries the state; a second yellow element reads as a warning about something. - The frames are precomputed, not built per tick. That is what makes a rate this high affordable; re-encoding the SVG each tick would not be.
An earlier version used getProgressIcon to count down the 15s watchdog. It was accurate and misleading: servers usually bind in 2 to 4 seconds, so the ring almost always vanished at 10 to 20% full, which reads as a cancelled job rather than a successful start. A spinner claims only that work is in progress, which is all we actually know.
Starting a project that currently shows a failed row rewrites that entry by cwd, resetting it to starting with a fresh logStart and startedAt rather than stacking a second row. A server that binds after being marked failed needs no special case: its cwd appears in servers, so the derived rule drops the failed row on the spot.
Three ways it goes, and they are worth stating plainly because users ask:
-
Dismiss (
⌘⇧D) deletes the entry. - The project starts. A real server for the cwd arrives, the derived rule drops the failed row, and the real one takes its place.
- A day passes. Rows more than 24h past their deadline are dropped on rehydrate, because everything a failed row offers comes out of the spawn log, which lives in tmpdir and is cleared on the OS's own schedule.
What is deliberately not on the list: the command unloading. Pending rows persist through src/pendingStore.ts (LocalStorage), and every row carries a deadline, the moment its watchdog would give up. The dashboard writes the map through on every change and rehydrates on mount, live entries winning on collision. An inherited row whose deadline ran out while nobody was watching is diagnosed right there in the merge, so a start that failed with the window closed is a red row the next time the dashboard opens. That was the motivating scenario for replacing the failure toast, and in-memory state alone never actually covered it: Raycast unloads a view command on pop-to-root (lifecycle), taking the watchdog with it.
A deadline sweep effect backs the live watchdog: it arms on the earliest outstanding deadline among starting rows and flips the overdue ones, so a mount that inherited rows it did not spawn still fails them on time. The sweep flips status and nothing else; toast, selection pinning and phase transitions stay with the live watchdog, because none of that belongs to a start the current mount did not make. Rehydration never moves the cursor either.
Failed rows do not expire on a shorter timer, on purpose. A failure that clears itself while the user is looking elsewhere is the exact weakness of the toast this feature replaced.
restart() puts a Restarting… row up before the kill, so the project never blinks out of the list, and pins selection to it. The row carries the cursor onto the replacement through the same render-time handoff a start uses, and a restart that times out turns red in place with the same diagnosis and remedies. Its failure toast is gone for the same reason the start one went.
Resolution is one predicate, asked everywhere. resolvingServer defines "the server this row was waiting for has arrived", and it has two conditions:
-
A deliberate port (below
EPHEMERAL_PORT_MIN). An OS-assigned port is plumbing, the same judgmentsuppressHelperRowsmakes, and mid-start those rows are dropped from the fetch entirely (rule 4, see Process Detection). Without this condition aworkerdhelper could retire the spinner in favour of a row the list was not even drawing, and the failure would never be diagnosed. -
A pid the row was not already looking at, per
ignorePids: every pid holding the cwd at the moment the row was made, plus the pid a restart replaces. Without it the row could resolve against a surviving sibling in a two-server folder, or against the corpse of the server just killed, which lingers inserversuntil the next poll. Either one turns a start that never binds into a reported success. A genuinely cold start has nothing holding its cwd, so the list is empty there.
Every consumer asks this predicate: row visibility, the cleanup effect, the selection handoff, the success watcher (which also picks the URL auto-open opens, so a sibling's tab is never the one opened), the 15s watchdog, and restart()'s poll loop. They cannot drift apart. The spawn machine's expecting map carries each row's own ignorePids so the watcher can keep asking after the cleanup effect drops a resolved row, which matters in a staggered batch.
Grouping needed nothing. The replacement is the newest server for its cwd, so the newest-first order (see UI Conventions) already floats it to the top of its project and its project to the top of the list.
restart() runs its whole state machine inside one async function, and lifting it into shared state is a later job.
A toast created right before launchCommand flashes for a fraction of a second and doesn't survive the navigation. Creating it from the dashboard means it persists through the whole wait.
Mutations propagate while the dashboard is alive: toast.title = "X is running"; toast.style = Toast.Style.Success works as expected. The watch effect uses this to transition the toast on detection, then schedules toast.hide() after 2.5s.
The start toast only ever says two things now: Starting X… while in flight, and X is running on success. A start that fails says nothing on the toast; the toast is hidden and the pending row carries the failure. A toast was the wrong surface for a remedy: its actions die with it, and with the Raycast window closed it degrades to an actionless HUD, which is where most starts are 15 seconds in.
On timeout, diagnoseSpawnFailure in src/index.tsx reads the spawn log and names two causes instead of falling back to the generic not-detected message:
-
Port conflict (
EADDRINUSE): another process already owns the port. -
Portless proxy down: a dev script wrapped in
portless runneeds the portless proxy already running, and a detached spawn has no TTY for its sudo prompt. See Portless.
Both read as "the extension broke" while the real cause and the fix sit outside the extension, which is what earns them a named message. Every missing target is diagnosed, start or restart, since each has its own row to say its own piece.
The log scan is scoped to the bytes written by the current spawn, so an error an earlier run hit and since resolved never matches. It seeks to that offset rather than reading the file and discarding the front: the log is opened append-only and never rotated, so it holds every run for that project until the OS clears tmpdir.
Every failure, start or restart, lands on the row. The section header carries the project name, so the row's title is spent on the cause:
| row title | actions | |
|---|---|---|
| portless proxy down | Portless proxy isn't running |
View Startup Log, Copy Fix Command, Open in Terminal, Dismiss |
| port conflict | Port already in use |
View Startup Log, Open in Terminal, Dismiss |
| undiagnosed start | Didn't start after 15s |
View Startup Log, Open in Terminal, Dismiss |
| undiagnosed restart | Didn't come back after 10s |
View Startup Log, Open in Terminal, Dismiss |
The cause is the title for the same reason it was the title back when this was a toast: it is the one part that always survives. A fix we can name is an action rather than prose, because a button can't be truncated and it saves retyping the command. When more than one row has failed, a Dismiss All N Failed action appears alongside Dismiss.
The startup-log detail view live-tails while open, refreshing every 2s. That keeps slow boots and crash loops visible without asking the user to refresh by hand, and it is why Copy Log, not Refresh, is the ↵ action: the view has already made refreshing by hand pointless.
The body is the log and nothing else. It used to open with a heading repeating the navigation title and close with a full tmpdir path long enough to wrap mid-token, which cost the top and bottom of a view whose whole job is fitting output on screen. The path is now Copy Log Path (⌘⇧C).
Opened from a failed start row, the view shows that attempt only. SpawnLogView takes an optional logStart and slices the file from that byte, the same slice diagnoseSpawnFailure reads. This matters because the log is append-only and never rotated: by the fourth restart of a crashing project the file is four near-identical failures deep, oldest first, and the run the user opened the view to read is the one scrolled off the bottom. Server rows pass no offset, since no single attempt is in question there, and Show Full Log (⌘⇧L) toggles. An offset past the end of the file means the log was cleared underneath us, and the view falls back to showing everything.
The "auto-open never fires from the menu bar" bug was solved 2026-07-26: the preference itself had been silently reset by a scope move, and a stale command-scoped copy kept the dashboard path alive. Nothing here was wrong. History and mechanism in Auto-Open Investigation.
The dashboard reads autoOpenInBrowser itself. It is an extension-level preference, so every command already sees the same value, and the dashboard is where the opening happens. SpawnRequest deliberately does not carry it.
It used to. Each caller read the preference and posted its own copy through the launch context, and the dashboard trusted the copy. Two sources of truth for one boolean, which duly disagreed: starting from the menu bar opened no tab while the identical start from the dashboard did. The fix was not to work out which copy was wrong but to delete the copy, so no caller can contradict the setting.
The open itself goes through openInBackground in src/servers.ts (/usr/bin/open -g), not Raycast's open(), falling back to open() if the subprocess fails.
Raycast's open() activates the browser, and Raycast hides its own window the instant it loses focus. So a second or two after a start the dashboard tore itself away, which reads as a crash and takes the rest of the window with it: Copy URL for a different browser, Open in Terminal, everything. open -g is the only non-activating open on macOS; there is no @raycast/api option for it.
The rule: the extension acting on its own opens in the background; the user pressing a key opens in the foreground. Row actions and menu-bar items keep Raycast's open(), because going to the browser is the entire point of pressing return on a URL.
The fallback exists because a subprocess has more ways to fail than an API call, and the failure was being swallowed. That made "opened behind your window" and "did nothing" identical from outside, which is also what a disabled preference looks like. Foreground is a worse outcome than background and a much better one than silence.
Note that auto-open fires the moment the port binds, which for wrangler-style stacks can be before the app can actually serve a request. That is not something the port check can see.
confirmRestartBatch consolidates per-target restart prompts into one alert. Wording adapts:
- Single target running:
"X is already running. Restart?" - All N running:
"All N already running. Restart [names]?" - M of N running:
"M of N already running. Restart [names], then start the other K?"
joinNames does Oxford-comma joining so the sentence reads naturally.
Both detect the same way now: resolvingServer with the row's own ignorePids (see the resolution section above). What differs is only the driver. Start detection is woven into the dashboard's normal polling (accelerated to 1s while spawning), watched by the success watcher and failed by the 15s watchdog; restart() drives its own staggered revalidate loop ([1000, 2000, 3000, 4000]ms, ~10s total) inside one async function and asks the predicate each round.
An earlier version had restart count servers for the cwd against a pre-kill baseline instead. The count and the row's predicate disagreed in reachable states: a kill-resistant old listener pushed the count over baseline while the row spun forever under a "Restarted" toast, and a sibling exiting mid-window kept the count at baseline while the row had already resolved, so a successful restart reported nothing at all. One predicate for both is the fix, not better counting.
Diagnosis and reporting are shared too. A restart respawns through the same startDevServer, takes the same pre-spawn logStart baseline, runs the same diagnoseSpawnFailure on timeout, and lands the failure on the same row.
If you add another respawn entry point: create a pending row with the pids currently holding the cwd as ignorePids, watch resolvingServer, capture logStart before spawning, and route the timeout through diagnoseSpawnFailure. Anything else will re-open a gap this page has already documented closing twice.
▸ Toast on the launcher: lost during navigation.
▸ Standalone rapid-poll setInterval for matching: deps caused cleanup teardown on every render, toast spun forever.
▸ Detail view as the launcher's placeholder: too much chrome for a transient state.
▸ Per-target restart confirms in a loop: N popups for N folders.
-
Retry on a failed row. Wants a re-entry path into the spawn flow, which is once-only behind the
spawnFlowFiredref. Open in Terminal covers the need by hand until then. - When Raycast 2 ships, retest the
launchCommandflash (see Raycast Quirks). If they fix it, no change here; we're already idiomatic. - The launcher could go
no-viewmode if Raycast ever exposes a way to render confirms or hand off without showing the calling command's UI at all.
Surfaced by the post-release code review; judged real but not worth their churn yet. If one starts biting, this is the context.
-
Pending rows key sections by cwd, not git common-dir. A cold start's row falls back to the raw cwd for
projectKey, while the server it becomes keys on the shared git dir. With a sibling worktree or another monorepo package already running, the pending row sits in a section of its own and hops into the repo's section on handoff, and its header isbasename(cwd)rather than the detected project name. Cosmetic; resolution and selection are unaffected. Fix shape: compute the git common-dir for the spawn target at row creation, or match a running server by prospectiveprojectKeyinstead of exact cwd. -
Dismissing a failed row does not re-point selection.
selectedItemIdkeeps naming the deleted row, and Raycast picks a replacement itself, so the cursor can teleport to the top instead of a neighbour. Depends on undocumented controlled-selection behavior; verify on a device before designing around it. -
The Start picker's running-check calls
fetchServersbare. It gets no mid-startsettlingCwds, so a project whose helpers are up but whose server has not bound can briefly count as running and be hidden from recents. Arguably correct (it is starting); if it should agree with the other surfaces, give it the samereadPendingStartsprelude the menu bar's refresh uses. -
The spawn launch context is built by hand at two sites (
launchSpawninsrc/start.tsx,launchRecentinsrc/menubar.tsx) against an unexportedSpawnRequesttype, so a field change is a silent drift rather than a compile error; the menu bar also hardcodesshowAutoOpenHint: falsewhere the picker computes it. This exact shape produced the stale-autoOpenbug. Fix shape: export the type from a shared module, or liftlaunchSpawninto one and have both surfaces call it. -
PendingItemduplicatesServerItem's project-action section chord for chord, and renders two near-identicalList.Itemtrees for its two states. Any chord change now has to be made twice. Fix shape: one sharedProjectActionscomponent, oneList.Itemwith derived props.