Skip to content

Releases: wighawag/wherever

wherever-dev@0.15.2

Choose a tag to compare

@github-actions github-actions released this 16 Sep 17:28
11725c2

Patch Changes

  • 88e0810: Apply the instance appearance from the layout rather than from the config fetch.

    fetchConfig() in session-store.ts wrote directly to document.documentElement via applyAppearance(), putting a DOM side effect in the module that talks to the server. The store is now the only thing it publishes, and +layout.svelte re-themes off that store: it already owns app.css, where the --color-brand-* tokens and the frame/pattern rules the appearance drives actually live.

    Driving the effect off the store rather than off the fetch also means any future writer re-themes too, instead of the palette only updating on the one code path that happened to remember to call it.

    Adds coverage for the /config appearance mapping, which had none: the accent/label/frame/pattern payload, the compiled defaults for a server that configures no appearance, and the last-known appearance surviving an unreachable server.

  • e6c3ca2: Fix the web unit suite failing to resolve $lib imports.

    web/vitest.config.ts is a standalone config that deliberately does not load the SvelteKit vite plugin, so nothing registered the $lib alias. This went unnoticed until src/lib/session-store.ts gained an import ... from '$lib/theme', which put an aliased specifier on a module the tests actually load, breaking all 6 tests in session-store.test.ts and remote-candidates.test.ts.

    The alias is now registered directly in the vitest config, keeping the no-plugin property intact. Its target is read from SvelteKit's own svelte-kit sync output rather than hardcoded as src/lib, so it cannot drift from what SvelteKit resolves $lib to if kit.files.lib is ever changed.

wherever-dev@0.15.1

Choose a tag to compare

@github-actions github-actions released this 15 Sep 15:07
7876c01

Patch Changes

  • ee96ade: Configure the CLI bridge's connection from config.json, not just flags.

    The bridge's connection settings were reachable only as command-line flags, which breaks down for any deployment that is not on the built-in defaults: the settings have to be repeated on every single pi invocation, and a human who starts pi by hand just gets a bridge that never connects.

    The case that motivated this is a server behind a TLS-terminating reverse proxy. It runs --http on loopback and lets Caddy or nginx own HTTPS, but the extension defaults to wss://, so the bridge fails the TLS handshake with wrong version number while the server logs nothing at all: from the server's side no connection was ever made. The existing --remote-insecure flag fixes it per-invocation, which is the part that does not scale.

    A new remote section in the shared config.json sets the same things persistently, alongside the beep section the extension already reads from that file:

    • host / port / token: where the standalone server is, defaulting to 127.0.0.1:31415 as before.
    • insecure: connect over plain ws://. This is the reverse-proxy case above.
    • bridge: set false to disable the bridge entirely.

    Precedence is explicit CLI flag, then config, then built-in default. Making that hold required dropping the registered default: from --remote-host, --remote-port and --remote-token, because a registered default is indistinguishable from the user typing that same value: getFlag always returned something truthy, so the config could never have won. The defaults now apply at the point of use and are stated in the flag descriptions, so --help is unchanged in substance.

    Two of these are only reachable from config, and that is inherent rather than an oversight. A pi boolean flag cannot be passed as false, so --remote-bridge could never actually disable the bridge, and remote.insecure: true cannot be turned back off by a flag. The insecure asymmetry is documented on the config field: undo it by editing the config, not with a second negating flag.

    The extension now also honours WHEREVER_CONFIG_DIR when locating config.json, matching the server's getWhereverConfigDir(). Previously an isolated harness would relocate the server's config while the extension kept reading the developer's real ~/.wherever/config.json, which is the exact cross-talk that variable exists to prevent. With the variable unset the path is unchanged.

wherever-dev@0.15.0

Choose a tag to compare

@github-actions github-actions released this 15 Sep 13:45
40ed5bb

Minor Changes

  • dd66a68: Config-driven theming of the web dashboard: distinguish each server instance at a glance.

    Machines that mirror the same pi sessions over cloned folders look identical in the dashboard, so you cannot tell which instance you are driving — and with clones drifting out of sync, answering "is this the one that is ahead?" currently means reading paths. A new appearance section in ~/.wherever/config.json gives each server a visual identity that the web frontend applies on connect:

    • label — instance name shown as a pill next to the logo in the sidebar and appended to the tab title (Wherever · laptop-ahead). It defaults to the machine hostname, so two servers are already distinguishable with zero configuration. An explicit ""/false opts out for the exact pre-feature look.
    • accent — one CSS color re-tints the whole accent family (links, buttons, gradients, active-session highlights) plus the mobile/PWA theme-color chrome. Because the brand palette is compiled as Tailwind v4 @theme custom properties, every bg-brand-* / text-brand-* utility resolves them at paint time: the override is applied as inline CSS variables on <html> with no rebuild.
    • colors — advanced, per-token overrides (brandDark, brandSurface, brandSurface2, brandSurface3, brandBorder, brandText, brandTextMuted, brandCyan, brandBlue, brandPurple) for operators who want more than a hue shift; applied on top of accent.

    The identity is served by the existing GET /config (behind the same token gate), applied when the dashboard fetches it, and reset before reapplication so a PWA reconnecting to a different instance never blends two palettes. Values are written through style.setProperty only — arbitrary config strings cannot inject markup.

    Two additional visibility markers, for when a hue shift is not loud enough:

    • frame — a solid accent bar across the top edge of the whole dashboard (default: enabled when accent is set). It survives a collapsed sidebar and a session filling the screen, which the sidebar pill does not.
    • pattern — a backdrop pattern (stripes / dots / grid) painted on the dashboard's dark background wherever it shows, tinted with the accent at low alpha (color-mix), so it reads as a watermark rather than noise.

    The two hardcoded gradient stops in app.css were switched to the brand variables, so accent retints the .gradient-text/.gradient-border accents too.

@wherever-dev/pi@0.5.0

Choose a tag to compare

@github-actions github-actions released this 15 Sep 15:07
7876c01

Minor Changes

  • ee96ade: Configure the CLI bridge's connection from config.json, not just flags.

    The bridge's connection settings were reachable only as command-line flags, which breaks down for any deployment that is not on the built-in defaults: the settings have to be repeated on every single pi invocation, and a human who starts pi by hand just gets a bridge that never connects.

    The case that motivated this is a server behind a TLS-terminating reverse proxy. It runs --http on loopback and lets Caddy or nginx own HTTPS, but the extension defaults to wss://, so the bridge fails the TLS handshake with wrong version number while the server logs nothing at all: from the server's side no connection was ever made. The existing --remote-insecure flag fixes it per-invocation, which is the part that does not scale.

    A new remote section in the shared config.json sets the same things persistently, alongside the beep section the extension already reads from that file:

    • host / port / token: where the standalone server is, defaulting to 127.0.0.1:31415 as before.
    • insecure: connect over plain ws://. This is the reverse-proxy case above.
    • bridge: set false to disable the bridge entirely.

    Precedence is explicit CLI flag, then config, then built-in default. Making that hold required dropping the registered default: from --remote-host, --remote-port and --remote-token, because a registered default is indistinguishable from the user typing that same value: getFlag always returned something truthy, so the config could never have won. The defaults now apply at the point of use and are stated in the flag descriptions, so --help is unchanged in substance.

    Two of these are only reachable from config, and that is inherent rather than an oversight. A pi boolean flag cannot be passed as false, so --remote-bridge could never actually disable the bridge, and remote.insecure: true cannot be turned back off by a flag. The insecure asymmetry is documented on the config field: undo it by editing the config, not with a second negating flag.

    The extension now also honours WHEREVER_CONFIG_DIR when locating config.json, matching the server's getWhereverConfigDir(). Previously an isolated harness would relocate the server's config while the extension kept reading the developer's real ~/.wherever/config.json, which is the exact cross-talk that variable exists to prevent. With the variable unset the path is unchanged.

wherever-dev@0.14.0

Choose a tag to compare

@github-actions github-actions released this 10 Sep 16:25
2ce8afa

Minor Changes

  • bf90aed: Make "this session's folder does not exist on this machine" an explicit, safe, explained state instead of a silently broken one.

    Transcripts and the folders they refer to travel separately: a transcript syncs (syncthing, a backup, a new laptop), the git clone it talks about does not. Opening such a session used to look fine and then quietly hand you a live agent pointed at a directory that is not there: pi's SettingsManager.create() and DefaultResourceLoader.reload() both succeed against a nonexistent cwd, so nothing threw and the first sign of trouble was every file and bash tool misbehaving for no stated reason.

    • The cheap session-meta read now also answers whether the working folder EXISTS (one stat, beside the read-only verdict it already computes from the cwd), so both the warm and the cold load path share the answer.
    • When it does not exist, the transcript still paints (reading never needed the folder), the client is marked read-only, and the cold path does not build the live agent at all. There is no session_ready, so the composer stays disabled.
    • New folder_missing frame (plus a folderMissing flag on session_created) carries the absolute missing path, and the dashboard replaces the composer with a notice naming it. The shared client package mirrors it as WhereverState.folderMissing.
    • A message sent anyway is refused out loud, naming the path, instead of being dropped in silence.
    • Read-only precedence is now explicit and lives in one predicate: a configured sessions.readOnly folder is hard and never lifted, a missing folder is hard and lifted only by restoring the folder and reloading, and a folder conflict remains the only dismissible one. "Continue anyway" can no longer lift a missing-folder lock.
    • Detection is load-time only, deliberately: a session that is already RESIDENT when its folder is found missing is locked read-only for anyone loading it, but its running agent is left alone.

    Older clients degrade safely: they simply do not render the notice, and the server's own refusal is what protects them. The restore ACTIONS (clone/create with progress) are separate, later work; until they land this state is locked with no in-app remedy, which is strictly better than an agent running against a directory that is not there. See the new CONTEXT.md sections and docs/adr/0009 / 0010.

  • 5214eb8: Creating a new session that CLONES an existing remote now runs through the path-keyed restore registry, with live progress instead of a blocking overlay, and the old synchronous clone is gone.

    Creating a session in a folder that does not exist could already clone the remote the provider probe found, but it did so synchronously behind the "Creating session..." overlay: no progress, no submodules, and a 25-second client watchdog that gave up while git was still running, which made a large repository effectively un-clonable. That clone is now the same job the restore panel drives, so there is ONE clone implementation left in the codebase.

    • Cloning here is now RECURSIVE (--recurse-submodules), which it was not before. A session created this way lands with its submodule content present rather than with empty submodule directories discovered later. Upstream tracking is still configured on the resulting clone, as it always was.
    • Real progress, no watchdog. The server answers session_new { cloneRemote: true } with restore_started and then the ordinary restore_progress / restore_complete frames; the dashboard shows the same honest progress display as the restore panel (phase, scope, a real percentage or an explicitly indeterminate bar, submodules counted separately) and disarms the create watchdog while the job is watched. A multi-minute clone no longer strands the UI.
    • The server continues into creation itself once the job is done. Unlike the restore panel, this path has nothing to reload: the session does not exist until the clone lands, so the flow completes itself rather than asking the user to retry.
    • A failed or cancelled clone fails the create with the registry's mapped cause (no key this host accepts, host missing from known_hosts, repository not found, network) on session_error, and leaves no half-made session. The clone stays cancellable while it runs, through the ordinary restore_cancel frame, because the job is server-owned and keyed by the target path.
    • POST /session/new takes the same branch for cloneRemote, gaining recursion; having no progress channel, it simply waits for the job.
    • The synchronous cloneRemoteRepo() helper is DELETED and SessionPool.createNewSession no longer takes cloneRemote: by the time it is called the folder is there. Creating a NEW remote repository (the other branch of the clone-or-create dialog), creating a session in a folder that already exists, and the plain create-the-folder path are all unchanged.
    • The shared client package carries RestoreInfo.forSessionCreate, which marks a restore that is the first step of a session create, so a client can tell "watch this and then hand back to the create" apart from the panel's "restore, then reload".
  • eecb694: Add the server-side restore engine: a path-keyed registry of restore jobs that materialise a missing working folder, either by cloning a remote or by creating an empty folder. It is a standalone module (server/src/restore-jobs.ts) with its own tests and is wired to nothing yet; the WebSocket protocol, the dashboard panel and the re-point of the existing create-session clone are separate work.

    • Jobs are keyed by the resolved absolute TARGET PATH, not by socket or session (docs/adr/0009), so a clone survives a dropped phone connection, is shared by two tabs, and is never started twice for one folder. A second request for a running path coalesces onto that job and is told the URL actually in flight, so a requester whose edited URL differs is never silently answered as though their own URL was accepted.
    • Clones are RECURSIVE (submodules) and report live progress. Git rewrites one progress line in place, so the parser splits on carriage returns as well as newlines; each frame carries a phase, a scope, and either a percentage or an explicit indeterminate marker, and submodules are scoped to themselves rather than folded into one fake global percentage. The previous synchronous helper did neither, and the create-session path's 25-second client watchdog is why asynchrony and progress matter.
    • Unsafe input is refused before anything is spawned: the target must resolve inside the home directory, the leaf must be absent or empty, and the URL must match a shape allowlist (scp-style SSH, ssh://, plus file:// for fixtures) rejecting control characters, newlines and a leading dash. The clone runs through an argv array with no shell, and never carries a relaxation of git's file-transport protection.
    • SSH only, with the missing credential NAMED rather than worked around (docs/adr/0010): interactive prompts are disabled (no terminal prompt, no askpass, batch-mode SSH with a connect timeout) so an unprovisioned box fails fast instead of hanging at zero percent, and git's well-known signatures are mapped onto actionable causes (no key this host accepts, host absent from known_hosts, repository not found stated as both wrong-URL and no-access, network rather than credentials) while git's raw stderr is kept underneath.
    • Cancel is authoritative: the child is signalled, the job settles as cancelled, and the target directory is removed only when the job created it.
    • A second, trivial job kind creates the folder (with an optional git init) so callers drive one state machine rather than two, and the module owns an explicit subscription seam (progress plus terminal outcome, with unsubscribe) for its two coming consumers: the client broadcast and a per-folder existence-cache invalidation.
  • 84fc5a9: Restore a session's missing working folder from the dashboard: tap Clone, watch it honestly, cancel it, survive a dropped connection, and come back to a live session.

    A folder-missing session now shows a RESTORE PANEL in place of its composer: the missing absolute path, an editable URL pre-filled from the server's remote candidates (GET /remote-candidates, so a repository can be restored from a phone without typing one), a Clone button, live progress, Cancel while it runs, and on success a Reload that brings the session back live. This is the user-facing half of the path-keyed restore registry, wired over the WebSocket.

    • New frames, all namespaced restore_* and all addressed by TARGET PATH (never by session id, because the job's key is the path). Client to server: restore_start { targetPath, action, url?, gitInit? } and restore_cancel { targetPath }. Server to client: restore_started (with outcome: 'started' | 'joined' and the url really in flight), restore_rejected (refused before anything was spawned: no job, no completion coming), restore_progress and restore_complete (which carries every terminal state -- done, failed or cancelled). folder_missing now also carries any job already running for the path.
    • Subscription is DERIVED, not registered: progress reaches every client whose attached session cwd, pending load target, or folder-missing path MATCHES the job path, evaluated per frame. A phone that drops mid-clone and reconnects repaints the RUNNING job from the folder_missing frame and keeps receiving live progress, with no second clone and no leaked bookkeeping. A second device sees the same job, and its Clone tap JOINS it and is told which URL is actually being cloned rather than being silently answered as though its edited URL had won.
    • A restore in flight now counts as folder-missing. git clone creates the target directory in its first breath, so the plain existence check said "present" for the whole of a mult...
Read more

@wherever-dev/pi@0.4.9

Choose a tag to compare

@github-actions github-actions released this 10 Sep 16:24
2ce8afa

Patch Changes

  • Updated dependencies [5214eb8]
  • Updated dependencies [84fc5a9]
    • @wherever-dev/client@0.6.0

@wherever-dev/client@0.6.0

Choose a tag to compare

@github-actions github-actions released this 10 Sep 16:24
2ce8afa

Minor Changes

  • 5214eb8: Creating a new session that CLONES an existing remote now runs through the path-keyed restore registry, with live progress instead of a blocking overlay, and the old synchronous clone is gone.

    Creating a session in a folder that does not exist could already clone the remote the provider probe found, but it did so synchronously behind the "Creating session..." overlay: no progress, no submodules, and a 25-second client watchdog that gave up while git was still running, which made a large repository effectively un-clonable. That clone is now the same job the restore panel drives, so there is ONE clone implementation left in the codebase.

    • Cloning here is now RECURSIVE (--recurse-submodules), which it was not before. A session created this way lands with its submodule content present rather than with empty submodule directories discovered later. Upstream tracking is still configured on the resulting clone, as it always was.
    • Real progress, no watchdog. The server answers session_new { cloneRemote: true } with restore_started and then the ordinary restore_progress / restore_complete frames; the dashboard shows the same honest progress display as the restore panel (phase, scope, a real percentage or an explicitly indeterminate bar, submodules counted separately) and disarms the create watchdog while the job is watched. A multi-minute clone no longer strands the UI.
    • The server continues into creation itself once the job is done. Unlike the restore panel, this path has nothing to reload: the session does not exist until the clone lands, so the flow completes itself rather than asking the user to retry.
    • A failed or cancelled clone fails the create with the registry's mapped cause (no key this host accepts, host missing from known_hosts, repository not found, network) on session_error, and leaves no half-made session. The clone stays cancellable while it runs, through the ordinary restore_cancel frame, because the job is server-owned and keyed by the target path.
    • POST /session/new takes the same branch for cloneRemote, gaining recursion; having no progress channel, it simply waits for the job.
    • The synchronous cloneRemoteRepo() helper is DELETED and SessionPool.createNewSession no longer takes cloneRemote: by the time it is called the folder is there. Creating a NEW remote repository (the other branch of the clone-or-create dialog), creating a session in a folder that already exists, and the plain create-the-folder path are all unchanged.
    • The shared client package carries RestoreInfo.forSessionCreate, which marks a restore that is the first step of a session create, so a client can tell "watch this and then hand back to the create" apart from the panel's "restore, then reload".
  • 84fc5a9: Restore a session's missing working folder from the dashboard: tap Clone, watch it honestly, cancel it, survive a dropped connection, and come back to a live session.

    A folder-missing session now shows a RESTORE PANEL in place of its composer: the missing absolute path, an editable URL pre-filled from the server's remote candidates (GET /remote-candidates, so a repository can be restored from a phone without typing one), a Clone button, live progress, Cancel while it runs, and on success a Reload that brings the session back live. This is the user-facing half of the path-keyed restore registry, wired over the WebSocket.

    • New frames, all namespaced restore_* and all addressed by TARGET PATH (never by session id, because the job's key is the path). Client to server: restore_start { targetPath, action, url?, gitInit? } and restore_cancel { targetPath }. Server to client: restore_started (with outcome: 'started' | 'joined' and the url really in flight), restore_rejected (refused before anything was spawned: no job, no completion coming), restore_progress and restore_complete (which carries every terminal state -- done, failed or cancelled). folder_missing now also carries any job already running for the path.
    • Subscription is DERIVED, not registered: progress reaches every client whose attached session cwd, pending load target, or folder-missing path MATCHES the job path, evaluated per frame. A phone that drops mid-clone and reconnects repaints the RUNNING job from the folder_missing frame and keeps receiving live progress, with no second clone and no leaked bookkeeping. A second device sees the same job, and its Clone tap JOINS it and is told which URL is actually being cloned rather than being silently answered as though its edited URL had won.
    • A restore in flight now counts as folder-missing. git clone creates the target directory in its first breath, so the plain existence check said "present" for the whole of a multi-minute clone and a client loading in that window was handed a live agent pointed at a HALF-CLONED tree. A session whose folder has a running restore job is locked read-only with no agent built, exactly like one whose folder is absent, and the send refusal says "still being restored" instead of "does not exist".
    • Honest progress, per story 8: the phase, the scope (the repository or a named submodule), and either a real percentage or an explicitly indeterminate bar -- never a fake unified number. Submodules are shown under their own scope with a standing note that each is counted separately.
    • Failures explain themselves: the mapped cause (no key this host accepts, host missing from known_hosts, repository not found, network) with git's raw output available underneath, and a refused request (a target outside the home directory, a non-empty folder, a URL outside the SSH/file:// allowlist) says why and leaves the panel offering to try again.
    • The shared client package carries the new frames as WhereverState.restore (RestoreInfo / RestoreJobInfo / RestoreProgressInfo / RestoreFailureInfo) plus startRestore(), cancelRestore() and reloadSession(). Older clients degrade safely: they do not render the panel, and the server's refusal to accept their sends is what protects them.

    Restoring ends in a RELOAD by design: whether a session has a live agent is a load-time decision, and the load that found the folder missing built none. See the new CONTEXT.md section and docs/adr/0009.

wherever-dev@0.13.0

Choose a tag to compare

@github-actions github-actions released this 08 Sep 05:55
45e8d72

Minor Changes

  • 70257b6: Make the server deployable from a READ-ONLY config directory, with no secret on the command line. Everything here is purely additive: with the new variables unset, every path and every behaviour is identical to before.

    • WHEREVER_STATE_DIR splits "where config is READ" from "where the server WRITES". It defaults to the config directory, so an existing install (or a test setting only WHEREVER_CONFIG_DIR) is unaffected and there is no migration. drafts.json and the auto-generated self-signed certs/ pair move under it. That is the complete list of things the server writes into the config directory — uploads are not among them, since uploads.subDir resolves against the session's own cwd, not the config dir.
    • The certs directory now honours the override. It was built from os.homedir() directly, so WHEREVER_CONFIG_DIR did not move it and an isolated server still wrote into the developer's real ~/.wherever.
    • WHEREVER_TOKEN / WHEREVER_TOKEN_FILE supply the auth token without putting it in argv, where ps shows it to every user on the machine. Precedence, highest first: --tokenWHEREVER_TOKENWHEREVER_TOKEN_FILE (a file whose contents are the token, the shape sops-nix and systemd LoadCredential= produce) → PI_REMOTE_TOKEN. The two new sources are whitespace-trimmed (a secret file ends with a newline); the two pre-existing ones are taken verbatim, and --token still wins whenever the flag is given even with an empty value, so no install can have the string that authenticates change under it. A WHEREVER_TOKEN_FILE that is set but missing/unreadable/empty is a fatal startup error, because falling through to "no token" would silently run an unauthenticated server that looks healthy.
    • WHEREVER_SSL_KEY / WHEREVER_SSL_CERT, each resolved independently, so a key from a secret manager and a certificate from ACME can live in completely different places, neither tied to a home directory. A leading ~ is expanded (a systemd Environment= line is not shell-expanded). Supplying only one half now warns instead of silently discarding it.
    • The startup banner reports the token's source, never the token. A token variable that is set but blank warns loudly (that is what a half-rendered secret looks like), as does binding a non-loopback address with no token at all.
    • The token is deleted from the server's own environment once resolved, so the children it spawns — the agent's bash tool and the memonaut indexer, both of which inherit process.env — cannot read it. Previously !env in the dashboard printed the server's auth token into a stored transcript.
    • Explicitly-configured TLS that fails to load is now fatal instead of falling back to plaintext HTTP. This is the one deliberate behaviour change: the old code caught the error, logged it, and served unencrypted on the same (often 0.0.0.0) address, so clients sent their token in cleartext to a server that looked healthy — the same fail-open the token-file rule exists to prevent, and the exact shape of a secret that has not decrypted yet. The silent HTTP fallback survives for the self-signed pair the server mints for itself; use --no-ssl to ask for plaintext on purpose.
    • Nix packaging: package.nix (a plain function of pkgs, the interface a deployment repo imports with its own nixpkgs pin) plus a flake.nix providing nix develop (the exact node/pnpm toolchain, replacing a hardcoded version-manager path) and nix build. nix/check-pnpm-deps-hash.sh fails loudly when pnpmDepsHash goes stale, which a plain build cannot detect.
    • web/svelte.config.js honours WHEREVER_BUILD_VERSION, so a build with no .git reports a real build id instead of a timestamp.

    Also fixed while here: the Nix build never built the @wherever-dev/client workspace package, so it only succeeded on a tree where client/dist happened to exist from an earlier manual build and would have failed from a clean checkout.

    See docs/deployment-nixos.md, and ADRs 0006 / 0007 / 0008.

wherever-dev@0.12.0

Choose a tag to compare

@github-actions github-actions released this 03 Sep 20:24
48ef86a

Minor Changes

  • c805329: Add saved drafts: keep a message instead of sending it, then load it back later from any device.

    The composer footer gains "💾 Save draft" (keeps the typed message and clears the box, the way a send would) and "🗂 Drafts (N)", which opens a list of saved drafts, newest first, with a one-line preview, when it was saved and the folder it was written in. Tapping one loads it into the message box; 🗑 deletes it.

    Drafts live on the server, not in the browser: ~/.wherever/drafts.json, behind the same token gate as the other API routes, via new GET /drafts, POST /drafts and POST /drafts/delete endpoints. A draft saved on a phone is therefore there on the laptop, and survives clearing site data or restarting the server. The server is the only writer (it owns ids, dedupe, cap and ordering, and answers every mutation with the whole new list); the browser keeps a mirror only so the list still renders while disconnected. Saving fails loudly rather than silently, and the message stays in the box.

    Drafts work in every composer mode, including the no-session home page, which is exactly where you want to pull up something written yesterday and fire it into a new session. They are global rather than per session, for the same reason.

    Loading a draft never destroys unsent text: if the box is not empty the list warns first and offers Replace, Append below (keeps both, separated by a blank line) or Cancel. Loading does not delete the draft, but sending it does (mail-client semantics): a mistap must not lose the text, while a message that has actually been sent is no longer a draft. The draft is only consumed when the sent message is still exactly it, optionally below text you typed; edit it further and the draft is kept.

    The store fails safe throughout: over-long input is rejected rather than truncated, an unreadable or corrupt drafts.json is reported instead of being treated as "you have no drafts" (so a save can never overwrite a file it could not read), writes are atomic and 0600, and the composer is only cleared once the server confirms it has the text.

    This is separate from the existing per-session auto-draft, which remains client-side crash protection for the text currently in the box.

wherever-dev@0.11.2

Choose a tag to compare

@github-actions github-actions released this 23 Aug 07:48
b42a209

Patch Changes

  • a5723a8: Stop the test harness leaking a server process per test file. startHarness() spawned the server as pnpm exec tsx ... and tore it down with child.kill('SIGTERM'), but that signal reached only pnpm: the real server sat two levels down (pnpm -> tsx/cli.mjs -> node), so it survived teardown and was reparented to init. A full suite run therefore left ~50 MB of orphaned server behind per test file. Run inside the memory-capped wherever systemd service, this filled the cgroup with 141 leaked processes holding ~5.5 GB, pinning it at 98% memory pressure and 8.3 GB of swap: the service never OOM-killed (so Restart=on-failure never fired) and instead livelocked in permanent reclaim, presenting as a hang indistinguishable from a crash.

    The harness now invokes the tsx binary directly (no signal-swallowing intermediary), spawns it detached so it leads its own process group, and cleanup() signals the entire group and awaits the actual exit, escalating SIGTERM -> SIGKILL if the process is wedged. The group kill matters beyond the removed pnpm layer, since tsx itself spawns an inner node child that a bare child.kill() would strand. A process.on('exit') backstop reaps any still-live server, covering the abnormal path where a test throws or the runner kills the worker and cleanup() never runs.

  • 8d458d3: Fix "new conversation here" landing you back in an existing conversation.

    Asking for a new session in a folder that already had a live viewer did not create anything: the server attached you read-only to the session already running there and raised the "another client is active in this folder" banner. The conversation you were handed was often the one you were already reading (any second tab, or your own socket that dropped silently and has not been reaped yet, counts as another viewer), and "Continue anyway" could only unlock that old conversation, never give you the new one you asked for.

    session_new now always creates a new session (SessionPool.createNewSession(..., forceNew) bypasses the reuse-an-occupied-folder shortcut). When the folder really is shared, the folder-conflict banner is raised on the NEW conversation, which starts read-only until "Continue anyway" - so the warning stays, but it now sits on the conversation you asked for. POST /session/new keeps its old reuse behaviour.

    Also stops a second viewer of the SAME conversation being counted as a folder conflict, which could pin the banner (and its read-only) on with nothing left to resolve it.