Skip to content

feat(windows): support full host deployment - #127

Open
kimjunny wants to merge 358 commits into
AltanS:mainfrom
kimjunny:fix/windows-ctl-runtime
Open

feat(windows): support full host deployment#127
kimjunny wants to merge 358 commits into
AltanS:mainfrom
kimjunny:fix/windows-ctl-runtime

Conversation

@kimjunny

Copy link
Copy Markdown

Summary

  • add Windows-native bridge/config/path handling and declare Windows in the plugin manifest
  • replace duplicated launcher behavior with one TypeScript ctl entry point across Task Scheduler, systemd, and launchd
  • harden start/stop/update/serve/log handling, including pinned update commits, fail-closed version checks, exact process targeting, and supervisor fallback
  • add Windows CI, cross-platform ctl coverage, ADRs, and operator documentation

Prior Windows direction

This intentionally revisits #71, where official Windows support was declined because lifecycle work would have landed twice without a Windows environment to test it. This branch removes that duplication by making scripts/ctl/main.ts the shared lifecycle implementation and adds a Windows CI job. It also overlaps the narrower headless Task Scheduler fix in #121; if this lands, the supported TypeScript backend replaces that contrib-only launcher path.

Verification

  • bun run typecheck
  • bun test ./bridge ./scripts — 776 passed, 0 failed
  • bun test scripts/ctl — 95 passed, 0 failed
  • cd web && bun run typecheck
  • cd web && bun run test — 2,382 passed, 10 todo
  • bun run build — production PWA build, 20 precache entries
  • contrib/windows/collie-ctl.test.ps1 — passed
  • live Windows Task Scheduler start, status, url, and logs
  • live tailnet-only Tailscale Serve HTTPS request — HTTP 200 with CSP and build headers

The POSIX shell lifecycle permission check cannot pass on Git Bash/NTFS because NTFS reports mode 644; the Ubuntu CI job runs bun run test:ctl-posix on a POSIX filesystem.

Security and release notes

  • remains loopback-only behind one managed Tailscale Serve front door; no Funnel
  • preserves exact managed-mapping teardown and narrows Windows force-kill to the absolute bridge entry point
  • keeps the repository at 0.32.0 with no fork-side release/version bump

Post-implementation review passed goal, QA, code-quality, security, and context/history lanes.

AltanS and others added 30 commits July 27, 2026 09:29
fix(bridge): gate /api/config like every other endpoint
… input box

Fixes AltanS#34. Reproduced on a real Claude pane: with a Bash permission dialog focused ("Do you want to
proceed? ❯ 1. Yes"), a free-text reply had its text swallowed and its Enter ANSWERED THE DIALOG —
the command ran. The message was destroyed and the bridge returned {ok:true}.

The bridge is not at fault and cannot be: both Herdr RPCs genuinely succeed, because an ack means
"herdr took the bytes" (HERDR_API.md), never "the TUI acted on them". Only a client that can read
the input box can tell the difference, so the guard is client-side and there is no bridge change.

Every other path that types into a live TUI (prompt-/wizard-/preview-action) already refuses to send
a key it hasn't verified the pane is ready for — "Enter is never sent blind". The reply path was the
one exception. It now uses the same choreography submitPreviewNote uses:

  1. refuse outright while a dialog is on screen (free, from blocks agent-chat already builds), and
     do it BEFORE the destructive pre-clear sweep — those ctrl+k/Backspaces would land in the dialog
  2. type with submit:false
  3. poll fresh reads until the adapter sees our text on the "❯" line
  4. only then submit — empty text + submit:true, so the bridge sends nothing but its configured
     submitKeys and the submit-key contract stays server-owned
  5. never verified → NO key is sent at all, and the caller keeps the draft

Scoped to harnesses with an adapter. Without one the input box is unreadable, and a raw-mirror
heuristic would strand a no-echo input (a shell's sudo prompt) with the submit key withheld forever,
so those keep the previous one-shot send.

The verification read happens before the first sleep, so the common path now costs one live pane.read
instead of the old fixed 350ms REPLY_SETTLE_MS.

Test harness: the fake pane models a real input box (typed text appears on the "❯" line, submit
clears it), so the composer suite exercises the real two-step protocol rather than bypassing it.
Both new regression tests were negative-controlled — reverting the guard fails them with "expected
'sent' to be 'stalled'", and removing the fail-fast fails the composer test.

Design reviewed before implementation; the verify-between-the-two-steps shape came out of that
rather than the pre/post bookends I started with.
fix: never send the submit key until the reply text is verified in the input box (AltanS#34)
…ack does not mean

Two facts that cost real debugging time on AltanS#34, both live-probed rather than assumed:

send_text uses no bracketed paste — verified by sending into `/usr/bin/cat -v`, which prints control
bytes: the text came back bare, no ^[[200~ framing. Since a PTY is an ordered byte stream, that kills
every "the Enter overtook the text" hypothesis, which is where AltanS#34 debugging started and wasted time.

And an ack means herdr took the bytes, not that the TUI acted on them. That distinction is the whole
reason AltanS#34 could not be detected bridge-side, and the reason reply-action.ts reads the pane back.

(`cat` is aliased to `bat` on this host — the probe needs the absolute path or it proves nothing.)
Both places that point a uid-containment reader at the device gate oversold what it
buys. The gate is writes-only: guard() consults deviceAuth for "write" alone, reads
need no Origin, COLLIE_TRUSTED_USER passes an absent login, and the Host allowlist
always permits loopback. So the other uid keeps reading snapshots, pane output and
transcript history, which for that reader is the half that actually leaks.

README's sharp edge now qualifies the gate as writes-only; ARCHITECTURE §6 said
"close the port with the device gate" and now says it makes the port read-only.
Neither restates the Variant B text that already calls the header out as not a
confidentiality boundary. §6 also records that closing the read side is outside
what the bridge does: it needs an unshared port (own netns, or a uid owner-match
such as nftables `meta skuid`), since a plain port firewall rule won't stop a
same-host peer.

Reported by @simonallfrey, following up on AltanS#33.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…side

Reworked exactly as asked, verified here before merging.

`entryGuard` now returns `GuardOutcome`, so the passing path carries its region instead of borrowing a type that reads as a failure, and `doReq` is generic again — it knows a caller *may* claim a non-ok response, not which status or which body shape.

I negative-controlled the reply-path regression you caught yourself: dropping `recoverPromptChanged` from `sendReply` alone fails `api.test.ts:86` with `Serialized Error: { status: 409 }`. The test fires, so that asymmetry can't come back silently. 364 backend and 1074 web tests pass on the merge result, both typechecks clean.

Two notes for the record, neither blocking:

The audit line carries the expected region, but `sanitize()` caps every string at 120 chars, so a guarded write costs a preview rather than 8 KB of dialog text per tap. That happened to be already handled — worth knowing it's load-bearing if anyone raises `MAX_STR`.

`prompt-action` and `wizard-action` bind `model.signature` directly while preview/multi-select needed the additive `regionSignature`. That asymmetry is only safe because those two signatures are verbatim screen text, which is exactly what `prompt-binding-regions.json` pins. The fixture set is the thing keeping it true.

Thanks also for raising the precondition upstream in the existing revision thread rather than opening a competing one, and for establishing that `pane.read` returns `revision: 0` unconditionally on 0.7.5. That's the fact that keeps this a mitigation, and it's now written down where it belongs.
…lled PWA

The service worker answered every navigation except /api/ from the precached
app shell, so a proxy's sign-in page was invisible to an installed app — and
an installed app has no address bar to fall back on. A reload re-rendered the
same refused UI. The only crack in the precache was the API's own namespace,
so the workaround was to serve an HTML page under /api/, squatting a path
Collie could reclaim at any time.

/auth/ is now reserved: the SW always passes it to the network, Collie routes
nothing there, and the bridge answers with a placeholder saying so, since the
SPA fallback would otherwise hand back the very UI the operator is trying to
escape.

The refusal banner links to it. Reload alone was never enough — the banner
said it let a proxy serve its sign-in page, which the precache made false. The
link is an <a> with a real href on purpose: a button's click handler is a
same-document action the SW never sees as a navigation.

The denylist lives in web/src/lib/sw-routes.ts, imported by both the SW and
the banner, and is asserted against the built dist/sw.js contract in tests —
too narrow and the sign-in page is unreachable, too wide and Collie's own deep
links stop resolving offline. Both failures are silent.

Fixes AltanS#31
Workbox tests the NavigationRoute denylist against `url.pathname +
url.search` — verified in the vendored workbox-routing/NavigationRoute.js,
where `_match` builds exactly that string. A rule anchored on a trailing
slash therefore missed `/auth?rd=%2F`, which is the redirect shape Authelia
and oauth2-proxy both emit, and the precache answered it: the bug this
branch exists to fix, in its most likely real-world form. Every test passed
because every test fed a pathname with no query string.

Also reserves `/cdn-cgi/`. Cloudflare Access cannot be moved off it, so
pointing that operator at `/auth/` cannot help them — their callback would
be swallowed by the precache. Prefixes that CAN move (oauth2-proxy's
--proxy-prefix, Authelia) are documented rather than reserved.

The two implementations of the reservation — the SW denylist and the
bridge's isReservedAuthPath — now have a shared test that runs one case list
through both and requires them to agree. Each side already had its own
tests, so either could have been edited alone and stayed green while the
two drifted, and drift is silent in the way that matters.

README records the two things the live test established: the passthrough
covers a return-to query string, and a proxy that refuses the static bundle
to a signed-out client permanently freezes that client's service worker —
measured, update() throws outright — which is what strands a device that
lapsed before upgrading.
fix: give a fronting proxy a reachable path from an installed PWA
Pane history was Claude-only and hardcoded: one parser, one filesystem
source, both wired straight into the route. Every other harness Herdr can
detect had no journal and no way to get one short of forking the reader.

History is now an adapter keyed on the pane's agent (bridge/journal/), the
same shape the frontend already uses for block grammars. An adapter is
{agent, source, parse}: the fs half finds the log, the pure half reads the
grammar, and the harness-blind store owns caching and paging. The registry
is built FROM each adapter's own `agent` field, so a key can't drift from
what it points at, and an agent with no adapter simply has no journal.

Three adapters ship, each verified against real logs on disk:
  claude — unchanged behaviour, moved behind the seam.
  codex  — ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl. Rows are
           double-booked (response_item AND event_msg carry the same turns);
           we take response_item, the only family with tool results. Rows
           have no id, so the paging cursor is synthesised from row content
           rather than position, which a tail-read window would shift.
  pi     — ~/.pi/agent/sessions/--<cwd>--/<ts>_<uuid>.jsonl. Every row has
           its own id, so paging is free.

Two live findings from probing real panes, both fixed here:

  * pi reports `agent_session` as kind "path" — an ABSOLUTE PATH chosen by a
    process we don't control — and state-engine kept only kind "id", so pi
    could never have had history at all. Both kinds are kept now, and which
    are meaningful is the adapter's call. A path ref is treated as hostile
    input: confined to that harness's root after symlink resolution, with
    failure indistinguishable from "no log" so containment isn't probeable.

  * Herdr keeps reporting the last session announced for a pane, so
    relaunching its agent as a different harness leaves the old ref behind —
    a pane running pi still advertised a herdr:claude id. The ref is now
    dropped unless its own `agent` matches the pane's.

Because a session ref can be a filesystem path, it no longer goes on the
wire: /api/snapshot strips it to `hasSession`, which is what the History
affordance always actually wanted, and is now also gated on the harness
having an adapter at all.

scripts/journal-probe.ts probes every adapter against this machine's real
logs — the check unit tests can't make, since it's format drift that breaks
a journal. It caught codex 0.145 adding a `developer` message role that the
parser would have rendered as something the operator said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both strips are direct children of the space route's `flex min-h-0 flex-1
flex-col overflow-y-auto` scroller with no `shrink-0`, so they were
flex-shrunk vertically: 16px and 17px containers around 32px chips, whose
natural need is 48px. The rows then overlapped by 15px — the space pills
rendered sliced in half under the tab row, and a short label like `code`
was reduced to its two rounded end-caps.

Predates the theme work (reproduced against the shipped 0.17.0), and it
shows in both themes. Split out from that branch so it can be read on its
own.
Collie has been hardcoded to dark since 0.1.0 (`class="dark"` on <html>).
Unpin it and let the cascade decide.

Cascade. Every themed value is one `light-dark()` declaration keyed on
`color-scheme`, so System needs no JavaScript at all and there is no second
dark block to drift. Verified first that `color-mix()` resolves a
`light-dark()` argument, which the app-wide `border-border/60` and
`outline-ring/50` base rules depend on.

First paint. public/theme-init.js applies an explicit pin before paint. A
same-origin file, so `script-src 'self'` already allows it — no CSP change.
It stores a BARE string; useTheme deliberately diverges from
use-display-prefs' JSON encoding to match, because a strict compare against
a JSON-quoted value would fail silently and take the anti-flash with it.

useTheme owns what CSS cannot: the pin class (bidirectionally — a stale
class is why Dark to System would otherwise do nothing until a reload), the
theme-color metas, and the OS listener. Module-scoped so the three controls
agree and the listener outlives the idle lock unmounting the router.

The mirror renders in dark space and light inverts it (ADR 0002 follows).
Agents emit truecolor almost exclusively — 446 sequences in a live pane,
zero basic ANSI — and truecolor names an absolute colour no palette can
re-theme. ansi.ts still emits var(--ansi-N) for indexed colour, via both
spellings: color256() resolves 0-15 through the same table, so theming one
and not the other renders the same logical colour two ways.

Light surfaces are a ladder (page rgb 235 / card 255) rather than stock
shadcn's all-white, which rests the whole dashboard hierarchy on one 1.26:1
hairline. It also lands exactly on the mirror's inverted background, so the
seam at the pre's edge disappears.

Contrast work, all measured in a browser rather than modelled: light
--status-* retuned against the worst ground they land on (a translucent chip
over the pane header, not white — that took three attempts); --muted-
foreground darkened and every /70, /80 and opacity-60 modifier on small text
dropped, since no token value rescues a /70; focus rings to full strength
(they were 1.77:1); the off-switch given an outline (it was a white thumb on
a 1.09:1 track); header controls to 44px boxes without negative margins,
which overlap their neighbours and overflow the page.

Version bump rides with the release commit, so every commit on this branch
passes scripts/check-version.sh.
… by attention and recency

Herdr reports no timestamps at all — not on panes, tabs, or workspaces — so Collie derives and
owns them. An ActivityLedger persisted to the state dir (alongside snooze.json / notify-prefs.json)
records, per session and pane:

  activeAt — the last agent status transition the state engine observed
  seenAt   — the last time you opened or drove the pane through Collie

That is enough for the whole feature. "Unseen" needs no stored flag: an agent is newly-finished-
and-unread exactly when `status === "done" && activeAt > seenAt`, so opening the pane clears it by
construction.

A first sighting seeds activeAt = seenAt, matching the rule the engine already applies to
notifications — a fresh start must not open on a screen of alerts you were never shown. Writes are
debounced to one per 10s (an open pane polls ~1/s and each poll marks it seen) plus one on
shutdown; entries are reconciled against the live pane set each successful poll, which reaps bare
shells too — the engine's removal event is agent-derived and never fires for them.

Also denormalises the pane's tab label onto AgentView, exactly as workspaceLabel already is, so no
client has to join tabs[]. meaningfulTabLabel drops Herdr's positional default ("1") in a
single-tab space, where it would render as "project · 1" and read as a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The decision closes off an option someone will absolutely re-propose ("why
not just give the mirror a light ANSI palette?"), and the answer is a
measurement rather than a preference: four live panes emit 446/150/461/109
truecolor sequences and zero basic ANSI codes.

Records the three rules that follow — dark-space authoring, literals not
tokens, filter scoped to the pre — and the sharp edge that cost a blocker
during review: cancelling the filter is only safe on an element that fully
specifies its own foreground, or inherited text double-inverts to invisible.

Carries the short normative rule in CLAUDE.md linking here. The ADR states
its own measurements and method rather than citing a design trail, so it
stands alone.
…alling every agent "claude"

Three complaints, one cause: the dashboard was ordered by how Herdr stores things, not by what
needs doing. 45 spaces rendered in creation order above the agents; agents sorted by status then
creation order, with nothing distinguishing one that finished ten seconds ago from one dealt with
yesterday; and every row titled "claude", because the title fell back to the agent name.

Ordering (lib/triage.ts, replacing lib/agent-groups.ts):

  Needs you → Ready · unseen → Working → Recent

The first three are pinned — they never move, never invert, and never fold. Recent runs by when you
last used each pane and is the only section the direction toggle reaches. Recent and Spaces fold and
remember it; fold both and the page is the triaged herd and nothing else.

Naming (lib/pane-name.ts): a row is titled `project · tab`, with the pane's own name (a herdr label
or Claude's /rename session name) on the second line where the cwd used to be. Nothing is lost — the
agent's identity was always the avatar, never the text. In the space detail view, which already
groups under a per-tab heading, the pane's own name leads instead (scope="tab"): repeating the
heading would say nothing, and two panes in one tab would stop being distinguishable.

Spaces moves BELOW every agent section — it's a navigator, not a work queue — and gains recency
ordering plus a filter box. That retires the reason home.tsx split the agent list in two to hoist
"Needs you" above it; AgentList now renders once.

With no timestamps (an older bridge) this degrades to today's dashboard with no branch: the unseen
test is false, every comparator returns 0, and Array.sort is stable, so each section keeps the
bridge's own order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the two options that will otherwise be re-proposed forever: making "seen" per-device
(rejected — an alert cleared on the phone must not still shout on the laptop), and counting a Herdr
focus at the desk (rejected — a pane clicked past would silently clear an alert you never read; a
false positive costs one tap, a false negative costs a missed agent).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Security review of this branch caught a regression it introduced. Marking a pane seen made a
READ-level GET mutate server state, which no read had done before — and checkAccess deliberately
does not require an Origin on reads, because browsers omit it on same-origin GETs and demanding one
would reject the real client.

So a page the operator visits while on the tailnet could fire

  <img src="https://collie…/api/pane/w1:p1">

at guessable pane ids (herdr's are w<n>:p<m>) and silently clear the whole "Ready · unseen"
section. The response is opaque to the attacker and nothing can be typed into a terminal — writes
still require an Origin and the device gate — but the write lands, and the operator simply stops
being told their agents finished. That is precisely the signal this release exists to deliver.

marksPaneSeen now gates it on a custom request header the web app sets on its own pane and history
reads. A no-cors cross-site request cannot set one: doing so promotes it to a preflighted CORS
request, and the bridge answers no preflight. Write actions need no header — they already cleared
the Origin-requiring write gate.

Pure and exported, so it is unit-tested in bun's runner like checkAccess beside it.

Also folds spaceLastSeen into a single-pass spaceLastSeenMap. The dashboard re-renders every poll
and was deriving it per space and again per row — spaces x panes, three times over (45 x 59 on a
real herd).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Folded into 0.20.0 rather than cut as 0.19.1 — the release was never pushed or tagged, so the
vulnerable code never shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…luded

The swipe-up switcher feeds ThreadSidebar the WHOLE herd, so it had the exact problem the dashboard
was just fixed for: on a real herd that is 20 agents and 37 bare shells, and the handful you opened
the sheet to reach are buried under both.

Recent and Shells now fold, using the dashboard's own SectionHeader so the affordance is identical
in both places rather than a second implementation that drifts. The attention sections stay pinned
open here too — collapsing an alert defeats the alert.

Shells fold on the same count rule Spaces uses, so the preference generalises: spacesOpenFor becomes
openForCount, with one COLLAPSE_THRESHOLD. An explicit choice always wins; otherwise a section opens
only while it is short enough to be worth showing. Recent's fold state is shared with the dashboard,
so "hide the long tail" means one thing across the app.

SectionHeader gains a heading level, defaulting to 2 — the switcher's sections are h3 because the
sheet's own title is the h2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g its own section

UX round 1/5, against the real herd at 390px.

The rename shipped its premise and then broke it with a different word. Eight panes in one project
all begin "moonward_os · ", so tail-truncating the joined title ate the tab and left rows reading
"moonward_os · t…" — two different panes rendered the identical string. The 11 characters that
survived were the ones every row shares.

Project and tab now render as separate spans (paneParts): the project is capped at 45% and yields
width first, the tab takes what's left. paneTitle stays as the joined form for search text and
aria labels.

The width came from three places that were spending it on nothing:
  - every Recent row wore an "idle" pill under a heading that already said Recent, and every
    Working row a "working" pill. Inside a triage section the status is now a dot (the word stays
    for screen readers); the full badge remains where status ISN'T implied, i.e. the space view.
  - the ChevronRight on a full-width button that already has press feedback.
  - timeAgo moved to line 2, beside the secondary text rather than competing with the title.
    This restores the layout design.md specified all along.

Also: section headers were two different sizes and cases, because a <button> doesn't inherit
text-transform or font-size from its <h2> — pinned sections rendered small-caps "WORKING" while the
foldable tail rendered larger sentence-case "Recent", making the least important section the
loudest thing on the page. Both branches now set the type explicitly.

And the dashboard was silently missing the status-colour bullet the switcher had, because each
caller picked header fields by hand; both now spread sectionHeaderProps().

Smaller: the sort control reads as a pressable chip rather than an annotation, and is fixed-width so
flipping it doesn't shift the header; the filter input clears the 36px floor; and when nothing is
blocked the list says "Nothing needs you" instead of leaving an absence to interpret.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng line 1

UX round 2/5.

The density win came from the second line, not the borders. Eight of eighteen Recent rows read
"moonward_os" on line 1 and "…ropbox/dev/moonward/moonward_os" on line 2 — the same word twice, in
a mono face the eye slows down for, and clipped at BOTH ends (shortCwd head-elided it, then CSS
tail-truncated what was left to seat the timestamp). A string cut at both ends is worth nothing.

The cwd is now dropped when the directory's own name is the project label, which is almost always —
a space is named after its directory. It survives for the case that carries information: a pane
sitting somewhere other than the space root, a worktree or a subdir. Most rows collapse to one line.
shortCwd also drops whole path segments now instead of characters, so an abbreviation stops looking
like a rendering fault.

Card chrome on 100% of rows is wallpaper, not emphasis: a Working row and a Recent row rendered
pixel-identically, throwing away the four-level priority triage() had just computed. Cards are now
reserved for the sections that mean "a human is required here" (Needs you, Ready · unseen);
everything else is a flat row on a hairline divider. See a card, something wants you. The blocked
tint survives on both paths untouched — it's the one cue that reads at a glance.

Status dots were drawn as solid discs in a palette tuned for TEXT contrast, so eighteen idle dots
carried the same weight as the one thing that needs you. Resting states (idle/unknown) are hollow
rings now; states that mean something is happening stay solid. The dot also moved from the far right
— where the eye crossed 200px of empty card to reach a 10px mark — onto the avatar's corner.

Also: "Nothing needs you" is the product of the whole glance and was rendered in the page's faintest
type; it now has presence. The sort chip lost its fill (it outweighed the heading beside it). The
Spaces count reports what you can SEE while filtering, the filter sticks to the top of a five-screen
list, and its rows lost a chevron the agent rows had already dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he avatar

UX round 3/5.

The hollow ring shipped with bg-transparent, so on the avatar's corner its interior showed orange
logo through one half and page grey through the other, wrapped in a ring — indistinguishable from a
rendering fault. It was worst exactly where it mattered: Recent is the only section that ISN'T
homogeneous (idle, unknown and already-seen done all land there), so its per-row dot is the only
status signal on the page, and it rendered broken on 17 of 21 rows. A hollow ring is now filled with
the surface it sits on, and the card/flat paths ring in their own surface rather than both assuming
the page.

Line 2 had become a lone mono timestamp — a footnote given the same vertical presence as the title,
in the font the path used to occupy, left-aligned because nothing pushed it right. The age moved up
to the title row's trailing slot and the second line renders only when there's something to say, so
most rows are one line. That freed enough room to date the Working rows too: "working for 3h" and
"working for 40s" are very different facts.

Hoisting the age then cost the title ~55px and truncation crept back ("interview-con…"), so the
column drops the "ago" every entry was repeating — the column's meaning is already established.

Card and flat rows also disagreed about their avatar origin by 5px, so the column your eye rides
down the page stepped sideways at each section boundary.

And the switcher — the surface you use to jump TO the thing that needs you — rendered every pane
identically, with no way to show a blocked one as blocked. It now applies the tint via isAttention(),
which exists for exactly this and had no callers. Denser than the dashboard is fine; blind is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…are here" showed nothing

UX round 4/5. Two objective findings, both measured.

Light --accent was still stock shadcn's oklch(0.97) — correct only while --background is pure
white. 0.18.0 deliberately moved background to 0.97 (the hairline-contrast fix), which silently made
accent === background, so every "this is the current one" fill rendered as literally nothing in
light mode: the open pane in the switcher, the current session in the session switcher, and every
hover:bg-accent. It now sits at 0.92, between --background and --muted.

That is also why round 3's switcher fix only half-landed. A blocked pane you were CURRENTLY IN had
its attention tint suppressed by the !active guard and its active fill was invisible, so it rendered
with no marking at all — the one pane you're looking at, in the state the app exists to surface. The
border now applies regardless of active, so both cues compose: accent fill plus alarm edge. Only the
background is withheld, because two backgrounds can't both win.

Second: the flex filler that pushes the age to the right edge lived on the TAB span, and an
unlabelled single-tab space has no tab — so those rows had no filler and the age butted straight
against the project name, reading as part of it ("comm_cli 37m") while every other row's age lined
up at x=360. The project takes the width itself when it is the whole name.

Deliberately NOT taken, on the evaluator's own reasoning: an age on blocked rows (it would re-truncate
the titles round 3 fought to free, and "blocked" already outranks "how long"), and a tooltip
disambiguating last-opened from time-in-state (a title attribute does nothing on a phone).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n 0.20.0

The comment claimed 0.92 "sits between --background (0.97) and --muted (0.94)" — it's below muted,
not between. The value is right (the switcher needs three separable steps: resting 0.97, hover 0.94,
active 0.92, and 0.94 would have collided the last two); the sentence describing it was wrong, and
would have misled whoever reasoned from it next.

Round 5/5 verdict: SHIP. Confirmed all three round-4 fixes landed by measurement, swept r0→r4 for
regressions across seven properties (full tab names, uniform headings, cards only on attention
sections, single-line rows, the age column, avatar alignment, hollow-vs-solid dots) — all pass, no
horizontal overflow and no sub-36px targets at 390/768/1280.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AltanS and others added 28 commits August 19, 2026 15:20
The connection bar lives in RootLayout and was dated by HomeData alone. Cold
boot straight into /pane/:paneId and the two stamps come apart: the operator
opened that pane at 12:05 and left the dashboard polling until 14:32, so the
12:05 terminal text on screen sat under a bar claiming "last seen 14:32" —
the same undated-old-screen dishonesty, one level down.

paneLoader already carried the pane's own stamp and nothing read it. The pane
route now has an id, RootLayout asks the router for that route's data (react-
router returns undefined whenever it isn't active), and shownLastSeenAt picks
the stamp of the data actually on screen: the mirror's while a stale mirror is
what's being read, the herd's otherwise. An undatable mirror shows no time at
all rather than borrowing one that isn't about it.

No new store or context — both stamps were already in the loader data. ADR
0017 behaviour is untouched.
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
@AltanS

AltanS commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Thank you for the scale of work here. Full Windows host support is something we want, so a note on target and timing before you invest more:

  1. main is the wrong base now. This PR is built around scripts/ctl/**, and the v1 branch (which becomes 1.0) has replaced that layer entirely: every verb now lives in cli/*.ts behind a compiled bin/collie. Merging to main would ship code the 1.0 cutover deletes.
  2. The ask: retarget to v1 and rework the control layer against cli/. Your supervision and Task Scheduler design carries over; it is the invocation layer underneath that changed.
  3. Honest expectation: 1.0 will not gate on Windows host support. This lands in a 1.x release. See CONTRIBUTING.md for the base-branch policy and gates.

If you would rather wait until 1.0 ships and rebase once against a stable cli/, that is a reasonable call too. Say so and we park it without closing it.

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.