Repository navigation
Harden the API, fix data-loss paths, bound prompts, add installers and an eval harness - #5
Conversation
Any web page could send no-preflight POSTs to 127.0.0.1:4317 (the server parsed text/plain bodies as JSON), and DNS rebinding gave a hostile page full read and write access, including pointing a model tier at its own host with any environment variable as the bearer key. - Require a per-launch token on every API request. The server injects it into the index.html it serves; `pnpm dev` shares one token between the API and Vite through a small launcher that replaces concurrently. - Refuse non-loopback Host headers and foreign Origins; send CORS headers only for explicitly allowed origins. - Accept only application/json request bodies, capped at 20 MB. - Validate git refs and branch names before they reach git, so a value like `--output=...` cannot become an option. - Restrict api_key_env to *_API_KEY names and base_url to http(s) URLs. - Open a new book before closing the current one, so a failed open leaves the app usable. - Validate the export format, return 500 for programming errors, use unique job ids, and resolve the sample fixture with fileURLToPath (Windows, spaces). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
Every commit ran `git add -A`, so it swept in whatever else was dirty: edits made in another editor, and prose the author had autosaved but not committed. A ripple's origin commit could therefore contain an hour of unrelated typing, and reverting the ripple threw it away. The same sweep committed an edited card before minimal regeneration read "the committed card", so the reviser was given identical old and new cards. - GraphStore.commit stages only the files the store wrote (deletions included); GraphStore.checkpoint commits the whole tree for explicit checkpoints such as a new book or the step before a revert. - A ripple first checkpoints other work, then commits the originating edit alone (changeFiles maps each change kind to its files). Each round commit holds only what the round wrote, tracked by write sequence. - Minimal regeneration finds the card version whose hash matches the prose's generated_from in history, instead of trusting HEAD. - The repo staging area is reset before each commit; a file written and removed before any commit no longer errors; dirty state survives a failed or non-repo commit. - Prompt overrides are written through the store, so they are committed. - .engine/ and export/ are added to .gitignore of pre-existing repos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
- Revert a ripple's commits as one commit with `git revert --no-commit`. If any of them conflicts with later work, abort, put HEAD and the tree back, and raise RevertConflictError naming the files (HTTP 409). Previously a conflict left the book mid-revert with the ripple still open. - The history revert route uses the same all-or-nothing path. - A proposal that failed (network, rate limit) is retried by proposing again; before, an errored item blocked the round and only rejecting it unblocked it. - Accepting an edited card proposal keeps the scene's status and lock instead of resetting an approved or locked scene to Zod defaults. - Ripple ids get a random suffix; second-granular ids collided. - The revert fallback for edits committed before the ripple started now covers scene deletions and character edits, restoring their files from history. - A wording-only prose edit on a scene whose card had also moved no longer marks it in sync, which hid the card change from the prose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
- Retry rate limits, overload, 5xx and dropped connections with exponential backoff (honouring retry-after), on top of the SDKs' own retries. Mid-stream failures, which the SDKs do not retry, are now retried. Refusals, bad requests, missing keys and exhausted quotas are not. - Log usage when a call fails, including tokens already billed by earlier attempts; costs.jsonl under-counted exactly the expensive failure cases. - Cache read and write prices are configurable per tier (price_cache_read, price_cache_write). Read prices range from 0.025x to 0.1x of input across current models, so a fixed 0.1x was wrong for some tiers. - OpenAI strict mode now gets optional fields as required-but-nullable, and those nulls are stripped before Zod validation so defaults apply; strict mode used to force the model to invent a value for every optional field. Anthropic structured outputs keep optional fields optional. - Only a rejection of the output schema downgrades an Anthropic schema to prompt-only JSON, not any 400 whose message mentions "format". - The Codex child process gets an allowlisted environment (home, locale, temp, proxies, CA certificates) instead of everything except two OpenAI variables. Login advice is shown only for authentication errors, and a timeout says so. - The OpenAI-compatible backend has a timeout (QUILLGON_COMPAT_TIMEOUT_MS) and typed, retryable errors; the Responses backend reuses its client and explains an incomplete response caused by the output limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
Measured with a new scale check (pnpm scale-check: the sample grown to 96
scenes and 409 facts), the extractor, card planner and card reviser each sent
the whole fact glossary (about 12k tokens at that size, growing with every
extraction), and the simulated reader sent its entire log to every scene, so a
reader run cost 743k input tokens and grew quadratically.
At the last scene, approx. tokens, before -> after:
writer 8,310 -> 5,900
extractor 11,969 -> 3,018
card planner 12,744 -> 3,804
card reviser 11,368 -> 2,700
reader 14,996 -> 1,372 (whole run 743k -> 132k)
- Facts: once the glossary passes 80, send every fact the task names plus the
most similar ones (local embeddings); the planner prefers facts no scene has
revealed yet. Small books still get the whole glossary.
- Reader: the latest six log entries in full, plus the predictions and still
open confusions from older scenes.
- Writer: a deterministic "story so far" from chapter summaries and earlier
outcomes (bounded, oldest chapters dropped first), and style samples clipped
to 6,000 characters.
- One words-per-scene rule for the planner and the writer; the writer's target
ballooned when chapters were carded one at a time.
- New scene numbers ignore id suffixes ("s04-2" was read as 42).
- The cost estimate measures the writer's real context, prices the line editor
as span edits rather than a whole-scene rewrite, and counts the reader
window.
- `pnpm scale-check --max-prompt N` fails when any prompt exceeds N tokens.
Store writes stay at 9 ms (96 scenes) and 22 ms (300 scenes), so the index is
left as a full refresh per write.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
`pnpm test` now also runs apps/desktop/src/**/*.test.{ts,tsx} in a
happy-dom vitest project ("ui"), with @testing-library/react for hooks
and components.
Outline flatten/rebuild move to lib/outline.ts so they can be tested,
and lib/util.ts gains deepEqual (used instead of JSON.stringify for
dirty checks). Tests cover util and the outline row helpers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
Saves - Extract SceneEditor's data logic into hooks: useProseAutosave, useSceneDetail, useStyleCheck (and useDraft for the style settings). - Prose autosave failures are caught: the text is kept, a banner and the status bar say so, a toast offers Retry, and the next edit retries. Saves are serialised so an older PUT never lands after a newer one. - Unmounting or switching scene flushes a pending save instead of dropping it; a failed flush after leaving still toasts with Retry. Closing the page sends a pending save with keepalive. - A refresh (SSE-driven or after a job) no longer overwrites prose typed since the last save, nor a dirty card. Jobs flush prose first. - cardDirty and the style-sheet dirty flag are memoised structural comparisons instead of JSON.stringify on every render; the style settings no longer reset local edits on every project refresh. - Unsaved card and style edits are guarded: in-app navigation (go(), links, back/forward) asks via the app's Dialog; closing or reloading the window is blocked (Electron gets the in-app dialog, then closes). Errors - A global unhandledrejection handler toasts; errors already reported by job() are marked so they are not toasted twice. - Catch the remaining unhandled rejections in welcome, build, shell (branches, voice check, export), history, ripple, style settings and the scene's impact and review dialogs. - The shared EventSource reconnects with backoff after it closes and tells listeners to refresh after a drop (token handling unchanged). Keyboard and accessibility - One Escape stack: only the topmost layer (dialog, drawer, palette, branch menu) closes. - useShortcuts attaches once (stable rail toggle, state via ref); the ripple key handler has deps and ignores keys while focus is in an input, textarea, select, contenteditable or a real button, or while a layer is open. - Dialog and Drawer are labelled by their title, focus their first field on open, trap Tab, and restore focus on close. - Field links its label to a single native control (useId, htmlFor, aria-describedby for hints) and names composite controls as a group. - window.prompt/confirm replaced by the app's Dialog (DialogsProvider: useConfirm/usePrompt) for what-if naming, scene deletion, ripple and history reverts. - The branch menu opens on ArrowDown, moves with Up/Down/Home/End and returns focus to its button; the ripple list uses a roving tabindex. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
The sample is found relative to the server's source file, which does not hold once the server is bundled into a single file for the desktop app. The packaged app sets QUILLGON_FIXTURE_DIR; without it nothing changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
`pnpm dist` (or `pnpm -C apps/desktop dist`) builds the UI, then scripts/prepare-package.mjs stages: - out/server/server.mjs: electron/server-entry.mjs with @quillgon/server and every workspace and npm dependency bundled by esbuild, except better-sqlite3. Next to it: the built UI, the sample novella and the agents' prompt files (read relative to the bundle). - out/server/node_modules/better-sqlite3 (+ bindings, file-uri-to-path), from a prebuilt Electron binary if one exists, otherwise compiled with @electron/rebuild, then checked by loading it in Electron's Node. - out/app: package.json and the two shell files. electron-builder packs out/app into app.asar and ships out/server as resources/server, so no native module lives in the asar. Targets: mac dmg+zip, win nsis, linux AppImage+deb. Signing is optional: without certificates the build is unsigned (ad-hoc on macOS). The dev path (`pnpm desktop`: node + tsx from the source tree) is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
- Startup failures show an error dialog and quit: a missing node binary (with what to install or set), the server exiting before it is ready, or no URL within 30 s (QUILLGON_STARTUP_TIMEOUT_MS), with the tail of the server's stderr. - If the server stops later, a dialog offers Restart or Quit; restart starts a new server and reloads the window. - Every web contents stays on the server's origin: will-navigate and will-redirect elsewhere are blocked, window.open is always denied, and only http, https and mailto URLs are handed to the default browser. Webviews are refused; IPC is answered only for the app's own pages. - All permission requests and checks are denied. - Packaged: runs resources/server/server.mjs with process.execPath and ELECTRON_RUN_AS_NODE=1; the server exits when the shell's pipe closes. QUILLGON_SERVER_BUNDLE runs a staged bundle from a source checkout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
On a v* tag, build on macos-latest, windows-latest and ubuntu-latest with `pnpm dist` (version from the tag) and attach the dmg/zip, exe, AppImage and deb to a draft GitHub release. Signing and notarization secrets are optional. workflow_dispatch builds the same artifacts without a release. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
README: a "Desktop app and installers" section (what `pnpm dist` builds, requirements, unsigned installers, Git, API keys from the environment, Codex needing a checkout, and what has and has not been verified), and the Electron note now covers both the dev and the packaged server. CONTRIBUTING: how the packaging fits together and how to test it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
- CI: a second job installs Playwright's Chromium and runs the full UI scenario (pnpm e2e) and the layout checks; the main job enforces the prompt budget with pnpm scale-check. The browser scripts find Chromium from $CHROME, a system install, or Playwright's own. - Server jobs run one at a time (later ones report "queued"); they share one store, index and git repo. An external file edit that lands during a job is reloaded after the queue drains instead of swapping the project mid-job, and opening another book, reloading, branch switches, merges and deletes are refused (409) while a job runs. - CLI: boolean flags no longer swallow the next argument (`--yes s01`). Without a terminal, confirmations and review choices refuse to answer themselves unless --yes is given; a piped `quillgon change` used to accept and commit a ripple. - First CLI tests: argument parsing, validate and status, and the piped change flow with a scripted $EDITOR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
# Conflicts: # apps/desktop/package.json # pnpm-lock.yaml
`Number(flags.port) || 4317` turned 0 into 4317, so the desktop shell, which asks for a free port, always took 4317 and clashed with a running `pnpm serve`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
- Electron: the UI blocks unload while a save is pending or failed, and Electron cancelled the close silently. A will-prevent-unload handler now asks whether to leave without saving. - Codex: QUILLGON_CODEX_PATH points the SDK at the user's own Codex binary, which an installed desktop app does not ship. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
The research doc puts an eval harness before tuning any prompt against a live model; nothing measured quality across runs. - evals/premises.yaml: a frozen set of 20 premises across genres, each with a fixed intent, so runs start from identical inputs. - `quillgon eval` drives each premise through every gate (plot points, structure, scene cards, then the opening, the turn and the ending, or every scene with --all-scenes) and the simulated reader, then scores the books: slop and style violations per 1,000 words, continuity errors, structural tells, twists guessed early, engagement, and, across the run, shared character names, prose trigram overlap and plan diversity. Results and a Markdown report go to evals/runs/<id>/ (gitignored). - `quillgon eval compare <a> <b>` judges each key scene against its counterpart in both orders (a split verdict is a tie) and reports B's win rate and the Elo difference it implies. - Live runs ask for confirmation; --record/--replay keep model responses for re-scoring without new calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
The code carried eslint-disable comments but the repo had no ESLint config, so nothing was linted. Add a flat config with TypeScript's recommended rules and React's rules of hooks (missing effect dependencies are warnings: most are deliberate "refetch when the project changes" effects), run it in CI and the contributing checklist, and fix what it found: unused imports and variables and a regex with literal double spaces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Double prompt on Electron close
- Removed the redundant will-prevent-unload native dialog so only the in-app discard confirm runs on Electron close.
You can send follow-ups to the cloud agent here.
Remove the will-prevent-unload native dialog. beforeunload already opens the in-app discard confirm, and preventing unload is what triggers will-prevent-unload, so both prompts fired and could disagree.
- A ripple round commit no longer picks up an author's autosave in another scene that lands during a slow extraction. - Reverting a scene deletion restores the state entries of characters who were not on the scene's card; a character edit's files include its state file; the revert fallback restores every file the originating commit touched from its parent, rather than guessing from the first file's history. - Branch names follow git's own rules (apostrophes, accents, + and @ are fine); only names git refuses, and anything starting with "-", are rejected. - Reverts checkpoint again if an autosave lands between the checkpoint and the revert, revert merged what-ifs against their first parent, and report an unknown commit or other non-conflict failure as such rather than as a conflict. - simple-git resolves a failed command whose stderr is empty, so checks no longer pass -q where they rely on a rejection. - Electron asks once on close with unsaved edits (the main process's native prompt), not twice. - The fact-ranking cache keeps fact vectors only; one-off query vectors were filling it. - The originCommit-less revert path no longer makes a duplicate commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
…qj5cya' into claude/repo-review-improvements-qj5cya
The branch received a fix for the same double prompt from the other side (removing Electron's will-prevent-unload dialog). With both fixes there would be no prompt and Electron would silently refuse to close, so restore the in-app prompt, which asks and then closes the window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Resolved by another fix: Delete ripple sweeps unrelated state
- Confirmed real: changeFiles swept every dirty state.yaml; commit 1620e7d already records only characters whose state the deletion rewrote and scopes origin/revert to those files.
You can send follow-ups to the cloud agent here.
changeFiles took every uncommitted state.yaml for a scene deletion, so an unrelated, uncommitted state edit joined the ripple's origin commit and a revert undid it. GraphStore.deleteScene now returns the characters whose state had an entry for the scene, the change records them, and both the origin commit and the revert fallback use exactly those files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
…artner Conflicts resolved: - README: the redesigned layout, with this branch's installer section, CLI notes and security notes placed inside it; the source-preview note now says installers can be built but none are published. - API client and server: cover image URLs carry the token as a query parameter (an <img> or download link cannot send headers), and the server accepts ?token= only for that read-only GET and the event stream. - Cover generation runs outside the job queue: it reads a snapshot and writes only covers/, so it neither waits for nor holds up store jobs. - Keyboard shortcuts: the stable-callback handler with the new eighth view. Also: run the covers e2e in CI, use the shared Chromium lookup there, restore an icon import the covers nav needs, and let ESLint accept rest-omit destructuring and read-before-assign lets used by the new tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Close can drop unsaved prose
- Keepalive unload flush now runs whenever prose hasUnsaved (not only while the debounce timer is armed), completing the close-path fix alongside the existing hasUnsaved registration.
You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 6fd0656. Configure here.
Prose waiting to save, mid-save or failed was registered as unsaved only after a failure, so closing the window did not ask, and the keepalive save covered only a still-pending debounce. In Electron, closing quits the app and stops the server, so recent typing could be lost. The editor now registers its autosave's hasUnsaved() for window unload, with a flush. In Electron, closing first saves what can be saved and closes without asking if that was all; it asks only about what is still unsaved. Browsers show their own prompt while prose is unsaved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
Register hasUnsaved for the unload guard so pending or in-flight prose prompts before close, and keepalive-flush on unload whenever anything is still unsaved—not only while the debounce timer is armed.

Fixes from a whole-repo review, plus the additions it recommended. Each commit is one change with its own tests.
Security
127.0.0.1:4317, and DNS rebinding gave full read/write, including pointing a model tier at an attacker's host with any env var as the bearer key. Every API call now needs a per-launch token (injected into the servedindex.html;pnpm devshares one with Vite). Non-loopbackHostand foreignOriginare refused, bodies must be JSON, git refs are validated before reaching git, andapi_key_envmust end in_API_KEY.openExternallimited to http/https/mailto, permissions denied, IPC only answered for the app's own origin.Data loss and correctness
git add -Aswept unsaved autosaves into ripple commits, so reverting a ripple could undo unrelated typing. Ripples now checkpoint other work, then commit the edit alone. Minimal regeneration finds the card the prose was written from by hash.costs.jsonl, cache pricing is configurable per tier, OpenAI strict mode no longer forces invented values for optional fields, and the Codex child gets an allowlisted environment.--yes(a pipedquillgon changeused to commit a ripple).--yes s01parses correctly,serve --port 0works, and server jobs run one at a time.Scale
Measured with the new
pnpm scale-check(sample grown to 96 scenes, 409 facts), at the last scene, approx. tokens:Facts are scoped by relevance once the glossary passes 80, the reader keeps a six-scene window, the writer gets a bounded "story so far", and CI fails if any prompt exceeds 8,000 tokens.
Additions
pnpm distbundles the server with esbuild and builds with electron-builder;release.ymlattaches installers to a draft release onv*tags.quillgon evalruns 20 frozen premises and scores slop, style, continuity, structural tells, twist guessing, and cross-book sameness;quillgon eval comparejudges two runs both ways.Verification
pnpm lint,pnpm test,pnpm typecheck,pnpm build,pnpm e2e,scripts/ui-refresh.ts, andpnpm scale-check --max-prompt 8000all pass locally and in CI.Not verified
🤖 Generated with Claude Code
https://claude.ai/code/session_016zsDekiv5nExWZqocNHWNL
Generated by Claude Code
Note
High Risk
Touches authentication for the local API, Electron security boundaries, and autosave/commit paths where bugs could block access or lose edits; mitigated by broad new tests and CI e2e, but packaged installers remain partially unverified on macOS/Windows.
Overview
Adds cross-platform desktop packaging (
pnpm dist, esbuild server bundle,better-sqlite3rebuilt for Electron) and arelease.ymlworkflow that builds macOS/Windows/Linux installers and attaches them to draftv*releases. The Electron shell now runs either dev Node+tsx or the bundled server, with tighter navigation/IPC security and server crash recovery.Locks the local API to the app: per-launch token on every request (injected into served HTML; dev shares one via
scripts/dev.mjs/ Vite), plus client changes for token headers, SSE auth/reconnect, and keepalive saves.Reduces editor data-loss risk with debounced serial prose autosave (
useProseAutosave), scene reload coordination, an unsaved-changes registry (in-app confirm + Electron close path), promise-based confirm/prompt dialogs, and clearer save-failure UI.CI and quality gates add
pnpm lint(new ESLint config),pnpm scale-check --max-prompt 8000, and a Chromium e2e job (Playwright + screenshot artifacts on failure). Docs cover installers, eval workflow (quillgon eval), and contributing steps.Desktop UX/a11y improvements include modal focus traps, layered Escape handling, and expanded happy-dom unit tests for hooks and shell behavior. OpenAI-compatible adapter calls get timeouts and classified retryable errors.
Reviewed by Cursor Bugbot for commit f4699a0. Bugbot is set up for automated code reviews on this repo. Configure here.