Attach to an already-running detachable Lich session - #2
Open
kahlen-tech wants to merge 12 commits into
Open
Conversation
A third connection mode beside Lich-launch and Direct: point a tab at the listener of a Lich started attachably (lich --login Char --headless PORT, or any --detachable-client= launch) and render the session that is already logged in. No SGE, no password, no spawn — Lich's detachable listener takes a bare TCP connect (no auth, no handshake; lich-5 main.rb detachable_client_thread → global_defs.rb handle_detachable_client), pushes a Stormfront-XML state resync (vitals / spell / indicators / compass), and treats every line we write as typed client input. - AttachConnection: the transport. Electron-free on purpose so the protocol is exercisable by a plain-node harness against a fake listener. Never sends a login key or FE:WRAYTH line (they would dispatch to the game as commands), and never QUIT. - ConnectionManager: mode 'attach', connectAttach() with a friendly ECONNREFUSED translation and a post-attach `look` nudge (Lich's resync covers everything except the room). gracefulDisconnect DETACHES in this mode — half-close, no QUIT — because from an attached client Lich runs a user exit as full-session shutdown for every attached front-end. Typing `exit` in the tab remains the deliberate log-out path. - main: CH.LOGIN_ATTACH mirrors CH.LOGIN's lifecycle (hold-for-replay so the attach-time resync isn't dropped before the GameWindow mounts, roster, progress mirroring). useLich=true keeps the Lich Scripts panel and the rest of the Lich-only surface alive for attached tabs. - renderer: '⇋ Attach' in the launcher top bar → a three-field modal (character / host / port). Account+game resolve from the character's existing profile YAML when one exists, so the roster, the one-per-account conflict planner, and the ambient profile export stay truthful; the per-character profile (layout, highlights, macros, theme) loads exactly as on a normal connect. Known draft edges, deliberately out of scope: the tab menu's Reconnect runs a full relaunch login rather than a re-attach; an attach-only character's ambient profile save creates a tile under the 'attach' placeholder account; a headless Lich started with --genie sends no attach-time resync (Lich skips init for genie-identity sessions). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RES15zXfaUZZjD8PVz9ixy
… one-click and in-tab re-attach
The first cut attached but forgot: closing a tab (or the app) left no way
back except retyping host/port. Now the target is remembered and every
reconnect surface re-attaches:
- CharacterProfile grows a launcher-owned `attach` field ({host, port}),
written on every successful attach via saveCharacterAttach — the same
read-modify-write shape as setCharacterGame (buildCharacterProfile would
wipe a non-active character's state). For an attach-only character it
creates a minimal stub profile, whose tile IS the reconnect surface.
exportCharacterProfile's preserve list learns the field so ambient saves
can't strip it.
- Tile ⋯ menu gains "⇋ Attach (host:port)" when a saved target exists.
- The Attach modal opens prefilled with the last successful attach
(lichborne.attach.last) and autofills host/port when a typed name matches
a profile-saved target.
- The tab's Reconnect re-ATTACHES for attach sessions instead of relaunching
a login (which would spawn a second Lich against an account whose headless
session is still logged in — DR bounces one of them). Identity is pinned
from the session record so the revived tab keeps its characterId and
replaces itself rather than opening a second tab.
- The attach target rides main's session meta and the roster, so a
decoupled / re-homed window inherits the re-attach path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RES15zXfaUZZjD8PVz9ixy
…back Clicking Connect on a tile that carries a saved attach target used to run the normal login flow — which for an attach-only stub tile can only dead-end in the Add Account wizard asking for a password that doesn't exist. Connect now means "put me in that running session" for these tiles: - handleCardConnect branches before the same-account conflict modal: that guard protects a NEW login against DR's one-per-account law, but an attach joins a session that's already on — and stub tiles all share the 'attach' placeholder account, so it would cross-flag unrelated characters. - Per-character handling replaces it (prepareTileAttach, shared with the ⋯ menu path): a connected tab for the character is focused instead of double-attached (the protocol allows it; the characterId replace would orphan the live record), a dead one is torn down so the revived session replaces it cleanly. - When NOTHING is listening at the target, the click still means "get me in the game": a real account falls through to the normal launch-and-login flow; a stub tile surfaces the banner (no login to fall back to). - The card button says what it does — "⇋ Attach" with the target in the tooltip — and the 1.5s grace/Cancel overlay works for attach too (a cancel that loses the race destroys the landed session, which for attach mode is a detach: the running Lich is untouched). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RES15zXfaUZZjD8PVz9ixy
Reconnect Last ran every saved character through the login flow, so an attach tile died on "No saved password" (a stub has none) — and even a real account would have been starting a SECOND Lich against a headless session still logged in. Two fixes, one in the pure planner and one in the batch: - planReconnect: an attach pick bypasses the account arithmetic. All three account rules exist to protect a LOGIN from DR's one-per-account law; an attach starts no login, so no conflict row and no one-per-account batch dedup. The dedup was load-bearing, not cosmetic: every attach-only stub shares the 'attach' placeholder account, so it dropped all but the FIRST and "Reconnect Last (3)" revived one character. Attach picks still skip when already on, matched by CHARACTER (a stub's placeholder account never matches a real roster account). Login-mode rules are unchanged and regression-locked by the harness. - runBulkConnect: saved-target characters route through the new shared tryAttachPick — also used by the tile Connect button, so the "nothing is listening" fallback judgement (log in normally when the tile has a real account; a clear failure when it doesn't) cannot drift between the two entry points. Attached sessions honour "open each in its own window" like any other; a focused already-open tab reports no sessionId, so nothing the player already had open gets moved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RES15zXfaUZZjD8PVz9ixy
Attaching a SECOND character while one was connected was impossible without closing every open session first — the modal you get from the `+` tab had no Attach control, and even wiring one in would not have worked. Two separate faults: 1. NO ENTRY POINT. LauncherTopBar (which holds "⇋ Attach") renders only when !compact, and the Add Character modal renders the launcher compact. The onAttach prop was passed and silently dropped. Compact now gets CompactAttachRow — one button, not the whole bar, since compact deliberately sheds the logo/headings/chrome. A tile with a saved target was already attachable there via its own Connect button; what was missing is attaching a character Lichborne has never seen. 2. WRONG STACKING CONTEXT. AttachModal reuses CharacterNotesEditor's cne-* chrome and inherited its z-index of 100 — below .add-character-modal (1000). Opened from inside that modal it rendered BEHIND the backdrop, so the new button would have looked dead. `attach-backdrop` lifts it to 1600: above the launcher-connecting overlay (1500), below QuickSend (2000). Precisely the bug .launcher-connecting already documents, so the fix matches it. Edit Profile… has the same latent bug from the same inheritance; left alone as pre-existing and unrelated. Verified the compiled bundle carries both rules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RES15zXfaUZZjD8PVz9ixy
Reported as "not saving a connection tile for attached accounts" — the tile WAS saved. Launcher account sections default to COLLAPSED for multi-account users, and an attach-only character's stub profile is filed under the 'attach' placeholder account, i.e. a brand-new section the player had never opened. Correct YAML on disk, correct card from loadCharacterCards, nothing on screen: indistinguishable from the feature doing nothing. AddCharacterWizard already solves exactly this for wizard-created tiles by adding the new account to lichborne.launcher.expandedAccounts before bumping refreshKey. Attach creates tiles too, so it now does the same — including the wizard's 1→2 transition guard, since a player with one account sees it expanded by the single-account rule and would otherwise watch it collapse the moment 'attach' became the second. Only on stub CREATION; re-expanding on every attach would fight a player who deliberately collapsed the section. Also: a failed profile write now raises a toast instead of a console line. It produced the identical symptom — no tile, no reason — and the attach itself has succeeded by that point, so it is a toast, not an error banner. Verified end-to-end by bundling the real Launcher functions against the real main-process profile store (harness in the proposal bundle): profile written, card produced, section expanded, prior account preserved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RES15zXfaUZZjD8PVz9ixy
Reported as "only one of my attached accounts shows a tile". The tiles were never the problem — the second attach was silently landing on the FIRST character. The modal prefilled the character name from the last successful attach. But this modal exists precisely to attach a character that has no tile yet (one with a saved target attaches from its own Connect button), so the common case is a DIFFERENT character than last time. A player who changed only the port attached under the previous name — which SUCCEEDS, because the detachable protocol neither sends nor checks a character name — mislabelling the tab, overwriting that character's saved target, replacing its session record via addSession's characterId match (orphaning the real session in main), and producing no second tile. - The name field now always starts empty, and says what it must match: the character that Lich session logged in as. - Host still prefills (stable across sessions, and wrong values fail loudly). The PORT no longer does — every headless Lich listens on its own, so last time's port belongs to someone else. It still fills in from the saved target once a known name is typed. - The modal path gains the per-character guard the tile paths already had: typing a name that is already attached switches to that tab and says so, instead of replacing a live session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RES15zXfaUZZjD8PVz9ixy
Evidence from Kahlen's install: three attached characters, three saved targets, one visible tile. Nothing was lost — they were filed under three DIFFERENT accounts (Ethun→lenairk6, Tranon→lenairk21, Remu→the 'attach' placeholder, since only Remu lacked a prior profile), and only the section that got created was auto-expanded. The other two sat in collapsed sections, so counting tiles under 'attach' found exactly one and looked like the other attaches had not saved. Keeping a known character on its REAL account stays correct — it is on that account, and after detaching the player may want a normal login — so the fix is visibility, not filing. expandAccountOnce now runs on EVERY successful attach, including when the target is unchanged and nothing is written. This reverses the previous commit's "only on stub creation, or we fight a deliberate collapse" reasoning, which the evidence contradicts: a player who just attached a character wants to see that character, and this fires once per attach rather than persistently overriding their choice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RES15zXfaUZZjD8PVz9ixy
The attach-time resync was arriving and being parsed — and setting every
vital to 0.
Lich's detachable_client_send_init builds the init string by hand and
hardcodes value='0' on all four bars, carrying the real numbers ONLY in the
text attribute:
<progressBar id='health' value='0' text='health 100/100'/>
Lichborne's progressBar handler reads `value` and assumes max=100, which is
right for DR's own bars (value IS the percentage) but turns Lich's resync
into health/mana/spirit/stamina all at zero. Worse than blank: a zeroed bar
looks like live data and reads as one hit from dead.
The parser now falls back to the text attribute's cur/max pair, deliberately
narrowly — ONLY when value is 0 AND the text carries a non-zero current. DR's
live bars send a correct value, so they take exactly the path they always
did; a genuinely-zero vital still reports zero from either source; and
customText bars ('inner fire 59%', no slash) still fall back to value.
Widening it to "always prefer text" would change the numbers every existing
player sees, which is not this patch's business.
Verified against the exact init string lich-5 emits: 7/7, including
regression cases for live bars, zero health, and customText labels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RES15zXfaUZZjD8PVz9ixy
The previous fix read the text attribute as a cur/max PAIR. In DragonRealms there is no max, so it never matched and every bar still fell back to value='0'. Why the max is missing, from Kahlen's live capture plus lich-5: DR sends <progressBar id='mana' value='86' text='mana 86%' customText='t'/> Lich reads @health, @max_health = text.scan(/-?\d+/) (xmlparser.rb) A percentage yields ONE number, so max_health stays nil. The init then interpolates "#{XMLData.health}/#{XMLData.max_health}" into a trailing-slash string — text='health 100/'. GS sends 'health 100/100', so the pair regex worked there and hid the gap; DR is the shape that matters here. So take the FIRST number in the text and treat a missing or zero max as 100, which is correct for DR because these vitals ARE percentages. GS's real max (118/120) is still honoured when a second number is present. Still only when value === 0, so DR's live bars — which carry a correct value, 86 in that capture — and customText labels take exactly the path they always did. Harness covers both shapes plus live-bar, label, zero-vital and no-number regressions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RES15zXfaUZZjD8PVz9ixy
An attach target is often not loopback — another machine on the network, or one reached over a VPN — so the socket crosses a link that sleeps, roams and re-handshakes. Two gaps made that hurt more than it had to: - NO KEEPALIVE. An idle TCP connection through NAT gets reaped by intermediaries that tell neither end. Lichborne held a socket it believed was live, showed the tab as connected, and found out only when the player typed something. DR sessions are legitimately silent for minutes, so 'idle' is normal and cannot be treated as suspicious. Now: probes every 30s, which keeps the path warm AND turns a dead peer into a real 'close'. Also setNoDelay — vitals and prompts are small and latency-sensitive. - NO RECOVERY. A dropped attach left the tab stranded until the player noticed and reconnected by hand, which defeats the point of a mode whose whole promise is that the session outlives the client. Auto re-attach is safe HERE in a way it would never be for a login: it starts no SGE auth, claims no account slot, spawns no Lich, and cannot bounce anyone from the game. The character never left — Lich still holds the game connection — so a dropped socket is a lost VIEW, and restoring a view is idempotent. Backoff 2/4/8/15/30s then a 30s heartbeat, INDEFINITELY while the tab is open. No attempt cap on purpose: the whole point of attach mode is that the session outlives the client, so the view should keep trying for as long as the tab is left open — a cap would guarantee that the one time the player wanted it back is the time it had quietly given up. Reconnection happens IN PLACE on the same session, so the tab, its scrollback and its panels survive; Lich re-sends its resync per attach, so vitals repopulate. Never after a CLEAN disconnect (tab closed, `exit` typed), and an explicit Disconnect or a session teardown cancels a pending attempt — otherwise a tab the player just closed springs back to life. Harness kills the fake listener mid-session and restarts it: 5/5, covering drop detection, repeated refused attempts, a fresh resync on re-attach, and no spliced partial line across sockets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RES15zXfaUZZjD8PVz9ixy
After an auto re-attach the game feed was live and streaming into the window
while the tab stayed greyed out and the toolbar still offered Login.
GameWindow's status handler cleared its `dropped` flag only on
s.connected && s.message === 'Connected'
— so the connected FLAG was decorative and a human-readable STRING was
load-bearing. Attach mode sends 'Attached' and 'Re-attached', neither of
which matches, so the flag said connected, the socket was connected, text was
arriving, and the UI insisted otherwise.
Now keyed on `s.connected` alone. That is safe by construction: main sends
connected:true only from a genuine connect/attach, because wireSession's
status handler pushes every progress line as connected:false. The message is
free to say something useful, and the session log records what it said rather
than a hardcoded 'Connected'.
The mirror-image check below it — `!s.connected && s.message ===
'Disconnected'` — is deliberately left alone: there the string is doing real
work, separating a final drop from the connected:false progress lines that
precede a successful connect.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RES15zXfaUZZjD8PVz9ixy
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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.
Attach to an already-running detachable Lich session
What this adds
A third connection mode beside Lich-launch and Direct: attach to a Lich that
is already running and logged in — one started as
lich --login Char --headless 8001, or with any--detachable-client=flag.A
⇋ Attachbutton in the launcher top bar opens a three-field modal(character / host / port). No account, no password, no Ruby or Lich paths:
headless Lich authenticated itself from its own saved entry, and its listener
takes a bare TCP connection.
Why it is worth having, as a player: closing Lichborne currently means logging
out. With attach, a session survives the client — close the window and reopen
it without losing your place, recover from a front-end crash without losing
the login, pick the same session up from another machine, or watch it from a
second front-end alongside the first.
Why the Lich side is already done
Everything needed exists in current lich-5 — verified in source, not assumed:
--headless PORTnormalizes to--without-frontend --detachable-client=PORT(
lib/main/arg_normalization.rb). The listener accepts a plain TCPconnection: no auth, no handshake (
docs/runtime-io.md,lib/main/main.rbdetachable_client_thread).compass (
lib/global_defs.rbdetachable_client_send_init) — then the livestream. Game output reaches attached clients through
send_to_client(
lib/games.rb), so the feed is the same one a normal front-end sees.not end the session (
docs/runtime-io.md).orderly shutdown of the whole session
(
lib/main/user_exit_dispatch.rb#dispatch_detachable_client) — which dictatesthe disconnect semantics below.
The hard constraint also lives in Lich: only an attachably-started Lich can be
attached to. A normally-launched one accepted its single front-end and closed
its listener, so this cannot (and does not try to) attach to those.
What the patch does
Twelve commits, ~1210 insertions across 18 files. The transport is small; most
of it is fitting the session lifecycle.
AttachConnection(new) — the transport. Free of electron imports so aplain-node harness can exercise the protocol. Never sends a login key or
FE:WRAYTHline (either would be dispatched to the game as a command), andnever QUIT. TCP keepalive at 30s: an attach target is often not loopback,
and an idle connection through NAT gets reaped by intermediaries that tell
neither end.
ConnectionManager— mode'attach',connectAttach()with anECONNREFUSED → "start Lich attachably" translation and a post-attach
look(Lich's resync covers everything except the room).
gracefulDisconnectdetaches in this mode: half-close, no QUIT, for both the in-tab
Disconnect and app shutdown. Typing
exitstays the deliberate log-out.main.ts—CH.LOGIN_ATTACHmirroringCH.LOGIN's lifecycle, includingthe hold-for-replay (the attach-time resync would otherwise be flushed into a
window with no GameWindow subscriber).
useLich: truekeeps the Lich Scriptspanel and the rest of the Lich-only surface alive. Auto re-attach on an
unclean drop, backoff 2/4/8/15/30s then a 30s heartbeat, reconnecting in
place so the tab, scrollback and panels survive.
CharacterProfilegrows an optional launcher-ownedattach?: { host, port }, written on each successful attach via thesetCharacterGame-style read-modify-write. It drives a tile ⋯ menu entry, themodal's prefill, an attach-first tile Connect button (falling back to a normal
login when nothing is listening and the tile has a real account), the tab's
Reconnect, and the bulk paths.
planReconnectgains an attach branch thatbypasses the account arithmetic — those rules protect a login from DR's
one-per-account law, and an attach starts no login.
AttachModalreuses thecne-*modal chrome; the launcherbutton threads through
LauncherTopBar. Account and game resolve from thecharacter's existing profile when there is one, so the roster, the
one-per-account planner and the ambient profile export stay truthful.
Two changes worth your specific attention
1. The one shared-code change.
GameWindow's status handler cleared itsdroppedflag only ons.connected && s.message === 'Connected'— so theconnected flag was decorative and a human-readable string was load-bearing.
Attach sends
'Attached'/'Re-attached', so the tab stayed greyed out whilegame text streamed into it. Now keyed on
s.connectedalone, which is safe byconstruction: main sends
connected: trueonly from a genuine connect, becauseevery progress line goes out as
connected: false. The mirror-image!s.connected && s.message === 'Disconnected'check is deliberately left alone— there the string does real work, separating a final drop from the progress
lines before a successful connect. This is arguably a latent fix for any future
connect path, but it is the change most likely to surprise you, so it is its own
commit.
2. A DR-specific vitals-resync fix. Lich's init hardcodes
value='0'andputs the numbers only in
text— and in DR that text ishealth 100/, with nomax. Lich derives
max_healthby scanning numbers out of DR's own bar text,DR sends a percentage (
text='mana 86%', captured live), one number is found,and the nil interpolates to empty. GS sends
health 100/100, so the shape onlybites DR. The parser now takes the first number and treats a missing/zero max as
100, gated on
value === 0so live bars are untouched. You may prefer to fixthe nil upstream in Lich instead — this handles both either way.
Verification
tscon both tsconfigs: no new errors vs. v0.19.2 (main matches the stockbaseline exactly, including its one pre-existing error; renderer clean).
npm run buildpasses.reconnectPlan:bytes on attach, zero bytes on detach (no QUIT), re-attach accepted;
detection, repeated refused attempts, a fresh resync, and no spliced partial
line across sockets;
planReconnect's attach branch, with regression cases pinning thelogin-mode dedup / already-on / conflict-row rules unchanged;
customText-label and zero-vital regressions.
and over the network, through ordinary play including combat. Every bug fixed
after the first commit was found that way, not by reading code.
Known edges, left for your call
profile under an
attachplaceholder account, so it has a tile to reconnectfrom. Reasonable, but it is a design choice.
--genie-flavoured headless Lich sends no attach-time resync at all (Lichskips init for genie sessions) and suppresses stream tags, since genie is
registered without the
streamscapability. Plain--headlessis the shapeto recommend.
so the view keeps trying for as long as the tab is left open, and a cap would
mean giving up exactly when the player wanted it back. Easy to put behind a
setting if you would rather it were opt-in.
(
{name, host, port}JSON written per character when detachable), which wouldremove even the first-time host/port entry and the risk of mislabelling a tab
by typing the wrong name. Deliberately not attempted here.
the Attach modal had — opened from the compact launcher inside the Add
Character modal, it renders behind the backdrop. Left alone as pre-existing.
Housekeeping
Built AI-assisted (Claude) against CLAUDE.md's conventions — the one-list IPC,
the hold-for-replay pattern,
cne-*modal chrome, read-modify-write profilewrites, and comment provenance. Happy to rework naming, placement, the
Tracker.md / CLAUDE.md entries, or anything else to fit — or for you to take the
patch and reshape it however you like.