Skip to content

feat: project services, commands lich starts and keeps running - #930

Merged
omartelo merged 4 commits into
mainfrom
feat/project-services
Oct 24, 2026
Merged

omartelo merged 4 commits into
mainfrom
feat/project-services

Conversation

@omartelo

@omartelo omartelo commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Replaces the per-card entrypoint removed in #849 with services that belong to a project. A user who starts the same container, tunnel or dev server every day saves it once in Settings › Services and lich runs it.

  • A service is a process, not a shell. It runs $SHELL -c (PowerShell -NoProfile on Windows) in the project's directory, without rc files, on a PTY of its own. When the command exits the service has stopped: nothing is respawned, the last output stays readable, and Start runs it again. Ctrl+C at the console and Stop are a stop; an exit of its own with a code other than 0 is a failure.
  • States: stopped, running, stopping (asked to stop, waiting for the command; Force stop kills it), failed (with its exit code).
  • A service lives as long as its project's tab. Closing the tab stops the project's services (killed past a 10 second grace); reopening the project starts the ones saved with Start with lich, and so does lich starting with the tab open. Closing the window stops nothing. Quitting lich asks every service to stop, waits up to 10 seconds and kills the rest.
  • Home's services (Settings › Services with Global selected) run in the home directory and live as long as lich does, since Home has no tab: the place for a lazydocker kept open whatever the project.
  • Sidebar footer: a Services row above the profile. Closed, it shows a dot that beats while a service runs (0.8 s of ring at the spinner's rate, then no frames until the next beat 2.4 s on) and up/total; open, it grows upward with one 26px row per service (dot, name, uptime or how it ended; Start, Stop and Restart on hover; right-click for Show output, Edit and Delete) and New service. A project with no service shows the row dimmed with Add.
  • Rail: the collapsed sidebar gets a Services button (icon with a state dot) that opens the same list, and the profile button the open footer has. The profile was missing from the rail before.
  • Console: clicking a service opens its console in place of the session terminal, with a bar for its state and its actions.
  • Tray (Linux, Windows, and the macOS menu-bar item from feat(macos): menu-bar item and reopen through AppKit via purego #932): the running services, each with Show output, Restart and Stop, and Stop all services.
  • Close dialog counts services too. Before this, closing the window with only services running would have quit lich and stopped them.
  • Palette: a "Start the project's services" action.

Design and journey: https://claude.ai/artifact/BE6bLttP9ezYVJLdvp6tGA

Contract

  • api/services.go: services.List/Save/Delete/Start/Stop/ForceStop/Restart/StartAll/StopAll/Output, the services event (the whole list, emitted once a change is true) and service-reveal. TrayLabels gains the service labels. contract.golden only adds.
  • Migration 11 adds project_services (cascade-deleted with its project, name unique per project).

Tests changed, and why

  • TestOpenDropsTheTerminalThemeSetting drops project_services with the other objects later migrations add: the version-0 database it rebuilds never had the table.
  • TestOnlyProjectScopedSectionsStayLitInAProject lists Services, a new project-scoped section (the contract changed).
  • i18n_shell_test.go passes the new services count to closeRunning; its assertions are unchanged.

Ceilings

New docs/ceilings.md section: a detaching command shows stopped, no rc except under fish, a service lives as long as its project's tab (an update's restart starts only the Start with lich services), output kept in memory only, at most eight services named in the tray (the macOS entries never ran on a Mac), Stop as Ctrl+C through ConPTY on Windows.

Test plan

  • internal/store, internal/terminal (real PTYs), internal/services, internal/tray, internal/routes, api and native unit tests; service suites run 20 times each without a failure.
  • Local gate: gofmt, vet and go test ./... for the backend and native/, and the linux/darwin/windows cross-compile loop.
  • Rig on its own backend, display and HOME: services saved and started over RPC, then shots of the footer open and closed, a running console, a failed one, a stopped one after Stop, Settings › Services and the rail. Quitting the rig's backend stopped its services with nothing left running.

A project can save services: a container, a tunnel, a dev server. lich runs
each one in the project's directory on a PTY of its own, without the user's
rc files and with no shell behind it, so when the command exits the service
has stopped and its last output stays readable until it starts again. Ctrl+C
at its console and Stop both read as a stop, not a failure.

Backend: a project_services table (migration 11), the services.* RPCs and
the services and service-reveal events, internal/services for the state of
every run, terminal.StartService for the PTY, Start with lich at boot and a
graceful stop of every service on quit. Service PTYs are not sessions: they
stay out of LiveCount and the turn-state catch-up. The tray lists the running
services with Show output, Restart and Stop.

Window: a Services row in the sidebar footer above the profile, closed to a
count and open upward to a row per service; the same list from a Services
button on the collapsed rail, which also shows the profile now; a console in
place of the session terminal; Settings > Services per project; a Start the
project's services action in the palette; the close dialog counts services,
so closing a window with only services running asks instead of quitting.

TestOpenDropsTheTerminalThemeSetting drops project_services with the other
tables later migrations add, because the version-0 database it rebuilds never
had it. TestOnlyProjectScopedSectionsStayLitInAProject lists Services, a new
project-scoped section.
…oter

A service now lives as long as its project's tab. Closing the tab stops the
project's services (killed past a 10 second grace), reopening the project
starts the ones saved to start with lich, and lich starting brings up only
those whose project has a tab open. Home has no tab to close, so its services
run as long as lich does; Settings > Services edits them with Global selected.
The tray only ever lists services of open projects, so Show output no longer
reopens a tab.

In the window, the running dot of the Services row, the rail badge and the
console bar beats: a ring grows out of it for 0.8 seconds at the spinner's
rate, then rests with no frames until the next beat 2.4 seconds on. A
continuous pulse at a tenth of that rate held the window at 6% of a core.
The rows keep a plain dot. Start all is an icon with its words in a tooltip,
and the service list no longer reserves a scrollbar gutter, so its rows end
where the cards above them do.
#932 gave macOS a menu-bar item on AppKit through purego. Its menu now carries
what the Linux and Windows tray does: the running services count, a slot per
running service with Show output, Restart and Stop, and Stop all services. A
slot's submenu entries are told apart by their tag, and the logic behind them
is the one the other trays share. The ceiling that said macOS has no tray
names the eight-slot limit instead, and that these entries never ran on a Mac.
@omartelo
omartelo force-pushed the feat/project-services branch from 3b6d337 to beac4b9 Compare October 24, 2026 01:37
TestADoneWithOneErrandOpenStallsItWithoutANotice failed on CI with
"writes = 2, want no notice". stampDelivery marks a ticket delivered before
the paste is written, and the Enter follows a beat later, so the test, which
counted the target's writes as soon as it saw the ticket delivered, could
count the paste alone and then read the Enter as a notice. Holding the Enter
back 30ms in the fake terminal made it fail 5 runs out of 5; waiting for the
ticket to be submitted, which is after both writes, passes all 5 with the same
delay. The two-errand test beside it counted the same way and waits the same
way now.

The test asserted something the contract never promised: that a delivered
ticket's writes are all in.
@omartelo
omartelo merged commit d656b98 into main Oct 24, 2026
7 checks passed
@omartelo
omartelo deleted the feat/project-services branch October 24, 2026 02:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant