Repository navigation
feat(bin): add a localhost web page onto the primary first mate - #268
Merged
Merged
Conversation
Serves the primary Claude session's conversation as a calm chat page on 127.0.0.1, with tool calls collapsed into quiet expandable lines, compaction dividers, and a working/ready/not-running marker. A message box with image paste and drop sends only through fm-desk-voice.sh send, and a side panel reuses the bridge view's fleet glance. A per-home token, cookie, Host, CSRF and Origin checks guard every request.
…one assertion in tests/fm-web.test.sh: "token is owner-only". The cause is a bug in the test, not in the web server: the token file really is mode 600. **Cause:** the test read the file mode with `stat -f '%Lp' FILE || stat -c '%a' FILE`. On Linux, GNU `stat -f` doesn't fail. It means "filesystem status", so it succeeds and prints a filesystem report. The `stat -c` fallback never ran, and the test compared that report against "600". The CI log shows this: the expected value was the filesystem report ending in "600", and the actual value was "600". **Rule this breaks:** a test that reads file mode bits must pick the stat form for the platform (BSD `stat -f %Lp` on Darwin, GNU `stat -c %a` elsewhere). Trying one and falling back to the other doesn't work. tests/fm-account.test.sh already notes this. I checked every `stat` use in the files this PR adds (tests/fm-web.test.sh, bin/fm-web.sh, bin/fm-web.py). This assertion is the only shell `stat` call, so it is the only place to fix. **Fix:** tests/fm-web.test.sh:288 now picks by platform: `if [ "$(uname)" = Darwin ]; then stat -f '%Lp' …; else stat -c '%a' …; fi`. This is the same pattern as tests/fm-account.test.sh and tests/fm-channel-intake.test.sh. It is a one-line change in the test, and production code is unchanged. **Verification:** `bash tests/fm-web.test.sh` passes all 11 tests on macOS, and `shellcheck -x tests/fm-web.test.sh` is clean. I couldn't run the Linux branch here. It uses the same `stat -c %a` call that tests/fm-account.test.sh already runs in CI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Natalia too for her side. If she wants. Not sure if she would want to use something this nerdy. We could create a cleaner interface version? Lightweight / reliable but still has the full capabilities but just isn't display in the terminal. Just a localhost web instance. Let's build that now too. I would want to also use this in moments if I want a break from the terminal or this 'block box' style working aesthetic of herdr.
Context: Starship is Firstmate running in a terminal (Claude Code primary inside a herdr/tmux pane). Lars, and soon colleagues such as Luc and Natalia, run their own homes. The ask is a clean web interface on localhost that gives the full first-mate experience without the terminal.
What Changed
bin/fm-web.sh(start/stop/status/url/serve, default port 8767) andbin/fm-web.py, a Python standard-library server that binds only to127.0.0.1. It serves a chat page showing the primary Claude Code session's conversation, built from the end of its transcript. Messages and replies render as Markdown bubbles, tool calls collapse into lines you can expand, compactions show as dividers, and a header shows working, ready or not running. The page follows session restarts,/clear(through the Claude session registry) and compaction without a restart, and a side panel reuses the bridge view's fleet snapshot.bin/fm-desk-voice.sh send --source web, the same path the desk floater uses. Pasted or dropped PNG, JPEG, GIF and WebP images are saved owner-only understate/desk-voice/shots/and passed with--image. Under the box, the page shows whether the message was typed into the chat or saved to the mailbox.state/web/token, which the first visit swaps for an HttpOnly, SameSite=Strict cookie. Other guards: a Host check for127.0.0.1:<port>, CSRF/Origin/fetch-metadata checks on POST, and a strict nonce-based CSP. The request log never records the token and skips successful polls. Also addsdocs/web.md, cross-links from the bridge-view, desk-floater, configuration, scripts and home-layout docs, a fork-surface capability entry, andtests/fm-web.test.shcovering parsing, session following, guards, sending, the fleet panel and the launcher lifecycle.🤖 Generated with Claude Code
Risk Assessment
✅ Low: The fix round makes four small, correct changes: bash-mode entries no longer mark the primary busy, successful /api GETs are no longer logged, the test-only conversation subcommand is removed, and only 127.0.0.1: is accepted as Host and Origin. Each change is covered by an endpoint-level test that would fail without it.
Testing
The targeted test suitetests/fm-web.test.shpassed. I then stood up a real Claude Code primary in a disposable lab home on a private tmux socket and drove the page live in headless Chrome over CDP. That covered: - sign-in, with the token swapped for a cookie; - sending from the page into the terminal chat, with Working → Ready and the reply rendered with correct list numbering and bold text; - a terminal bash-mode command leaving the page Ready; - image drop and send, with the file saved owner-only and read by the primary; - following a terminal/clear; - light, dark and narrow layouts with the Fleet toggle; - the Not running state after the primary stopped; - start, status, url and stop through the launcher. Curl attacks on the live server were all refused with 403 (400 for the non-image attachment) and none reached the primary. The request log held no token and no poll lines. The one blocker I hit, a terminal permission prompt, is the documented and accepted limit, and I answered it once in the terminal. Populated fleet buckets were checked only by the unit test's stubbed snapshot, not live, because the lab fleet was empty. Screenshots and transcripts are in the evidence directory. The lab home, Chrome and the temporary driver were removed, and the worktree is clean.! cmd) in the terminal: the page does not get stuck on Working!command also started a short model turn, so the strictly idle case rests on the unit testEvidence: Terminal pane showing the message the page typed and the reply
Source: Terminal pane showing the message the page typed and the reply
Evidence: Send transcript (delivery result, state, list markup)
Source: Send transcript (delivery result, state, list markup)
Evidence: Shots folder permissions and saved PNG
Source: Shots folder permissions and saved PNG
Evidence: Terminal permission prompt the page cannot answer (documented limit)
Source: Terminal permission prompt the page cannot answer (documented limit)
Evidence: Live security guard probes (status codes)
Source: Live security guard probes (status codes)
Evidence: web.log: no token, no successful poll lines
Source: web.log: no token, no successful poll lines
Evidence: Launcher start/status/stop/url transcript (token redacted)
Source: Launcher start/status/stop/url transcript (token redacted)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 4 issues found → auto-fixed ✅
bin/fm-web.py:582- The page shows "Working" after a terminal bash-mode command (! cmd) even though no turn started. Claude Code records! git statusas two plain user entries,<bash-input>…</bash-input>and<bash-stdout>…</bash-stdout>, with no origin and noturn_durationorlocal_commandafter them (I checked a real primary transcript: turn_duration → bash-input → bash-stdout → metadata only)._classify_user_stringturns each one into an event item, and_userthen setsself.busy = True(line 582 for string content, line 611 for list content). So if the captain runs a shell command in the terminal while the first mate is idle, the page reads Working until the next model turn ends. This is the same kind of bug as the earlierlocal_commandfix at line 558, where an idle /clear did not read Ready. Fix: set busy only for entries that start a turn. Either leave busy unchanged for the bash-input/bash-stdout/bash-stderr tags, the way SKIPPED_TAGS items already leave it unchanged, or set busy only for human, task-notification, or tool_result entries.bin/fm-web.py:944-log_messageadds a line tostate/web/web.logfor every request, and nothing ever rotates or trims the file. The page polls/api/conversationevery 1.5 s while visible and every 6 s while hidden, and/api/fleetevery 30 s. A tab left open therefore adds about 2,400 lines an hour (around 3 MB a day while visible), with no limit. The log exists to record paths without the token, and logging every successful poll adds nothing to that. Fix: skip logging successful/api/conversationpolls (or all 2xx GETs under/api/), or cap the file's size.bin/fm-web.py:1395- Possibly unneeded component: thefm-web.py conversation --homesubcommand is used only by tests/fm-web.test.sh. bin/fm-web.sh never calls it and docs/web.md does not mention it. Nothing in the intent needs a second CLI way into the transcript reader, and the tests could read the same data through the authenticated/api/conversationendpoint of a test server they already start. This is the same kind of test-only production surface as the FM_WEB_CLAUDE_DIRS override the user chose to remove in round 1. Recommend removing the subcommand and pointing the parsing tests at the served endpoint, unless the user wants to keep it as a debugging tool.bin/fm-web.py:930- A narrower form would do:allowed_hosts, and through itallowed_origins, accept both127.0.0.1:<port>andlocalhost:<port>. The launcher only ever printshttp://127.0.0.1:<port>/?token=…, and the sign-in cookie is set per host, so thelocalhostspelling is a second accepted host that no documented flow uses. Accepting only127.0.0.1:<port>would meet the intent and leave one Host/Origin pair to guard. Recommend dropping thelocalhostspelling, along with its line in docs/web.md's security model and the localhost assertion in tests/fm-web.test.sh, unless the user wants it.🔧 Fix applied.
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
! cmd) in the terminal: the page does not get stuck on Working!command also started a short model turn, so the strictly idle case rests on the unit testbash tests/fm-web.test.sh(targeted suite for this change; all 11 cases passed)bin/fm-lab-home.sh create $LAB+tmux -L fm-lab new-session ... -e FM_HOME=$LAB claude(real Claude primary, private lab socket; took the lab lock on its first turn)FM_HOME=$LAB bin/fm-web.sh start --port 18767, thenstatus,stop,status(exit 3) andurl(refused once stopped)Headless Chrome 153 driven over CDP: sign-in with?token=, then the address, HttpOnly cookie, header state, chat items and fleet panelTyped a message into the page box and pressed Enter; the delivery note, Working/Ready transitions, rendered reply (<ol start>numbering,<strong>) and the terminal pane all matchedRan! echo bash-mode-probein the terminal; the page state stayed Ready for 10sDropped a PNG and a .txt file onto the page; only the PNG became a thumbnail. Sent it, then checkedstate/desk-voice/shots/(0700 folder, 0600 file) and had the primary Read the fileRan/clearin the terminal; the page switched to the registry session (.lock-sessionkept the old id) and a follow-up message landed in the new conversationRan curl attacks on the live server: no token, wrong token, Hostevil.example, Hostlocalhost, wrong port, POST without CSRF, foreign Origin,localhostOrigin,Sec-Fetch-Site: cross-site, no cookie, non-image attachment, and a connection over the LAN IPInspectedstate/web/web.logfor the token and for successful/api/GET poll linesCDP screenshots of light and dark themes and of the 420px layout with the Fleet toggletmux -L fm-lab kill-server, then confirmed the page reads Not running✅ **Document** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.