You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Every project has a changelog Weft keeps, so cards stop colliding on CHANGELOG.md. Threads record what they ship with mcp__board__changelog_add (_list, _edit) into ~/.weft/changelog.jsonl, mirrored to Supabase changelog_entries while you're signed in. Each entry links to the card that wrote it, and the card's own commit stamps it with the sha (draft → committed). Right after that commit Weft rewrites CHANGELOG.md and commits it alone as docs(changelog): <card>, keeping lines typed by hand; a commit that bumps package.json's version gets that version's section, so nobody runs a release step (pnpm changelog:release <version> stays for a bump Weft didn't see). More → Changelog of or ⌘K opens a read-only list, Unreleased then each version. A Markdown-only commit now skips the pre-commit typecheck. (lib/changelog*.ts, components/ChangelogDialog.tsx, scripts/check-staged.sh, docs/architecture/changelog.md)
A shared mini app's link preview refreshes itself, and the card is drawn once. The preview used to be made once, when the page changed, and then it sat there: a data app's numbers moved and its picture didn't, and a capture that failed never came back. Each preview now carries when it was made (at), and isStale (lib/app-preview-capture.ts) says when it needs doing again — the page changed, it's older than PREVIEW_MAX_AGE_MS (6 h), or it should have a picture and hasn't got one and PREVIEW_RETRY_MS (30 min) has passed. A timer walks the shared apps and refreshes the stale ones, one at a time (lib/app-preview-refresh.ts), so the hidden capture window and Haiku never pile up; it starts and stops with cloud sync. A refresh of an unchanged page reuses what the last one had: Haiku's sentence stays, and a capture that fails keeps the last picture but leaves shotFailedAt, so the retry is never lost. The Share dialog's card is cached beside the capture (~/.weft/app-previews/<app id>.card-<hash>.png), so opening it again doesn't redraw it, and a save that only changes who may open the link no longer re-uploads the page's bytes (pageForWrite in lib/app-share-row.ts). A tick that changes nothing reads nothing: each file's sha is cached by its path, size and mtime (lib/app-file-sha.ts). Files: lib/app-preview.ts, lib/app-preview-capture.ts, lib/app-preview-refresh.ts, lib/app-share-preview-actions.ts, lib/app-share-row.ts, lib/app-file-sha.ts, lib/cloud-sync-start.ts, docs/architecture/mini-apps.md.
A new thread waits for the limit instead of dying on its first turn. A thread created on a model that has hit its limit used to fail immediately and sit dead in Needs you. Weft now knows before the turn starts: the New thread dialog's Start button reads Start at 2:30am, says which limit is used up, and the card waits for the window before its first message goes out — the scheduler sends it the moment the window opens, and a card that never ran is fine to schedule. One answer covers every limit a thread can meet — the model's own cap (Opus can run out before the CLI's window does), the CLI's weekly window and its 5-hour window — in one pure module read as data, so the server (lib/start-gate.ts, lib/new-card.ts) and the browser (/api/limits/plans, lib/use-start-gate.ts) always agree; the client only labels the button, the server decides. The reply box reads the same gate, so a model's own cap and the weekly limit now reach it too, not just the 5-hour window. Files: lib/limit-reset.ts, lib/start-gate.ts, lib/use-start-gate.ts, lib/new-card.ts, lib/reply-mode.ts, app/api/cards/route.ts, app/api/limits/plans/route.ts, components/NewCardDialog.tsx, components/NewCardDialogFooter.tsx, components/ReplyBox.tsx, components/ThreadFooter.tsx, components/ModelSelect.tsx.
Deleting a card asks in Weft's own dialog, with a "Don't ask again" tick. The trash button in an open card's header and the right-click menu's Delete used to open a native window.confirm, which is a macOS sheet with only Cancel and OK — no room for a checkbox. Both now open components/DeleteCardDialog.tsx, Weft's own modal: it names the card, says its CLI transcript stays on disk, says the delete stops a running turn, and carries a Don't ask again tick. Ticking it writes the flag in lib/delete-confirm.ts (localStorage, like the Switch all dialog's) and later deletes go straight through; Settings → Security's Ask before deleting a card row is the way back. The dialog's state and the board's delete live in lib/use-delete-dialog.ts. Files: components/DeleteCardDialog.tsx, components/DeleteConfirmSwitch.tsx, lib/delete-confirm.ts, lib/use-delete-dialog.ts, components/CardActions.tsx, components/use-board-menus.ts, components/SettingsSecurityPage.tsx, components/BoardDialogs.tsx, components/Board.tsx.
Open-source maintainers get Weft's Open Source plan free for a year, checked on GitHub when they sign in. Two $0 plans join the catalog, Open Source (oss) and Education (edu). Both unlock teams and BlastCP Creator and stay on the free tier's Puku cap (FREE_TIER, TEAM_PLANS, GRANT_PLANS). A GitHub sign-in on weft.zip checks in the background, after the redirect: if you maintain a public, non-fork, non-archived repo with an OSI licence, pushed in the last 90 days, with 25 stars or 20 commits in those days, the workspace moves onto Open Source for 12 months. A sign-in in its last month renews it, and the billing page has Check my GitHub now (lib/billing/oss.ts, lib/billing/oss-grant.ts, lib/billing/grants.ts, app/auth/callback/route.ts). The grant's end is kept in the subscription's trial_end, because the billing package rolls a $0 row's period forward on every read. A lapsed grant reads as Free in the team gate. Regional pricing is built on Weft's side: World Bank low-income and lower-middle-income countries, by IP or billing address, show Pro at $5 or $10 a month on the billing page and pass a price variant to Checkout (lib/billing/regional.ts, lib/billing/regional-pick.ts). Selling it still needs @aimhuge/billing price variants. Education's plan and grant are built, and its redemption waits on the GitHub Student Developer Pack. Deleting a workspace on a granted plan is no longer blocked as "still billing" (lib/team-delete.ts).
Level-ups open like an episode, and you can replay the last one. A level-up popup now opens on a picture of your rank: six BlastCP renders of our hovercar, every one wearing the Weft emblem, from a dusty garage at Thread through the canyon run (Yarn), the neon city (Bolt) and the snowy-pass formation (Loom) to a cliff over the woven city (Tapestry) (public/brand/levels/). Over the picture rise the level as an "Episode" number in roman numerals ("Episode XXIV") and a line of story, tilted like an opening crawl; each rank has a few lines, and each level takes the next one (lib/level-lore.ts, components/AchievementPopup.tsx, .level-hero-art / .level-crawl in app/globals.css, both still with Reduce motion on). Replay last achievement, in the avatar's menu and the command palette, opens the last popup again, or a level-up to your current level if none has opened yet (lib/achievement-replay.ts, components/GameBadge.tsx). The avatar menu moved into its own components/AccountMenu.tsx.
The card that crashed or restarted Weft is marked on the board. When a thread stops Weft's server or leaves a file that doesn't build, its card gets a red ring and a 💥 Broke Weft chip (×N if it does it again). Hover the chip to see what it did; × dismisses it. The thread is told why ahead of its next message, once, so it doesn't do it again. Electron notes a server exit Weft didn't ask for (electron/server-exit.cjs), the board reports build errors and full reloads (lib/use-break-report.ts → POST /api/broke), and lib/crash-blame.ts blames the card whose recent tool call explains it: a command that stops Weft, or the last edit to the file that doesn't build. It never blames an edit just for being near a reload. When nothing on the board explains it, no card is marked. The usual cause is now refused outright: mcp__devservers__start pnpm dev in Weft's own folder (whose dev script frees the board's port) restarted Weft seven times in half an hour on 2026-10-23, and startDev now won't start it (lib/devserver.tsstartsWeft). The Bash restart guard also catches pnpm dev without run (lib/restart-guard.ts). Drawn by components/CardMarks.tsx and components/CardView.tsx; docs/architecture/error-boundaries.md.
Puku's gateway is one click away in Weft's Goose settings. The Anthropic provider takes a base URL and a typed model id now, so a gateway that speaks the Anthropic Messages API is reachable from Settings → Models → Bring your own key: the base URL rides into Goose's env as ANTHROPIC_HOST (the host, with no trailing /v1 — Goose appends v1/messages itself), and the model field is a text box with the known ids as suggestions, because a gateway behind Anthropic's API serves model ids of its own. The Puku button beside the Provider picker sets all three at once — the Anthropic provider, https://api-cli.puku.sh and puku-ai-2.8 — in a single save, so all that is left is your Puku key. The model and the endpoint the Settings pane shows now actually reach the turn: the model becomes --model, and the endpoint you picked is the one the key's grant hands to Goose. Files: lib/goose-provider.ts (PUKU_GOOSE, the catalog's extraEnv and freeModel), electron/goose-provider.cjs, electron/goose-ipc.cjs, lib/goose-launch.ts, lib/runner-state.ts, lib/use-goose-selection.ts (setAll), components/GooseProviderKey.tsx, components/GooseKeyRow.tsx, docs/architecture/goose.md.
Goose is a real backend now, and its harness can improve itself. A Goose turn leaves its recipe on disk at ~/.weft/goose/<card id>/recipes/turn.yaml — written from the same plan and prompt the argv is built from, atomically, and never failing a turn — so "run this turn again by hand" is a file you can point goose run --recipe at, not a prompt you can print (lib/goose-recipe-store.ts, lib/goose-state.go). A Goose card also had a blind overlap warning: Goose sends its file changes as human-readable progress notes, so card.touchedFiles was never filled. The paths are now read out of that text (lib/goose-progress-paths.ts, a pure function), keeping only the ones that resolve inside the card's own folder — a path that climbs out with .. is dropped, because that boundary is what the overlap warning reads. Beside Goose sits a self-improving harness, in the spirit of Google's Harness² but built as a sidecar rather than a fork. A harness is five editable surfaces — prompt, instructions, model, maxTurns, skills — and the policy that guards the run (provider, extensions, tools, cwd) stays outside them, in Goose's own extension allowlist (lib/harness-policy.ts, lib/harness-recipe.ts). The optimizer runs rounds: propose candidates, probe them, and compose the winner from what actually happened, scored on behavioural diffs rather than a grader (lib/harness-contrast.ts); lessons carry across tasks (lib/harness-lessons.ts, lib/harness-lessons-store.ts), and adopting a new harness is rollback-safe (lib/harness-compose.ts, lib/harness-optimizer.ts). And you can bring your own key. Settings → Models → Bring your own key offers Goose's providers (Anthropic, OpenAI, Google, OpenRouter, Ollama and more) with a key that is sealed in the macOS Keychain by Electron main and handed to Goose as an env grant — the plaintext never crosses the control server and the board never sees it (electron/goose-token.cjs, electron/goose-env.cjs, lib/goose-launch.ts). A provider's base URL and model id ride along with it, so a gateway behind Anthropic's API is one key away (lib/goose-provider.ts, components/GooseProviderKey.tsx, components/GooseKeyRow.tsx, components/GooseModelField.tsx). One bug went with it: a Goose card's typed model id was silently dropped at runtime, because an id no shared list named was assumed to be Claude's. A Goose card now keeps any model id its provider serves — a local Ollama model, a private endpoint's — so the card runs what it asked for (lib/new-thread.tsmodelOn). Files: lib/goose-runner.ts, lib/goose-recipe-store.ts, lib/goose-progress-paths.ts, lib/goose-state.ts, lib/harness-policy.ts, lib/harness-recipe.ts, lib/harness-contrast.ts, lib/harness-lessons.ts, lib/harness-lessons-store.ts, lib/harness-compose.ts, lib/harness-optimizer.ts, lib/goose-provider.ts, lib/goose-launch.ts, lib/new-thread.ts, components/GooseProviderKey.tsx, components/GooseKeyRow.tsx, components/GooseModelField.tsx, electron/goose-token.cjs, electron/goose-env.cjs, docs/architecture/goose.md.
The Growth app now shows what people do inside Weft, not just that they signed in. The old weekly retention counted a sign-in as activity, which flatters it — opening the app is not the same as pushing work through it — so a second table, in-app retention, sits beside the first: the same weekly signup cohorts, but an account counts as retained only if it pushed a card, moved one, or ran a turn that week. Two more panels follow: cards pushed per day stacks the cards created against the cards moved, and in-app engagement per day stacks the cards people touched against the cards they started, with the accounts working and the turns they ran in the hover. Two tiles join the top row: cards pushed in the last 30 days, and the accounts doing real work. Four new reads back it, all security definer and executable by service_role alone (supabase/migrations/20261024041130_growth_in_app_reads.sql), reading the synced rows (cloud_cards, cloud_thread_items) — so, like the earlier reads, they undercount the local-only work, because cloud sync is opt-in per folder. Files: ~/.weft/apps/growth/ (the page and weft-app.json), docs/architecture/mini-apps.md.
Weft shows its pricing in the app and takes you straight to Stripe Checkout, and it opens on its own whenever you hit a paywall. A new upgrade dialog shows Free, Pro and Team side by side, priced from the billing catalog, with a Monthly/Yearly switch (Pro's $200 a year shows what it saves; Team is $9 per seat a month) and the plan you're on marked Current. Upgrade to Pro and Upgrade to Team start Stripe Checkout: in the desktop app it opens in your browser, and the dialog waits for the new plan, then thanks you. On weft.zip it goes to Checkout in the same tab. It opens by itself when an invite on a Free workspace is refused (every invite box: Settings → Teams, the board's Share panel, People on your projects), when your shared mini apps run out of room (See plans), and beside a card's error when the free Puku cap is used up. The avatar menu's and Settings → Accounts' Upgrade to Pro open it too, and Settings now says Manage billing on Team. Behind it, one startUpgrade in lib/billing/checkout.ts serves the billing page, the weft.zip dialog and a new token-authed /api/billing/checkout for the desktop. Team checkouts bill one seat per current member. Stripe sends desktop buyers back to a new /checkout/done page that needs no sign-in. The Weft Pro and Team products and prices now exist on Stripe, and so does weft.zip's billing webhook. Three scripts run through the vault: scripts/stripe-weft-prices.mjs creates the prices and is safe to re-run, scripts/stripe-weft-webhook.mjs registers the webhook and hands its signing secret to Vercel without printing it, and scripts/stripe-prices.mjs lists what's there. Also fixed: the billing page sent Stripe a return path that already had ?checkout=done in it, Stripe added it again, and the page never re-checked your plan on the way back. (components/UpgradeDialog.tsx, lib/use-upgrade.ts, lib/billing/upgrade.ts, lib/billing/plan-cards.ts, lib/billing/checkout.ts, lib/billing-actions.ts, docs/architecture/billing.md)
A billing admin console, coupon codes and referrals. Super-admins get Admin → Billing: find a workspace, see whether its Stripe ids are live or sandbox, and upgrade, downgrade, switch plan or interval, change seats, cancel at period end or now, resume, comp a free plan, send a checkout link, refund a charge in full or in part, add or remove people (Team's seat count follows), apply or remove a discount, or reset its billing state so the next checkout starts fresh. Every Stripe call uses the key for the mode the ids live in, and every change leaves an audit row. Admin → Coupons makes and turns off Stripe promotion codes per mode and pays or voids referral rewards. Each workspace's billing page now offers a referral code: the team it refers gets 20% off three months, and the referrer gets $20 off its bill when their checkout completes (lib/billing/admin/*, lib/billing/referrals.ts, app/(admin)/admin/billing, app/(admin)/admin/coupons).
⏳ Limit badges and the Fastest Burn leaderboard. A new Limits badge family, 32 badges: a ladder for hitting your 5-hour limit 1, 2, 5, 10, 20, 50, 100, 250, 500 and 1,000 times, the same for weekly limits, speed badges for how fast you burned a window (⚡ Speedrun is a 5-hour window gone in under 30 minutes, ☄️ Week in a day), and badges for several hits in one day or days in a row. The usage chart shows your fastest burn of each window, and the 🏆 Leagues dialog gets ⏱️ Limit records, all-time boards for the fastest 5-hour and weekly burn and the most of each hit. (lib/limit-badges.ts, lib/limit-board*.ts, components/LimitBoardPanel.tsx, supabase/migrations/20261024203912_limit_records.sql)
Slack on the rail. Slack's mark under GitHub's connects the board to a Slack workspace over Socket Mode, so nothing needs a public URL: Create the Weft app opens Slack with Weft's manifest (or pnpm slack:manifest <app id> configures an app you made), and the bot and app-level tokens stay in your Keychain. Pick a default channel and what goes there, and map any project to a channel of its own, from the drawers' searchable picker (channels named like the project first, and + New channel to make one in Slack without leaving Weft): commits and pushes from every board repo (read from the reflog, whoever made them), Vercel deploys when they finish, and card status, where a card landing in Needs you, Review or Done gets a reply in its own Slack thread with its stage, what's left, a preview of its latest reply and a link to the card on weft.zip. In Slack, /weft fix the login bug or @weft starts a card in that channel's project (or your default one), and @weft in a card's thread sends that card your next message. electron/slack*.cjs, lib/slack*.ts, app/api/slack, components/SlackDrawer.tsx, components/SlackChannelPicker.tsx (docs/architecture/slack.md).
📥 Opening a project takes it in. Pick a project on the board and Weft moves the new values in its untracked .env files into 🔐 with no click: never over a value the vault already holds, and never from a file git tracks (you get "rotate these keys" instead). The keys its code reads, the names .env.example lists and the webhook routes it serves come in as 🔐 and 🪝 blanks, each with a link to the provider's page. A screen lists it all by name, with Set it up in a thread to start a card that works through the blanks and webhooks with you. Anything you remove stays removed. It runs in the background, once per project until a commit or an .env file changes (lib/project-intake.ts, components/IntakeDialog.tsx, electron/vault-intake.cjs).
🌱 A starter Backlog for every new project. The first time a folder lands on the board, Weft parks up to 6 cards in its Backlog, in the background: - Set up for agents when there's no CLAUDE.md or AGENTS.md - one card for migrations the linked Supabase project hasn't applied - up to 3 open GitHub issues assigned to you or labeled bug - the 2 most important failing production-ready checks - the file with the most TODOs It runs once per project, never starts a card, and never adds a card that's already on the board. Re-seed Backlog in the project menu runs it again, and the copilot's chips offer the same finds. The new shared project-open hook (lib/project-open.ts) is what triggers it; the seeder is lib/project-seed*.ts, and Re-seed is in components/ProjectMenu.tsx.
Weft installs its own updates when you're away. Once an update has downloaded, Weft restarts into it on its own the first time no thread is running and nobody has touched the Mac for 10 minutes. If Weft can't tell whether threads are running, it waits. The "Weft X is ready" notification now does something: clicking it opens Software Update, and its Restart Now button restarts right away, asking first if threads are running (electron/updater.cjs, electron/update-view.cjs).
Changed
The free tier's Puku cap is now 150 requests and 1M tokens a day (was 300 and 2M), and weft.zip's pricing previews Startups, Open Source, Education and Regional pricing.FREE_TIER in lib/billing/plans.ts groups the plans that run on the Puku trial's daily cap instead of an allowance; the proxy reads its numbers (lib/puku-proxy.ts), and Settings → Accounts, the billing page and the home page's Free card quote it through freeTierCapLabel(). Under the plan cards, a two-by-two Coming soon row (components/home/AccessPlans.tsx) shows Team free for six months then half price for six more for startups under $5M raised and 3 years old (up to 10 seats, STARTUP_TEAM), free plans for open-source maintainers and GitHub Student Pack students and teachers, and Pro from $5 (REGIONAL_PRO_CENTS). PUKU_TRIAL_DAILY_* on Vercel still override the cap. README, FAQ, puku-trial.md, billing.md and marketing-site.md are updated.
Every model is on offer, and a thread on a spent model moves to your backup. Wherever Weft asks for a model — the New thread dialog, Settings → Models (New threads, Use for subagents, Copilot, Review all, Run /weft:done on, Backup model) and the thread header's chip — the list is every model any CLI serves, grouped under Claude, Puku and Codex; nothing is locked to one CLI, and picking a model moves the card to the CLI that serves it. The first row, Default model, means "let the CLI decide": there is no default model behind the scenes any more, and Weft leaves the model off the command line instead of picking one for you. A changed default takes effect at once — an open New thread dialog follows it, and an idle thread picks it up on its next message. Plan limits are per model, so Weft remembers which model ran out and until when (lib/model-limit.ts): a used-up model is marked in every picker, and every thread on it — a new one, a running one, a subagent, the /weft:done wrap-up, the Review all card, the copilot — moves to your backup model instead of dying in Needs you (lib/model-fallback.ts); the backup may live on another CLI and the card's CLI moves with it. With no backup set, Weft never guesses a model you didn't pick. The thread header's picker now offers every model while a thread runs and says why a move was refused instead of failing silently. Files: lib/new-thread.ts, lib/model-limit.ts, lib/model-fallback.ts, lib/backend-switch.ts, lib/automations.ts, lib/copilot.ts, lib/use-new-card-dialog.ts, components/ModelSelect.tsx, components/Pickers.tsx, components/BackendPicker.tsx, components/NewThreadDefaults.tsx, app/api/settings/route.ts, app/api/cards/[id]/switch-backend/route.ts.
The feature intros are 16:9, with new voiceovers, and New thread gets one that plays in the browser pane. All the feature intros (the 🔐, 🪝, ▲, Supabase, Cloudflare, BlastCP, GitHub, ✦ and 🖥 drawers, code quality and the leagues) now play on a 1600×900 stage instead of a portrait 800×1000 one: the drawer's art on the left inside a clipped .art box, and the title and captions in a right column in larger type. The frame in the drawer and the dialogs is aspect-video. Every intro has a rewritten voiceover in Weft's own voice ("Weft's Supabase connector lets your AI…"), recorded with Kokoro, and its screens and captions are rebuilt on the new words' timings. The first time you open New thread, a minute of tips plays in the browser pane beside it: how to brief your AI (type, paste, or Option-' to talk), talking out a big idea, asking for a plan, keeping one area to one thread or using a worktree, and fanning out with subagents. ▶ by the dialog's title plays it again (lib/pane-intro.ts; electron/self-url.cjs lets /intros/<id>.html into the pane; on the web it plays in the dialog). The ?ratio=9:16 vertical mode is gone (public/intros/*, lib/feature-intros.ts, components/FeatureIntro.tsx, components/NewCardDialog.tsx, docs/architecture/feature-intros.md).
The welcome tour gives the 🔐 vault its own beat, and the board loop shows the pane as a beat of its own. A new Vault beat after Connect your tools shows your keys masked in the vault, a thread asking for one, macOS's Touch ID prompt, and the key filled in without the thread ever seeing it: "Your vault stores your credentials and lets your AI securely use them." The tools beat is back to GitHub, Vercel, Supabase and Cloudflare. In the board loop, "Click a card to open its thread" is now its own beat, followed by "Every Weft card has a pane, and it's always showing you something: your app, a plan, a video it made." The loop's Touch ID scene is gone. The pane beat says panes "can use keys in your vault" to reach your database or APIs. The beats run 64.5s and the tour 106s; the tour bar has ten beats (vault in BEATS, lib/welcome-loop.ts). Narration and both MP4s are re-rendered (public/welcome/beats.html, public/welcome/loop.html, scripts/narrate-welcome.mjs, public/brand/welcome-loop.mp4, public/brand/weft-tour.mp4, docs/architecture/welcome-tour.md).
Delete and Stop take effect on the board the moment you click. A deleted card used to sit there until the server had ended its CLI, and a stopped thread stayed in Running with its Stop button for up to the runner's grace period. Now a deleted card disappears at once, and a stopped one moves straight to Review (where a stopped turn ends) and loses its Stop button. A late update from the server, such as the stopped turn's last result or a deleted card's final event, doesn't bring the old state back. If the server refuses, Weft reloads the board, so the card comes back with an error toast; a stop that never leaves Running gives up after 10 s the same way. Every Delete and Stop goes through one module, lib/optimistic-cards.ts (removeNow, stopNow): the header's trash, the right-click menu, the card's Stop, the reply box's ■, the palette and the first-message banner. lib/board-live.ts draws each server card through it.
Next.js is on 16.4.3. The patch release is an LTS backport: Google font URLs without a file extension now resolve in both bundlers, proxy.ts no longer pulls in the app-page runtime, and middleware and render spans share one OpenTelemetry trace. The vendored HMR patch (scripts/patch-next-hmr.mjs, the fix for the reload loop in docs/bug-fixes/009-hmr-hash-mismatch-reload-loop.md) still applies to 16.4.3's Turbopack hot-reloader, so the loop stays fixed. next and eslint-config-next move together (package.json, pnpm-lock.yaml).
Every feature intro opens on a splash poster about its own feature, and the portrait videos fill their frame. Each of the eleven intros now plays a splash before its story starts: the feature's own icon big in the middle (640 px in the portrait render), its name under it, and a line saying what the feature does — "Your keys, encrypted in your Keychain", "Deploy, and watch the logs" — on a radial ground with a halo behind the icon, sized like an OG image so it fills the frame instead of floating in it. The icon and the name are read out of each page's own head row, so a drawer with artwork brings its SVG and one that uses a glyph brings its emoji, and no page needed editing; Vercel's plain triangle and the code quality ring get poster marks of their own, since neither carries at poster size. The splash holds 1.5 s and fades over 0.5 s, and the narration waits for it to clear, so the voice starts with the picture instead of underneath it. It lives on a master timeline with the page's own timeline nested under it, which keeps the whole composition seekable for the frame-by-frame render. The 9:16 render also restacks the landscape stage into a tall canvas — art on top, title and caption below — so a vertical video fills the frame instead of sitting in a black band. The renderer that captures the frames and muxes the narration now lives in the repo (scripts/render-intros.mjs, scripts/encode-intros.sh, pnpm intros:render and pnpm intros:encode), so a video can be reproduced instead of being lost with the machine, and scripts/verify-intros.sh checks each finished one — a blank render, the failure that looks most like success because ffmpeg encodes a page that never loaded happily, is caught rather than shipped (public/intros/player.js, public/intros/kit.css).
Level-ups now play on a cinema-sized screen, read aloud by a narrator. A level-up's popup grows from a small card to a wide 16:9 frame (up to 760 px), with the episode and its line of story in much bigger type, and a narrator reads the line as it opens. 🔊 in the corner mutes the voice, the same mute as the welcome and the feature intros. Thread's picture is new too: our hovercar half-built on the lift while a mechanic fits the woven emblem to its nose, twin suns through the garage door. components/AchievementPopup.tsx, lib/level-lore.ts, scripts/narrate-levels.mjs (pnpm levels:narrate), public/brand/levels/.
Review puts the latest card on top. The Review column now always orders by last update, newest first, whatever the board's sort, so the turn that just finished is the first one you see (lib/board-view.ts).
Switching projects clears the card filter. Text typed into the project bar's filter used to carry over when you switched projects, so the new project could look empty. Picking a different project now clears it, along with closing the open thread. Picking the same project again keeps it. The filter state moved into lib/use-board-view.ts, out of components/Board.tsx.
Commit the uncommitted pile ends with a deploy. The commit walk-through card now pushes and deploys once, after every change that can go is committed: it runs /weft:done's push.sh and then deploy.mjs, which waits for the build, and puts the push and deploy result at the top of the checklist page. A rejected push stays local and says why. Main file: lib/commit-pile.ts.
Admin has its own look, and Users lists accounts again. The /admin pages are built from a new admin kit on the board's own colours (components/admin/: page, sections, stats, badges, buttons, form fields and a table with a pager), so Users, Billing, Coupons and Settings match the board in light and dark. The bar lights the current tab and shows who's signed in. Users shows each account's avatar, name, email, provider, join date and last sign-in, with email search and sorting. It had been empty with "[object Object]" because it queried auth.users over REST, which Supabase doesn't expose; it now reads the auth admin API, and API errors that are plain objects show their real message (lib/guard.ts). Each admin page now runs its super-admin gate inside its own Suspense (AdminGate.tsx), so opening a page or switching tabs no longer trips Next's "blocking route" error.
The drawer intros play big, and a click pauses them. A feature intro now opens in a modal three quarters of the window instead of a small frame at the top of the drawer. Closing it (✕, Esc or the backdrop) shrinks it back into the drawer or dialog, where it keeps playing at the drawer's full width; ⤢ makes it big again, and ✕ there hides it. Clicking the video pauses the picture and the voice together and plays them on, and the native browser pane hides while the modal is up. components/FeatureIntro.tsx, lib/use-feature-intro.ts, lib/intro-modal.ts, public/intros/player.js.
A mini app's link opens to anyone with it, and its keys go up with it. Share… on an app with no link now makes one anyone with the link can open (Only you is one click away). A new folder app link uploads the keys its reads need in the same step, after one Touch ID, and the dialog says what went up (lib/app-share-keys.ts, electron/app-secrets-push.cjs, components/ShareAppDialog.tsx).
A mini app's keys follow its link on their own. Opening or saving Share… on a folder app now sends its keys to weft.zip whenever they changed in 🔐 or never went up, after one Touch ID. Before, only a brand-new link took its keys, so an older link (like 📈 Growth's) needed Upload secrets. Unchanged keys go up silently with no Touch ID, a Touch ID you decline isn't asked again while the preview loads, and the dialog keeps saying what went up. The link itself, its files and its preview already happened on their own. Main keeps a salted fingerprint of what last went up, never the values (electron/app-secrets-pushed.cjs, electron/app-secrets-push.cjs, lib/app-share-keys.ts, components/ShareAppDialogUploadSecrets.tsx).
Fixed
The drawer intros' voice starts with the picture. The narration came in up to a few seconds late, because the board started it on a message from the intro's frame while it was still busy opening the drawer. Each intro page now plays its own voice and holds its timeline until that voice is really playing; a voice later than 4 s pulls the frames back to it, so its first words are never cut off. The 🔊 setting travels in the frame's address and as a message when you flip it (public/intros/player.js, lib/use-feature-intro.ts, lib/feature-intros.ts, docs/architecture/feature-intros.md).
The web board works on localhost, and you can sign in on it. The weft.zip routes — /login, /team, /<workspace>/board, the share and profile pages — used to be deployed-only, so a local server answered "Workspaces live on weft.zip" instead of the real page. WEFT_WEB=1 turns just those routes on locally (isWebEnabled() in lib/home.ts): the desktop board still sits at /, the marketing home page still takes over only when VERCEL or WEFT_HOME is set, and analytics stay off because app/layout.tsx still gates GA4 and PostHog on isDeployed(). Set it in .env.local, or run pnpm dev:web, which sets it for one run on port 1024. GitHub sign-in works there too: proxy.ts refreshes the visitor's session cookie on isWebEnabled() rather than the deployed-only check, and the whole OAuth round trip is pinned to one canonical origin, http://127.0.0.1:1024 (webLocalOrigin() in lib/web-auth.ts), so one Supabase allow-list entry covers it whichever name the browser used to reach your machine. The PKCE verifier cookie @supabase/ssr writes is host-only, so the host that starts the round trip has to be the host that finishes it: a visitor who arrives as localhost is moved across before the provider is asked, and the host is read from the request's Host header (webRequestOrigin()) rather than from request.url, which next dev normalizes to localhost — reading it there sends that move in a circle. app/auth/sign-in/route.test.ts and app/auth/callback/route.test.ts drive both handlers for a localhost and a 127.0.0.1 visitor and assert the redirect_to, the callback host and the final redirect all agree on one origin. Add http://127.0.0.1:1024/** to the project's Auth redirect URLs and Continue with GitHub signs you in on localhost. Your local server and the deployed site now share one implementation of the workspace board.
Share… on a mini app no longer stalls on its spinner. The board, the open thread and the copilot each opened their own live event stream, and each one kept an HTTP connection open for good. Chromium allows six per host, so with a few slow polls the Share dialog's requests sat queued in the window and never reached the server. Every listener in a window now shares one stream (lib/use-events.ts). Changing who may open a link also stopped re-uploading a folder app's unchanged files on every save (pageForWrite in lib/app-share-row.ts).
The weft.zip board takes the whole window again. The board drew as a narrow strip down the left with the rest of the width empty. The shell renders <div class="wz-app wz-workspace"> — both classes on the same element — with the sidebar's <main> as its only grid child, and the rule that widens the board for it was written as a descendant combinator (.wz-app .wz-workspace:has(.wz-desk)), which asks for a .wz-workspaceinside a .wz-app and so never matched the real markup. The board therefore stayed in the shell's 252px first column, and the 1fr track beside it went unused. The selector is now a compound (.wz-app.wz-workspace:has(.wz-desk)), which matches; the board spans the window, and the Team and Billing pages keep their sidebar. A test pins the selector's shape and matches it against the shell's real markup, so a later "cleanup" of the selector can't bring the narrow board back (components/web-board/web-board-layout.test.tsx).
The limits chart reaches back into last week, and the strip shows the account's real limits. The status strip's hover over 5hr / 7d draws the week's burndown, and in the first days of a week it should reach back into the days of the week before — shaded and dimmed, with a "last week" label. It asked /api/limits for one CLI's slice of the limit history, but the plan limits are one pool per account, shared by every CLI: Claude Code and puku-cli report the same 5-hour and weekly windows with the same reset times, because they draw from the same plan. On 2026-10-23 puku read used=100 at 19:45 while Claude Code read used=99 at 19:43 and used=100 at 19:47 against the same reset — one climbing counter, not two. A card on puku, the CLI with the fewest readings, therefore saw no history before the week began, so the lookback could never fire and the chart stayed at seven days however fresh the reset. The chart now reads the account's whole history, and the strip's numbers are the pool's too: a card on a CLI that stopped reporting shows the account's real limits instead of a stale snapshot of its own. The plan the strip names is still the card's own CLI's. /api/limits no longer takes a ?backend= to narrow the history — that narrowing is the bug, so it is gone rather than left to be walked back into. Each CLI's entry carries its own copy of the pool's window, dated by the pool's own reading. The docs carry the proof, because the pool reading was once disputed: the two puku rows look like Claude's own readings logged under the wrong backend after a CLI switch, and the log rules that out — a mislabeled reading must be a duplicate, and used=100 with resetsAt=1792765800 appears in no earlier Claude row, while it is exactly the next step in the climbing counter against the identical reset epoch. Files: components/LimitBurndown.tsx, lib/plan-limits.ts, lib/use-start-gate.ts, app/api/limits/route.ts, app/api/limits/plans/route.ts, components/StatusStrip.tsx, docs/architecture/limits-burndown.md, docs/architecture/limits.md, docs/bug-fixes/031-limits-chart-never-reached-back-into-last-week.md.
A finished /done is archived, not left waiting in Needs you. When a wrap-up ended with column=needs_you, the card was parked in Needs you, unarchived, waiting for a reply nobody had asked for. A wrap-up that has run is over whatever its status line asked for: columnAfterTurn() in lib/turn.ts takes a wrappingUp flag and reads needs_you as done for a wrapping turn, and settleWrapUp() in lib/runner-turn.ts archives the card into Done instead of holding it back. The automations queued behind the wrap-up still go out, since a wrap-up is over whether it asks for something or not. "This turn is the wrap-up" is the turn's own marker (session.wrapUp, set in sendMessage() when the message that starts the turn is /done or /weft:done) and not the card's: a Done move landing on a busy card marks the card, but the turn already running is an ordinary one and still ends where its status line says. A wrap-up that ends in review stays on the board, unarchived, with what it couldn't do in remaining; a plain turn that ends needs_you is unchanged and still waits for your answer. Files: lib/turn.ts, lib/runner.ts, lib/runner-turn.ts, lib/runner-state.ts, lib/turn-column.test.ts, lib/runner-wrap-up.test.ts, docs/architecture/threads-and-sessions.md.
Picking Goose in the New thread dialog now starts a Goose card. The card came up as a Claude Code card with nothing to say so: lib/new-card.ts validated the backend against its own hardcoded list of three CLIs, and Goose was not on it, so a goose body fell through to the default. The board had 0 Goose cards out of 1278 despite Goose being offered in the picker. There is now one list of CLIs — BACKENDS, BACKEND_IDS and ALL_MODEL_IDS in lib/new-thread.ts — and every route that validates a backend checks against it (lib/new-card.ts, both switch-backend routes, app/api/settings, lib/plan-limits.ts, app/api/cards/[id]/rerun-model), so a CLI added to the picker works everywhere at once; a test fails if a CLI is ever added to the type but not the list. Goose's own commands are now the ones its version has: it has no goose auth, so its login status is read from goose info --check (lib/login-status.ts), its login is the interactive goose configure wizard in a Terminal window (lib/login.ts, lib/login-terminal.ts), and a Goose thread no longer reports Claude Code's effort level or reads Claude Code's settings. Files: lib/new-thread.ts, lib/new-card.ts, lib/login-status.ts, lib/login.ts, lib/login-terminal.ts, lib/cli-defaults.ts, lib/subagents.ts, lib/types-card.ts, app/api/cards/switch-backend/route.ts, app/api/cards/[id]/switch-backend/route.ts, app/api/cards/[id]/rerun-model/route.ts, app/api/settings/route.ts, app/api/warm/route.ts, lib/plan-limits.ts, lib/board-tools.ts, components/CardFooterMeta.tsx, components/StatusStrip.tsx, components/NewCardDialogFooter.tsx, docs/architecture/goose.md.
A Goose thread runs the model you gave it, delegates the way Goose can, and signs its commits. A model picked for a Goose card on the New thread dialog, or switched to mid-thread, was silently replaced by the one picked in Settings → Models → Bring your own key; the card's own model now wins, as on Claude Code and puku-cli, and the Settings model only fills in a card left on Goose default (lib/runner-state.tsspecFor). Goose has no Agent tool, yet its first message told it to use one: it now gets GOOSE_DELEGATE and Goose's own headless line, goose run --text "<task>", which runs on the thread's provider and model because gooseEnv() sets GOOSE_PROVIDER and GOOSE_MODEL for the Bash children it starts (--model for a picked Goose model; another CLI's model keeps that CLI's line) (lib/subagents.ts, lib/goose-runner.ts). And its commits and PRs carry Co-Authored-By: Goose (<model>) on Weft <noreply@weft.zip> and a Generated-with-Goose line, given in its system text since Goose has no attribution setting (lib/attribution.tsgooseAttributionPrompt, lib/goose-recipe.ts). docs/architecture/goose.md.
A changelog entry is stamped by the commit that carries it, not by any commit its thread happens to run. Cards share one working tree, so a thread's Bash call can land another card's commit. On 2026-10-24 the quota card's 35d9ed2 stamped the preview card's entry, whose real commit was 858706e. Entries now know their files: changelog_add takes files, and an entry without them is read for the backticked paths in its text. After a commit, the hook reads each new commit's changed paths and stamps only the drafts whose files it touched. That covers a batch commit carrying several cards' work, and a call that lands several commits, where each entry gets the commit that carried it. An entry with no files known stamps on any commit, as before. Drafts a commit left behind are named to the thread, and changelog_edit takes sha (and files), so a card can stamp a missed draft or correct a wrong stamp. The preview entry is corrected to 858706e. Files: lib/changelog.ts, lib/changelog-files.ts, lib/changelog-git.ts, lib/changelog-stamp.ts, lib/changelog-tool.ts, docs/architecture/changelog.md.
The Growth app's partial-day hatch keeps its own colour. The charts that mark the unfinished day with a hatch pattern each defined an SVG <pattern id="hatch">, but an id is unique document-wide, so url(#hatch) resolved to whichever pattern the page rendered first — the stacked charts could lose the per-series colour of their last column, and the plain bar charts could take the wrong series' hatch. Each chart now emits its own pattern (hatch-bars-1, hatch-stacked) and names it explicitly, so the hatched day reads as the series it stands for in every chart. (~/.weft/apps/growth/index.html)
The intros say "Vercel" right, and New thread really plays its intro. Kokoro read "Vercel" as VER-sul; the voice script now respells names it gets wrong for the voice only (SAY in scripts/narrate-lib.mjs, "Vercel" → "Vercelle", ver-SELL), while the captions and word timings keep the real word. The ▲ Vercel and 🔐 Secrets intros are re-recorded, and their screens still land on their words. The New thread dialog's intro (in the browser pane on the desktop, in the dialog on the web, ▶ by the title to replay) had been left out of its commit and is now wired in (components/NewCardDialog.tsx, public/intros/vercel.*, public/intros/vault.*).
A thread whose CLI session is gone keeps going instead of failing every message. A card names its new conversation before the CLI has saved any of it, so a Weft restart or a Stop in that gap left a session id with no transcript, and every message after answered "No conversation found with session ID". Now Weft drops the lost id and re-sends your last message in a fresh session started from a brief of the thread, the way Send fresh does, with one spliced row marking where it begins and no second copy of your message (lib/lost-session.ts, lib/splice.ts, lib/runner-turn.ts, lib/runner-session.ts, docs/architecture/threads-and-sessions.md).
The plan limits are one pool per account, and the chart, the strip, the start gate and the auto-resume now agree on it. Claude Code and puku-cli both read the same Anthropic subscription's 5-hour and weekly windows, so a card's numbers are the account's numbers whichever CLI it runs on. The proof is in the mechanism, not in a log's two rows: the SDK's rate_limits come from the claude.ai usage endpoint and are null for API-key, Bedrock and Vertex sessions, so a reading in the log means a claude.ai account session made it — and a puku row cannot have come from a plain API-key gateway. The log agrees: puku's five_hour used=100 resetsAt 1792765800 at 19:45:15 sits between Claude Code's 99 at 19:43:51 and 100 at 19:47:40, the next value in one climbing sequence against the same reset epoch to the second. Two independent plans do not share a reset epoch. lib/plan-pools.ts holds the mapping, so nothing has to work it out again. Four bugs the per-CLI model was hiding went with it. The chart never reached back into last week: it asked for the CLI's own readings, and puku's two rows left a puku card with a flat seven days and nothing to look back at — it now draws the account's whole history. The server and the client disagreed about the gate: the server read the raw log while the browser read the plans payload, so the dialog could say "Start at 2:30am" and the server start the card anyway. A card parked on the wrong CLI never resumed: the filter was the backend's, so a puku card parked on the account's spent window waited for a Claude Code reset. And the log remembered the counter twice, keyed by the CLI, so a puku reading of the same counter looked like a change and a pool reset seen on Claude Code did not fire for puku. The plan the strip names is still the card's own CLI's, and each CLI's entry carries its own copy of the pool's window, dated by the pool's own reading. ?backend= on /api/limits now means the plan's readings — the same rows for Claude Code and for puku-cli, and only Codex's own for Codex — instead of narrowing the history away. A puku card's own reading is no longer stamped "claude". Files: lib/plan-pools.ts, lib/plan-limits.ts, lib/limit-reset.ts, lib/limit-history.ts, lib/limit-resume.ts, lib/limit-chart.ts, lib/use-start-gate.ts, app/api/limits/route.ts, components/LimitBurndown.tsx, docs/architecture/limits-burndown.md, docs/architecture/limits.md, docs/bug-fixes/037-limits-chart-never-reached-back-into-last-week.md.
Fix Stripe payments that never upgraded the plan.checkout.session.completed and invoice.paid webhooks failed with "no unique or exclusion constraint matching the ON CONFLICT specification": the invoice and card upserts in @aimhuge/billing couldn't use the partial unique indexes. They're plain unique indexes now (supabase/migrations/20261024155332_billing_upsert_indexes.sql, applied live), so a payment marks the workspace Pro. scripts/stripe-replay-local.mjs replays Stripe events to a local Weft, signed with the vault's webhook secret.
Feedback you write on a review step is no longer lost when you step through the tour. Each step keeps its own note (lib/review-feedback.ts, in localStorage under weft.review.<cardId>.<step>), so a note you wrote on one step comes back when you return to it, and the panel's header now says how many steps still hold feedback you haven't sent ("· 1 step with unsent feedback") instead of leaving it stranded where you can't see it. "Looks good, next" and "Done" send the feedback of every step that still has some, in one message, before the tour's /weft:done goes out — so a note written for the thread to act on always reaches it. Previously, feedback written on a step you moved past was invisible and never sent, and "Done" sent only /weft:done. (components/ReviewPanelTour.tsx, components/ReviewPanelHeader.tsx, lib/review-feedback.ts)
Card pictures load again in pane pages. A page a thread opens in the browser pane (a commit walk-through, a review page) showed broken images for card pictures, because the pane never carried Weft's read header and an <img> can't add one. The pane now sends the plain read value on attachment GETs only, never the launch token and never a write. Main files: electron/api-token.cjs, electron/browser-pane.cjs; the two-line hook-up in electron/main.cjs lands with the card editing that file.
Copy link on a mini app gives its real address. Share… showed @you / apps / growth, but a link whose slug field was never edited went up without a slug, so Copy link handed out the hashed weft.zip/a/<id>. Every link now gets its default address when it's shared (the next free -2 … -9 if that one is taken), and an older link without one gets it the next time Share… opens. Copy link now gives weft.zip/@you/apps/<slug>. In lib/app-share-slug.ts, lib/app-share.ts and components/ShareAppDialogOwnerSlug.tsx.
Upload secrets finds the keys a mini app's reads use. A folder app under ~/.weft/apps (like 📈 Growth) reads its data with the vault of the project its weft-app.json names. Share… → Upload secrets used to look in the app folder's own vault, which is empty, so the shared link never got its keys and every panel on weft.zip stayed empty. It now reads the same vault as the reads (electron/app-secrets-push.cjs).
Achievements, ⌘K, Settings and the welcome stay in front. An achievement popup now stacks above every other dialog, so one that arrives mid-task is never hidden. Next come the command palette, then Settings and the welcome, all above feature intros and the upgrade dialog. ⌘K now opens while Settings is open and stacks over it; Escape closes the palette first. The ⌘P file picker now uses the shared dialog chrome like every other dialog. Modal takes a layer prop (components/Modal.tsx).
Upload secrets stores its keys on weft.zip. Every upload failed on weft.zip with "no unique or exclusion constraint matching the ON CONFLICT specification", yet answered 200, so the Mac said "Uploaded…" while the link stayed empty. The keys now store (lib/app-secret-store.ts updates the row, else inserts it), and a key that doesn't store comes back as an error naming it (app/api/apps/secrets).
Inlined images show on shared pages. weft.zip's secret masking read a page's data:…;base64, image as a run of random-looking tokens and cut it into ‹hidden: an opaque value› pieces, so a photo pasted into a shared mini app (like For Yan) came up blank. Shared pages and folder files now leave data URI payloads whole and still mask keys around them (lib/share-redact.ts, lib/share-page-serve.ts).
Removed
(Superseded) The plan limits were treated as per CLI again, so a puku card never showed Claude's numbers. That change treated Claude Code and puku-cli as two separate plans: it copied only each CLI's newest reading onto that CLI and drew the chart from each CLI's own history. It rested on two limit-log rows that do not prove a shared plan — and it was wrong: Claude Code and puku-cli read the same Anthropic subscription's 5-hour and weekly windows, so the plan limits are one pool per account. The reading belongs to the CLI that made it; the window belongs to the plan. The model that replaced this one is described in the entry "The plan limits are one pool per account" (lib/plan-pools.ts, lib/plan-limits.ts, app/api/limits/route.ts, components/LimitBurndown.tsx, lib/use-start-gate.ts).
No more coach tips over the board. The one-time bubbles under the Needs you, Review and Done headers are gone. That includes the last one, which opened your Wefter Profile. An empty board still offers its Start your first thread panel. Reset tips and Replay the welcome now bring back just the drawer intro videos and the ask to sync. The welcome's last screen no longer promises the column tips (components/EmptyBoardStart.tsx, components/Board.tsx, lib/welcome-client.ts, components/WelcomeFooter.tsx).
Security
The pentest findings are all closed. Nine findings across two waves — one high, four medium, four low — each fixed with its own tests. F-11: a hosted mini app's manifest no longer leaks its author's Supabase URL and key; a public read's supabaseUrl has to be a Supabase host and the author's key never rides along. F-12: the webhook relay no longer accepts every request it receives — it now checks the provider's own signature (Stripe, GitHub, Vercel, Svix) and refuses anything else (401), a relay with no provider (403) or a deployment with no key (503). The signing secret is sealed into the relay row with AES-256-GCM on the Mac, behind one Touch ID, so the plaintext never crosses IPC or reaches the deployment. P-1: a thread can no longer approve its own permission prompts — every control that grants power is marked human-only, and board-view clicks arrive as real trusted mouse events. P-2: cloud sync uploads every free-text string through the share redactor first, so secrets stay on the Mac. P-3: an "Always allow" rule is pinned to a fingerprint of the clean git tree it was granted in — edit the tree and the permission asks again. P-4: power-granting local writes need the real launch token, not the constant x-weft-token: 1. Files: lib/webhook-signatures.ts, lib/relay-secret.ts, electron/relay-secret.cjs, lib/webhook-relay.ts, lib/webhook-forward.ts, electron/pane-routes.cjs, electron/board-view.cjs, lib/cloud-sync-redact.ts, electron/run-rules.cjs, lib/guard.ts, app/api/*.