Added
-
The desktop app has a launcher instead of a blank rectangle. The window loads a remote origin, so between double-clicking the icon and seeing a board there was a DNS lookup, a TLS handshake, a document, a hydrate and a canvas mount — and being frameless, nothing on screen to say the app had started rather than failed. Now the mark and the wordmark rise in over an indeterminate sweep, and the window itself stays hidden until it has painted something.
- The sweep is indeterminate on purpose. There's no progress to report — the app is either loaded or it isn't — and a bar that filled to 90% and stopped would be a claim we can't back.
- "Connecting…" appears only after four seconds. Said on every launch it's noise; said once it has stopped being normal it's information.
- It's compiled into the app, not fetched. A launcher that exists to cover a slow network can't be served over one.
- Hiding a window means owing it a guaranteed way back, so there are two: the app says when it has painted, and a 15-second timeout reveals it regardless. A launcher you can only escape via Task Manager is a worse bug than the flash it replaced.
-
You can get the desktop app from the web app, and from the website. A "Get the desktop app" row in the space menu, a
/downloadpage with the build for your platform first, and links from the docs and the landing page. The page ships before the installers do and knows the difference: with no published version it says so and points at the releases page, rather than offering a button that 404s. The platform guess only decides what is shown first — every build stays one click away, because the times the guess is wrong are exactly the times hiding the others would be infuriating.- The landing page no longer claims "nothing to install". That stopped being true, and the claim worth keeping is that nothing is required — the browser is still the whole product.
-
The desktop app hands everything that isn't the app to your own browser. The shell has no address bar and no back button, so any click that navigated it away from the canvas was a one-way trip — docs, pricing, terms, a link on a card, a link inside an AI reply, the checkout. All of it opens in a real browser now, and the window stays where it was.
- The rule is an allow-list of app paths (
/,/auth/*,/mcp/*), not a block-list of website ones: a docs page added next month is external without anyone remembering, and an app surface added next month fails loudly on the first click rather than quietly. - Enforced by one capture-phase click listener rather than a component used at each call site. The links that matter are the ones nobody can annotate — markdown in an AI reply, an unfurled card, a cross-reference written months ago.
- A fresh install no longer opens on the landing page. By every cookie-shaped measure it is a first-time visitor, but it is someone who has already read that page — it's where they got the download — and in a frameless window it would have been marketing with no way out.
- The rule is an allow-list of app paths (
-
Signing in with Google happens in your browser, then returns to the app. Not a preference: Google answers
disallowed_useragentto an embedded webview, so the old full-page redirect replaced the app with an error page and no way back. The app starts the flow and keeps the PKCE verifier; the browser gets the consent screen and comes away with a code it can't redeem;clipspaces://authhands the code back; the app exchanges it. The code crossing a URL the operating system can see is worth nothing on its own, and is single-use besides.- Email codes deliberately stay in the app. A 6-digit code gives a browser nothing to do, and the round trip would be strictly worse. It's also the way in on a machine with no browser to hand off to, so it stays available while the Google button is waiting.
- Needs
<origin>/auth/desktopin Supabase → Auth → URL Configuration, for every origin the shell can load.
-
Payments open in your browser too. A card form inside a frameless window offers no padlock to check and no domain to read, 3-D Secure steps out to the bank's own page, and a processor redirecting somewhere unexpected would have stranded the app. The plans tab re-reads your subscription when you come back, and says so while it waits — buttons that just go quiet read as a click that failed, and the second click is the one that charges twice.
-
The top bar is translucent, over a blur of whatever is behind it. The bar was fully transparent, which is why its right-hand controls carried their own bordered pill — something had to stay legible when a canvas card scrolled underneath. A blurred surface does that for the whole bar at once, so the pill is gone and the controls sit directly on it. Not an opaque band: you should be able to see that a card has passed under the bar rather than been cut off by it, which is the difference between a surface floating over the board and a strip the board ends at.
saturate(150%)keeps it from going grey — blurring alone washes colour out, and a bar over a colourful board should pick that colour up. Falls back to opaque wherebackdrop-filteris missing, since raw canvas showing through is unreadable rather than merely plainer. -
The desktop window has no title bar any more — the app's top bar is the title bar. A grey system caption in colours we don't control was sitting on top of a carefully themed app, and no amount of tinting makes two strips read as one. The window is frameless now: the existing 48px top bar carries the drag region, and minimise / maximise / close are drawn at its right end in the app's own tokens, at the app's own size. Double-click to maximise still works, edges still resize.
- Only two things are borrowed from the platform, because they're muscle memory rather than decoration: the left-to-right order, and close going red on hover.
- Dragging the bar moves the window — which took a second pass to actually be true. Tauri
starts a drag only when the mousedown target carries
data-tauri-drag-region, and marking the<header>alone left it hittable on just its 12px padding and the 8px gap between clusters. Every other pixel of empty bar belongs to the left cluster, which isflex: 1 1 0and grows to fill it — so the one region a person naturally reaches for was the one region that did nothing. The clusters carry the attribute too now; their children are their own targets, so every control stays clickable without opting out one by one. - Windows 11 Snap Layouts stops working — that flyout comes from the system hit-testing its
own maximise button, and there isn't one. Restoring it means answering
WM_NCHITTESTwithHTMAXBUTTON; noted indocs/DISTRIBUTION.md§1.6b. - macOS keeps its native caption on purpose. Frameless there would delete the traffic lights
and put Windows-shaped controls on the wrong side of the window.
WindowControlsasks the window whether it is decorated rather than sniffing the platform, so both behave correctly without a second code path. - Every control is an ACL-gated core command and the window loads a remote origin, so each one
needed a
core:window:allow-*entry in both the static capability and the runtime grant. A missing entry there is a silent denial — a button that simply does nothing — which is the same failure shape that once cost a day oncore:event:listen.tests/desktop-capabilities.test.tsnow parses both files and fails if they drift.
-
The model chip shows Claude's mark when Claude is answering. It always showed the ClipSpaces badge, including on the desktop harness where the answer comes from Claude. Matched on the model id rather than the configured engine — the harness is always Claude, but the cloud path can be too, and what the badge names is the model that ran. Anything else keeps the ClipSpaces badge rather than borrowing a logo that would be a lie.
-
ClipSpaces asks before it connects to your Claude Code install. The desktop shell can answer turns with the
claudebinary already on the machine, and until now the only way to discover that was to go looking in Settings. It now offers — once, on launch, and only when a probe finds a CLI that is both installed and signed in.- A stepped wizard, not a toggle, reusing
SoulWizard's exact chrome so the app has one idiom for "a first-run flow with a decision at the end" rather than two that look different. Three steps — what it is · what it can reach · what it costs — and the Connect button is on the last one, so nobody agrees before the cost has been on screen. - It states the sandbox honestly, because the sandbox is the reason this is safe to say yes to:
the board is the agent's entire capability surface (every built-in tool is removed — no
Bash, no Read, no Edit), and the user's own MCP servers, settings, hooks, plugins, skills and
CLAUDE.mdare all skipped. Step one shows the resolved binary path and version, so "we found it" is evidence rather than a claim. - The asking is recorded separately from the answer (
cs-ai-engine-asked). The engine pref could not carry it:cloudis the value both for somebody who has never seen the offer and for somebody who looked and said no, and re-asking the second person on every launch is how a permission prompt becomes nagware. Declining counts as firmly as accepting; Escape and the backdrop decline rather than merely dismissing, since leaving a permission question unanswered would bring it back tomorrow. - Where storage is unavailable (a private window, blocked site data) the question is treated as already asked — the answer could not be remembered, and the alternative is prompting forever.
- Nothing changes for the browser, or for a desktop app with no CLI installed: there is no teaser for software you do not have.
- A stepped wizard, not a toggle, reusing
-
The Claude mark, vendored from Iconify's
logos:claude-icon(components/icons/ClaudeLogo.tsx), used in the consent wizard and in the Settings → AI engine row, which previously showed a generic terminal glyph. It defaults to Claude's own terracotta rather than--accenton purpose: the mark's job is to say this is another company's product, running on your machine, billed to your account, and painting it in our accent would make it look like a ClipSpaces feature — the one thing a consent screen must not do. Amonoprop exists for rows where it sits among UI icons. -
Play a flow, and watch the data move through it. Right-click any card in a diagram → Play flow from here. A token walks the graph hop by hop, lighting each card as it arrives.
- Transient by design. Playback changes nothing: no card moves, no connector is edited, nothing reaches localStorage or Supabase, and nobody else's tab sees it. It lives in component state and its own React context, so there is no undo entry to make and nothing for the echo-suppressed sync plumbing to fight. It is an explanation of a diagram, not an edit of one.
- Correctly ordered, not just breadth-first. A join waits until every arrow into it has been crossed, so a fork's two branches run on the same step and the node they rejoin at lights after both. A cycle — a retry loop is a perfectly good data flow — plays once in a deterministic order rather than hanging or being refused.
prefers-reduced-motiondrops the travelling token; the lit cards and brightened connector carry the sequence on their own. The sequence is the information, the glide is the pleasure.{"type":"play_flow","from":id}is filed withfocus, not with the editing verbs, because it changes nothing — a caller with no viewport (a server, an MCP client) ignores it. So "walk me through this pipeline" now actually walks through it.
-
Animate a whole flow at once, without selecting anything. Right-click a card → Animate flow sets every connector in that diagram marching. A diagram is a connected component, so naming one box in it names all of it — the per-edge toggle in the edge toolbar stays for the finer intent.
{"type":"animate_flow","from":id,"on"?:bool}gives AI and MCP the same reach in one operation; the alternative was oneupdate_connectionper edge, and a twenty-edge diagram would not have fitted insideMAX_BOARD_OPS. -
Marquee selection, and a toolbar for what you selected. Shift+drag lassoes cards (Ctrl/⌘+ click already worked and still does); left-drag still pans, so no existing gesture changed. With two or more selected: Play · Animate · Tidy · Frame · Delete, positioned by React Flow's own
NodeToolbarso it tracks pan and zoom without any coordinate maths. Frame sizes the new frame withframeBoxFor— the same function that fits an AI-created frame to its contents, so a frame you draw by hand and one the assistant makes are the same object. -
lib/board/graph.ts— connected components and run order, extracted because three unrelated features turned out to be asking the same question.flowGroups(which diagram is this card in?) backs tidy,flowFrom(every connector in it) backs animate,flowOrder(what happens after what) backs playback. -
tidy— straighten a board that already exists, without rebuilding it. Everything else here straightens a batch as it arrives; this is the retro-apply, and it needed to be a second verb rather than a change toauto_layout. Auto-arrange throws away every positional decision on the board and re-derives them from the graph — right when a board is genuinely a mess, wrong when somebody laid it out by hand and only wants the wobble gone. Tidy keeps the arrangement and fixes only what nobody chose: rows level to within a few units, a run of steps at 350/362/355, two cards resting on each other. Nothing moves further than half the alignment tolerance.- Flow by flow, because that is what a person means by "this diagram". Cards are partitioned into connected groups and straightened inside each one, so a step gap is evened against the other steps in its own flow and never against an unrelated diagram forty units away. Loose cards are straightened together as their own layer, which is what tidies a wall of stickies.
{"type":"tidy","ids"?:[…]}scopes it to the flows those cards belong to — "tidy the login flow" leaves the rest of the space alone. Reachable from the in-app assistant, over MCP, from/tidyin the composer, and from Tidy alignment in the canvas right-click menu.- It deliberately does not push existing groups out to
GAP.groupthe way an arriving batch is pushed. A batch has no arrangement to respect yet; a board someone built has nothing but. Overlaps are separated atGAP.tightand everything else stays where it is. - Stable by construction: blocks settle top-down and only ever move down, so running tidy twice changes nothing the second time.
/tidy,/alignand/straightennow reach the gentle command. They used to be keywords of Auto-layout, which quietly reflowed hand-made boards; a test pins the routing shut.
-
A desktop app, and it can run the assistant on your own machine.
src-tauri/is a Tauri v2 shell that loads the same Next.js app — a shell, not a fork: every native capability arrives throughlib/desktop.ts, which is a no-op in the browser. First run asks where to point it (ClipSpaces Cloud, or a server you run yourself). The mode described indocs/DISTRIBUTION.mdas "Local — no server at all" is deferred, and the doc now says why: that plan assumed the app could be served statically, and an App Router app with server components cannot be. It needs a bundled Node runtime, which is ~50 MB and undoes the size argument that picked Tauri over Electron in the first place. -
Settings → AI → Engine (desktop only): answer turns with your own
claudeCLI. The existing path is single-shot — the model writes prose plus a fencedactionsblock, and that is the entire interaction. Claude Code is an agent loop: it streams, calls real tools, reads the result and corrects itself. The panel's Agent mode has promised exactly that since it shipped; this is what makes the promise true. It bills your own Claude account, so nothing on this path is metered by us — and it needs no ClipSpaces server at all, because the whole prompt is assembled in the browser. The row states both failure modes separately (not installed / installed but signed out), because they need different fixes. The composer's model switcher follows the engine. It reads/api/ai/credentials— the server's effective model — which is the wrong question entirely once a CLI on your own machine is answering: it labelled turns "Nemotron" that Claude Sonnet had actually handled, and on a desktop pointed at no ClipSpaces server it's a pointless request besides. On the harness it now offers Claude models, defaulting to "Claude Code default" — no--modelflag at all, leaving whatever you configuredclaudewith, rather than overriding a choice you already made. The CLI names its model on the session's init line, so once a turn has run that entry says which model it actually resolved to. -
lib/ai/transport.ts— one seam, two engines. Both resolve to the sameTurnResult, so bubbles, persistence and the room echo are untouched and the panel never learns which answered. The harness fills the bubble in as text arrives and names what the agent is doing to the board ("Editing the board…") instead of showing a pulsing dot. The cloud transport is byte-for-byte the old behaviour, andbuildActionsSpecstill emits the identical fenced-block prompt for it. -
app/api/ai/turns— persistence with the inference removed. The harness never reaches/api/ai, which is also where conversations get written, so without this a desktop answer would be invisible on your other device and missing from a shared room. Authorship is stamped from the session exactly as/api/aidoes it; membership stays enforced by RLS.
Changed
- Agent mode drops its ✕ on the desktop. The window's own close button now sits immediately beside it, and two adjacent ✕ glyphs where the left closes a panel and the right quits the app is a trap — the cost of guessing wrong isn't symmetric. "Compact chat" is already the way out, and Escape still closes the panel. In a browser there's no window control to confuse it with, so it stays.
- One spacing rule for the whole board, in
lib/board/spacing.ts. The distances that decide where a generated card lands were eight literals in five files and no two agreed: the packer gutter was 40, the declutter margin 24 and its step 56, dagre used 56/120, the loose grid 340×230, both fallback placers 340×220, and the prompts said "~290 apart" three lines above "~360 per step". Nothing was wrong individually; a batch laid out by one of them and then nudged by another simply looked like it was assembled by two people who never spoke.- There are now only two distances that matter, and the gap between them is what a person
reads:
GAP.card(40) means one group,GAP.group(120) means separate groups. Three times, not slightly more — 40/60 reads as a sloppy grid, 40/120 reads as two things. - The rule applies to anything the AI creates, in the app or over MCP: cards inside a batch sit
GAP.cardapart, and the batch as a whole keeps a fullGAP.groupfrom content that was already on the board. It moves as a block, never one card out of line. - Both prompts interpolate the real constants instead of quoting prose numbers, so they can no longer drift from what the reducer does. A test fails if a literal is written back in.
- There are now only two distances that matter, and the gap between them is what a person
reads:
Security
- The harness sandbox is four flags, and they are one unit.
--tools ""(no Bash, no Read, no Edit),--strict-mcp-config(your own MCP servers are invisible to it),--setting-sources ""(so are your hooks, plugins, skills andCLAUDE.md), and--permission-mode bypassPermissions— which is only defensible because of the other three: there is nothing left to prompt about, and a prompt in a headless child would hang the turn forever. The CLI also runs in an empty scratch directory we own, so it cannot walk up into a real repo and inherit its instructions. Enforced twice, deliberately:tests/harness-argv.test.tsasserts the web layer emits them, andsrc-tauri/src/harness.rsre-checks against its own allowlist and refuses to spawn otherwise — so a compromised webview still cannot turn this into arbitrary command execution. - The board MCP bridge is a transport, not a trust boundary. It listens on 127.0.0.1 only,
behind a bearer nonce minted per launch, and every action the agent proposes is re-validated
with the same
sanitizeAIActionsthe cloud path uses before it reacheslib/board/ops.ts. It adds a third caller of the reducer, never a second vocabulary.
Fixed
- An installed desktop app had no title bar at all. The window was frameless on the promise
that the web app draws its own — but the shell is compiled from this repo and the page is
fetched from a server, so the two ship separately and nothing makes them agree.
components/desktop/is not onmaster, so a cloud-mode install deleted the system caption and then loaded a page that had never heard ofWindowControls: no title bar, no minimise or close, and no way to move the window.decorations(false)is commented out until the desktop code is live on the deployment the shell loads; the window keeps its native caption meanwhile.- Nothing else had to change.
WindowControlsalready asksisWindowDecorated()before rendering, which is how macOS has always kept its traffic lights, so it draws nothing here of its own accord. The drag regions on the top bar are harmless on a decorated window. - The same gap explains the rest of the install: a ~15s launcher (
DesktopReadynever mounts, soapp_readynever fires and only the timeout clears it), links that navigate the window one-way (noExternalLinks), and yesterday's?app=desktopfix having no visible effect —master'sAPP_INTENT_PARAMShas noappentry, so production cannot observe the marker.
- Nothing else had to change.
- A new install opened on the marketing page instead of the app. The desktop window is
pointed at its backend twice: once by
run()when a backend is already configured, and once byset_shell_modewhen the first-run picker is answered. Only the first appended?app=desktop, so the very first launch after installing landed on a bare/— which, in a webview profile that has never seen ClipSpaces, is exactly the fingerprint of a first-time visitor, so middleware served/welcomeinside a frameless window with no address bar and no back button. Every launch afterwards went throughrun()and was correct, which is why nobody running the app saw it and every new user got it once.- The same call was also skipping
grant_remote. Only cloud escaped that, and only by luck:capabilities/default.jsonnameshttps://clipspaces.appstatically. A self-host origin is only known once it is typed, so a self-hoster's first launch loaded an origin with no capability at all — and an ACL denial on a remote origin is silent, so the title-bar controls would simply have been dead and the harness events dropped until the next restart. tests/desktop-links.test.tsnow reads the Rust source and fails on anynavigate()without anapp_intent()above it, plus three unit tests inlib.rspinning what the marker does to a bare URL, to a self-host URL that carries query of its own, and to one that already has the marker. The invariant is not testable any other way — it needs a running window — and this is the third bug of the shape "two callers do the same setup and one of them forgot a step".
- The same call was also skipping
pnpm desktop:buildfailed before it bundled anything. The build stopped with "A public key has been found, but no private key" —tauri.conf.jsonpairscreateUpdaterArtifacts: truewith a placeholder updaterpubkey, so the bundler tried to sign the update artifacts with aTAURI_SIGNING_PRIVATE_KEYthat has never been generated. Not a regression: it is the one step of §1.4 nobody had taken, and the first build to reach the bundling stage was always going to hit it.pnpm desktop:build:unsignedbuilds installers locally by mergingsrc-tauri/tauri.unsigned.conf.json, which setscreateUpdaterArtifacts: falseand nothing else. The installers are real; they just cannot be served as an update to an existing install, so they are not for shipping.- The placeholder is deliberately still there. Generating the real pair is a release task,
not a build fix: the private half has to go into CI secrets and be kept for the life of the
app — the public half is compiled into every installer in the wild, and an update signed with
a different key is refused, so losing it freezes every existing install permanently.
docs/DISTRIBUTION.md§1.6 now carries the commands and that warning instead of a one-line TODO.
- Agent mode had no title bar, so the window couldn't be moved or closed while it was open. The agent view is a full-screen takeover above the top bar, which meant it covered the only caption the app has. It draws its own now: drag regions on both header strips — including the flexible spacer that actually owns the empty middle — and the window controls at its right end.
- A reply re-played its word-by-word reveal when you scrolled the thread. The reveal is a CSS
animation — one span per word with a staggered
animation-delay— and a CSS animation restarts from the top whenever its element is recreated. The row stayed in "revealing" mode for its whole life, so long after the animation had finished it was still emitting.cs-stream-wordspans on every render. Scrolling past the at-bottom threshold re-renders the panel, react-markdown re-parses into a fresh element tree, and any node React chose not to reuse came back animating. The symptom looked random because it depended on which nodes happened to be reused.- The reveal now ends: once it has actually finished (
revealDurationMs, which tracks the last word's delay plus its fade) the row goes back to rendering plain text, and there is no animation left for anything to restart. Invisible when it happens — the spans aredisplay: inline, so removing them changes no layout. - Rows are keyed by message identity, not array index.
upsertMessagewalks backwards to keep a thread in timestamp order, so an out-of-order arrival inserts anywhere but the tail and shifted every index after it — handing one row's component instance, and the reveal state pinned to it, to a different message. That is the other way a settled reply could start animating on its own.
- The reveal now ends: once it has actually finished (
- A generated diagram arrived as a grid with arrows threaded through it.
applyOpsarranges a new batch in pass 2 but does not draw its connections until pass 3, so the packer ran blind — it knew six cards had arrived together, not that they were a pipeline, and tiled them into a 3×2 block that pass 3 then wired back and forth across. Every card right, every connection right, and nothing readable as a flow. The reducer now resolves the batch's own edges up front: a connected batch is laid out along its arrows (left→right, one rank per step, branches on their own rows), and only a genuinely loose batch is gridded. - Coordinates an external client supplied were honoured to the pixel, wobble included. Over
MCP the model always supplies x/y, and what it supplies is nearly right — steps at 0, 355,
720 that were meant to be evenly spaced, and a "row" at one y whose shapes are five different
heights, so nothing in it is actually level.
symmetrizenow straightens the arrangement instead of reproducing it: centres within a tolerance are collapsed onto one row (or column), a run of gaps that was already nearly even is made even, and a deliberate break is left alone.- Centres, not edges. Shape variants are different sizes (
processis 170×72,decision132×100), so a row sharing ayis level along its tops and nowhere else — and a connector leaves a card from the middle of its edge, not the top. - Existing cards anchor, and never move. A card added to a diagram lands on the row it belongs to rather than near it.
- Grid snapping was tried in three places and kept only where it helps. Rounding a rank centre turns dagre's constant 350/350/350 step into 360/340/360, and rounding a top-left un-levels any two cards whose heights differ by an odd number. Only distances are snapped now.
- Centres, not edges. Shape variants are different sizes (
- A frame the AI created never contained anything.
create_framedropped a fixed 320×220 box wherever the placer happened to put it, with no way to say what it was grouping — so "put these three steps in a frame" produced a small empty rectangle standing next to the three steps. It now takescontains(the refs or card ids it holds) and is sized and positioned around them once the batch has been arranged, with padding. Re-fitted after a move or delete in the same batch, since either changes what it is holding.- The MCP tool description never listed
x/y/w/honcreate_frameeither, so an external client could not place or size one by hand as a fallback. Both are documented now.
- The MCP tool description never listed
- Frame membership went by the card's top-left corner. A card poking almost entirely out of a
frame's right edge was still dragged along with it (its corner was inside), while a card sitting
squarely in the frame but starting a few units above the top edge was left behind. Membership is
decided by the card's centre now, so it agrees with what the arrangement looks like — which
is the only thing anyone can check it against. Affects frame dragging, the AI's board context,
and
auto_layoutpinning alike. - A connected app could be locked out of write access by its own OAuth request. ChatGPT's
grants came out read-only while Claude's didn't, and the difference was not a setting anyone
chose.
parseScopesfalls back toDEFAULT_SCOPES(read-only) when a client sends noscopeparameter — correct — but the consent screen then rendered checkboxes only for scopes the client had requested. A client that asks for nothing therefore had no write box on the page, so the user could not have granted write however much they wanted to. Every opt-in scope is now offered regardless of what was asked, and the ones the client didn't request are marked "not requested" so ticking one is a visibly different decision from agreeing to what it wanted. Granting past the request is explicitly provided for by RFC 6749 §3.3, which asks only that the token response state what was actually issued —tokenResponsealready does. The tick remains the only gate, still off by default; wideningDEFAULT_SCOPESinstead would have granted write to every future connection that asked for nothing, and a test now pins that shut. tools/listadvertised tools the connection could never call. Every client was handed the full catalogue, so a read-only grant was offeredcreate_spaceandedit_boardand could only learn otherwise by calling one and being refused. A client that discovers four tools and gets rejected on two can reasonably present the whole connector as broken. The list is now cut to the token's scopes.- Filtered on scopes, never on live per-space state. Scopes are fixed for a token's
lifetime, so a list built from them stays true;
writableSpaceIdschanges whenever a role does, and clients cachetools/list— gating on it would strand a connection without a tool it had since gained the right to use. Where a tool may act is still decided per call bycanRead/canWrite. - The catalogue moved to
lib/mcp/tools.ts: a Next route module may only export its handlers and segment config, so nothing defined inside one can be reached by a test, and this is the security-relevant half oftools/list.
- Filtered on scopes, never on live per-space state. Scopes are fixed for a token's
lifetime, so a list built from them stays true;
- Four bugs stood between the harness and a working turn, and every one of them looked
identical from the outside: a spinner that never resolved. Recorded because the shape of
this failure is the lesson — each fault was independently fatal, so fixing three of them
changed nothing observable.
- The Tauri capability didn't cover remote origins. Capabilities apply to local app URLs
unless they name remote ones, and this window always loads one (
localhost:3000,clipspaces.app). Socore:event:listenwas denied — while the app's own commands kept working, because commands fromgenerate_handler!bypass the ACL entirely. That asymmetry is what made it so confusing: the Rust trace showed every step succeeding. - Board tool calls carried an empty session id while the webview filtered on it, so every edit was dropped and the agent sat out a 30s timeout and retried. The session id now rides in the MCP URL path, so the server can tell concurrent sessions apart — deleting the filter would have "worked" while letting two open chats both apply the same edit.
- Listeners raced the process start.
listen()is async; firing it and spawning the CLI in the same tick could losesystem/initand everything after it. All listeners are now awaited before the process starts. - The stream parser was fed lines it could never parse. Rust reads stdout with
BufReader::lines(), which strips the newline, and emits one event per line — but those were fed to apush()that buffers until it sees a\n. Every event was appended to a buffer and not one line was ever parsed. There is now an explicitpushLine()for the one-line-per-event contract, andpush()documents that it is for raw pipes only.
- The Tauri capability didn't cover remote origins. Capabilities apply to local app URLs
unless they name remote ones, and this window always loads one (
Notes
- The stream tests passed throughout all of that, and were worthless. They fed the fixture as raw NDJSON with newlines — the pipe contract, which nothing in production uses. They validated an interface that existed only in the tests. There are now three that feed events the way Rust actually sends them, including one asserting the old path produces nothing.
edit_boardreports what was handed to the canvas, not what verifiably landed.CanvasHandle.applyAIActionsreturnsvoid, so the count in the tool reply is optimistic: if the canvas ref were ever null the agent would still be told the edit succeeded. Threading a real count back through the handle is the honest fix and hasn't been done.- It runs on Windows; two thirds of the webview risk is still open.
cargo checkandcargo clippyare clean, the sandbox-allowlist tests pass, the window opens in WebView2 and serves the real app (canvas route, docs, pricing and three API routes all 200), and the board MCP server binds127.0.0.1only and 401s a wrong token. But WebView2 is the Chromium-ish engine and was never the worry — macOS (WKWebView) and Linux (WebKitGTK) are untested, and the canvas rendered empty, so@xyflow/reactat scale,perfect-freehandand MapLibre WebGL have still not been exercised anywhere.docs/DISTRIBUTION.md§1.6 tracks exactly what is and isn't proven. If MapLibre fails on WebKitGTK the answer is Electron, and everything above survives that swap unchanged. - The app icon is a stopgap. Generated from
app/icon.png, the repo's only brand mark, which is 117×133 — padded square and upscaled to 1024, so it is soft at large sizes. It needs a real 1024×1024 master before any installer ships.