2026-09-03 19:10 · feat(#80): the gateway port is configurable from Settings, landing a community PR (#82) that had waited three weeks and gone stale. The branch was written against apps/desktop/src-tauri/src/gateway.rs; the gateway has since moved into crates/osd-core so the desktop and osd server share one implementation, so the Rust half was re-applied there rather than sent back to the contributor. The collision worth recording: the extracted gateway already has a port field, but it is an ADDRESS RECORD — start writes where the listener landed, stop erases it, and osd reads it to find a gateway already running on this machine. Keeping the user's setting in that field would have let an ordinary stop erase the setting, and let a recorded 4098 read back as a pin, turning "4098 when it is free" into "4098 or refuse to start" the first time anything else took the port. The preference is now its own field, preferred_port, which the lifecycle never writes; set_gateway_config carries it, start_at binds it exactly (a pin exists because a firewall rule or reverse proxy names that port, so a quiet fallback would break them while Settings still showed the port asked for), and autostart honours it on relaunch — staying down and visibly not-running if the port is taken, rather than listening somewhere nobody is pointed. osd server is unchanged: --port still wins, and without it the CLI keeps preferring 4098 then anything free. Two defects fixed in the contributed code: the port field's useState sat after the isTauri early return, a conditionally-called hook that eslint rejects; and two of its tests both bound 4098 in one test binary, which cargo runs in parallel, so one failed on every run (verified with an equivalent minimal case — three runs, three failures) and both are replaced with ephemeral-port tests. Coverage: the pin outliving the address record, a record never reading back as a pin, an occupied pin erroring with the port named, and four UI cases including a mode change carrying the pin through instead of silently clearing it. 1187 desktop tests (4 new), 186 osd-core (3 new), tsc/eslint/clippy clean.
2026-09-01 03:00 · fix: a session filed apart from its own files, and the empty collections planted in every folder opened. Reported as "click beautiful_figure.ipynb → file not found", with sessions/ and projects/ showing empty. Neither was a recent regression, and the newest feature was cleared by evidence rather than by reading: the broken session was created 08-28 23:27, two days before #124 landed, and conversation sync has never run on that machine — ai4s.sync.dir.v1 is absent from the app's localStorage, and scheduleConversationSync returns on its first line without a folder. Nor was it systemic: 22 of 23 recent sessions are correctly homed in their own dated folders. The empty collections trace to 6ea8271 (07-29), which made workspace_dir return ensure_base_layout(dir): the base layout is the BASE folder's two collections, and applying it to the ACTIVE folder wrote empty projects/ and sessions/ into every folder the user opened — including their own git checkouts — where the pair it planted was always empty while the real collections were elsewhere, so the app looked like it had lost their history. workspace_dir now returns the folder as it is. The mismatch itself is a real, silent state — session directory = a git checkout, its notebook and figure on disk in sessions/2026-08-28-2327 — and two paths could reach it undetected, both now closed. openSession swallowed a failed setWorkspace (.catch(() => {})) and then ran everything on the folder it had failed to leave: reconnect, kernel, markSession stamping this session's id into the wrong folder, and every file resolving there; it now reports the folder and the reason and does none of it. And session creation checked only that the runtime was "ready", never WHICH folder it had landed in, so a reconnect that came back on the previous folder would file the conversation there while its files were written elsewhere; it now compares the folder newDatedWorkspace/setWorkspace returned against the one the runtime is actually on and refuses instead. The store test's workspacePath was a stub answering one fixed folder — it could not tell a reconnect that landed right from one that did not — and now reads back what the two setters wrote, which is the contract the Rust side keeps. Repairing the reported session then found a FOURTH instance of the same bug, in shipped code: opencode import files a session in the importing process's working directory and ignores the info.directory the export records — measured, by importing an export that named one folder from another and watching the row come back naming the second. session_sync.rs set the CLI's env and never its directory, so with sync enabled every conversation arriving from another machine would have been filed wherever the app was launched from (/, for a Finder-launched .app) rather than beside its own files — the same defect this whole entry is about, one release from reaching users who turn sync on. The directory is now chosen: the folder the export recorded when this machine has it (the shared cloud workspace of #123, which is the case the feature exists for), else the base folder — and held to the same rule every other caller-supplied session directory here already is (gateway::session_dir, fs_base): inside the base workspace or a registered project, since that path arrives in a file over a cloud drive and decides the folder the imported conversation, and the agent that later runs in it, works in. Building the command is split from running it, for the same reason cli_dirs already was — the first version of the test asserted the chooser alone and still passed with the call site unwired, which is precisely the defect (right in principle, inherited in practice), so it now asserts the command's own current_dir. All four fixes mutation-checked: each new test fails with its fix reverted. NOT fixed, and not guessed at: what re-homed that one session, which is unreproduced (1 in 23) and left the app closed mid-investigation — the guard above turns the next occurrence into a refusal instead of a silent wrong folder. 1183 desktop tests (3 new), 183 osd-core (2 new), tsc/eslint/clippy clean.
Repaired on the reporter's machine, using the runtime's own commands rather than SQL: the orphaned session exported (4.8 MB, 12 messages) and re-imported from the folder holding its files, which re-homed it to global + sessions/2026-08-28-2327, matching every other dated-folder session; 12 messages / 53 parts / title intact, integrity_check ok, 223 sessions. The control-plane move endpoint refuses this — "Destination directory belongs to another project" — so import-by-directory is the supported route. Also removed the 31 folders the ensure_base_layout defect had littered with an empty projects/+sessions/ pair (every session folder, every project folder, and the user's own git checkout), with rmdir so anything holding content could not be touched; the base folder's real collections (4 projects, 25 sessions) are untouched.
2026-08-30 23:45 · verify(#124): sync exercised against the built app on a real machine, not just in fixtures. Built the DMG, installed it to /Applications, launched it, and drove the mechanism the Tauri commands invoke while the app was running and holding its database open — which is the concurrency case the whole design depends on. A live session exported to 4.8 MB / 12 messages / 53 parts; a fresh second store imported it and matched exactly; re-importing into the LIVE store left session and message counts unchanged (220 and 12), and PRAGMA integrity_check came back ok with the app still alive. Two exports of that real session either side of the concurrent activity normalised to identical bytes and the same FNV fingerprint, so the ping-pong fix holds on a 4.8 MB conversation and not only on the synthetic pair in the unit test. Also confirmed the shipped artifact carries the work: the release DMG contains Resources/history-plugin/history-guard.ts byte-identical to source, and all four sync commands are in the binary. The camelCase-to-snake_case argument mapping I could not exercise without clicking has a shipped precedent — provider_auth_exists(provider_id) is invoked as {providerId} — so that path is proven by production rather than assumed. One test was rewritten after checking whether it actually asserted: the XDG-parity test gated on runtime_cli, which bails when no sidecar sits beside the test binary, so it would have passed vacuously on any CI job that skips the sidecar fetch; the dirs are now built by a separate cli_dirs and asserted unconditionally. Still NOT verified: the UI path itself (choose folder → automatic pass → readout), which needs clicks in a desktop window. 1181 desktop tests, 181 osd-core.
2026-08-30 21:35 · fix(#124): three UI defects, found by rendering the sync card rather than reading it. (1) A cloud path ran straight through the card's right edge at phone width — long, and with no spaces to wrap at, which is exactly what a cloud folder's path is; it wraps inside the card now (break-all, mono, 11px). (2) The card's own description said "off until you choose a folder" while a folder WAS chosen — the one sentence telling the user whether the feature runs, and it was wrong in the state that matters. It follows the state now and names what does not sync. (3) A background feature with no feedback: sync ran silently after every turn, so a user who switched it on had no way to tell it worked, and a mirror folder that had gone away would fail every pass unnoticed. Each pass now records its outcome and the card reads "Last sync — N in, N out", or the failure in red. Verified in all three themes at 720px and 340px, in English and Chinese, in the off, configured and failed states. One harness lesson worth keeping: the first attempt rendered both card states on one page and they came out identical — they share a single localStorage key, so two instances can never disagree; one state per page load. 1181 desktop tests, 180 osd-core, 7 locales (3 new keys each), tsc, eslint and both builds clean.
2026-08-30 21:05 · fix(#124): three defects found reviewing the sync feature before it ships. (1) The post-export re-listing marked EVERY session this machine had ever exported as reconciled at the file's current timestamp — including a file whose import had failed seconds earlier in the same pass. The error was reported, the file was recorded as done, and that update would never have been retried; it would also have swallowed anything the other machine wrote between the two listings. Only sessions actually written in this pass are marked now. (2) scheduleConversationSync sat at the END of onTurnIdle, behind two early returns that are both ordinary paths — with auto-review on, the normal turn takes one of them, so sync would never have run at all for those users. Moved above both, where a turn settling is the whole trigger. (3) The import command took an arbitrary path from the frontend, against this codebase's own convention of scoping paths (resolve_under). It now takes the mirror directory plus a session id and builds the name from an id that has been validated, which is both narrower and simpler than passing a path — nothing outside the chosen folder is reachable, whatever the caller passes. All three were found by reading, then confirmed against the code paths rather than assumed. 1181 desktop tests and 180 osd-core (1 new: import refuses a traversal), tsc, eslint and both builds clean.
2026-08-30 20:35 · feat(#124): conversations follow you between your own machines, one JSON per session. Raised by #123, whose author put the workspace root on a cloud drive and found only files had moved. Conversations are not in the workspace — they are in the runtime's SQLite store per machine — and syncing THAT as a file is not a workaround: WAL-mode SQLite under file-level sync is overwritten, not merged. So the unit of syncing is one session, not one database: each becomes <dir>/<id>.json, which a cloud client can actually handle, and a conflict costs one conversation instead of all of them. Built on the runtime's own export/import rather than a format of ours, after measuring three properties against the pinned 1.18.18: import PRESERVES the session id, is idempotent, and UNIONS by message id — so two machines that each added messages to one session converge with no merge algorithm on our side. Verified end to end with two independent stores and a folder between them. Testing then found the defect that would have made this unusable: a NO-OP import still bumps time_updated, and updated is what marks a session for export, so each machine would rewrite the mirror at the other one forever — permanent cloud churn. A diff of two exports either side of a no-op import shows exactly one differing field, info.time.updated, so the export now fingerprints the conversation with that field excluded and leaves the file untouched when it matches; an unchanged conversation never moves, so the other machine is never told it did. Import runs before export in a pass, which is what publishes the merged version rather than overwriting the other side's half. Also hardened: session ids are validated before reaching a command line, mirror writes are atomic (a cloud client uploads a partial file the moment it appears), an empty export is refused rather than replacing a good mirror with nothing, and a missing mirror folder is empty rather than an error. Desktop only and off until a folder is chosen — the web client has neither a local folder nor a runtime to import into, so the card is hidden there rather than offered and broken. Secrets never sync. 1181 desktop tests (17 new) and 179 osd-core (8 new), 7 locales, tsc, eslint and both builds clean. Not yet released.
2026-08-29 00:25 · fix(#122): two rules where an unsupported CSS feature took down more than itself. The reporter's screenshot is the whole diagnosis: "正在处理…" is legible ONLY while text-selected, and the "10s" beside it renders normally — one label with transparent glyphs, not a broken theme. .shimmer-text painted the status label by making it color: transparent and letting a decorative gradient fill the glyphs through background-clip: text; if that gradient does not paint, the text is present, selectable and invisible. color-mix() inside the gradient is enough to do it — an engine that does not know the function drops the whole linear-gradient as invalid — and background-clip: text would do it too. Legibility now comes first: a plain var(--link) on the base rule, the transparent-plus-gradient treatment only inside @supports, and the reduced-motion override moved after it so it still wins. The companion defect is the hover row: [data-hovered] [data-hover-row], [data-hover-row]:has(:focus-visible) is one selector LIST, and a list is discarded entirely when any part of it fails to parse — so on an engine without :has() the message's Copy/Edit/Revert controls are unreachable by hover as well as by focus. Split into two rules. Version-checked rather than assumed: :has()/hover-row landed in v0.4.2 and shimmer-text in v0.5.1, so a reporter coming from 0.4.0 meets both in one jump — and note the buttons were ALWAYS visible before v0.4.2, so part of what they describe is the hover-reveal design change, not a fault; asked them to confirm whether hovering reveals the row. Verified by building the same stylesheet twice, once with the @supports condition forced false, which is what an unsupporting engine actually applies: the label renders solid --link in all three themes instead of vanishing. The first attempt at that simulation only stripped the gradient and left the transparent fill, which reproduced the BUG rather than the fallback — a check that would have passed a broken fix. 1166 desktop tests, tsc, eslint and the build clean. #121 triaged, not ours: its CSV shows 193 DISTINCT assistant message ids behind 381 identical tool completions, so the reporter's replayed-tool-result theory does not hold — the model genuinely took 193 turns emitting zero text and zero reasoning, and the useful guard is their other suggestion, a cap on consecutive tool executions, which neither we nor the profile currently set.
2026-08-28 03:30 · release: 0.5.1 published, and the two things checking it turned up. Reviewing before release caught a real defect in the previous commit: a session whose history is malformed fails every later turn identically, so it reopens with a run of the same error — the reporter's screenshot shows three — and a repair card was appended after each one. Three identical cards offering the same rollback read as three separate problems; rendered once now, under the last failure, with the live path dropping any earlier offer rather than stacking. Found by probing historyToThread with a three-failure history, not by reading. Also removed two speculative rules I had just added to the guard (tool-part metadata, key-level pruning): providerMeta already strips the one key the runtime puts in tool metadata, nothing in this app writes it, and all 14,060 tool parts in the local corpus are well-formed — guarding an empty set. Verified the shipped artifact rather than reasoning by analogy: downloaded the built .app and confirmed Resources/history-plugin/history-guard.ts is present and byte-identical to the tag. The frontend could NOT be verified the same way and is not claimed to be — a grep of the binary finds no UI strings at all, including ones that have shipped for months, because Tauri embeds the assets compressed; the negative is uninformative, not evidence. Publishing also surfaced two process defects. The draft body carried only the standing install notes and a <!-- prepend the changelog --> placeholder, so publishing as-is would have shipped a release that never says what changed; notes written in the 0.5.0 voice, from the user's side of the bug. And PATCHing a draft's body without tag_name makes GitHub reset the tag to untagged-<sha>, which 404'd the publish call — assets, body and target commit all survived, and passing tag_name back alongside draft=false fixed it in one call. Post-release: Zenodo minted 22136307 for v0.5.1 while CITATION.cff and the README BibTeX still carried 22004919, which is v0.5.0's — the version had been bumped before tagging but the DOI had not. Both corrected. CI's Verify bundled resources list covered neither guard plugin (browser-guard was missing too, pre-existing); both added, and checked that removing the file now fails the step, since a guard that is not bundled fails silently.
2026-08-27 02:00 · fix(#114): the bug was ours all along — a background review silently ended the conversation it had just reviewed. The reporter supplied the pattern that cracked it: new conversation, let it sit idle, a "Review · N findings" appears, and the NEXT message fails forever. Review is our feature (#72), so this was never the upstream defect we spent two rounds blaming. finishAutoReview persists the review through appendTextPart onto the last ASSISTANT message (runtime.ts:1502, checkpoint chosen at 1546-1553), and that part carried metadata: { source: "ai4s.background-review" }. OpenCode forwards an assistant text part's metadata to the model as providerMetadata, whose schema is Record<string, Record<string, JSONValue>> — a string one level up is invalid, so the AI SDK rejected the ENTIRE conversation before dispatch, on that turn and every turn after, because the part is on disk. REPRODUCED both ways against the pinned 1.18.18 with a stub provider: PATCH the app's exact body onto a clean assistant message and the very next turn returns the reported string verbatim; send the same body namespaced two levels and the turn completes. It fires only while the session stays on the model that produced the checkpoint (message-v2.ts:245,283 drop the metadata on a model switch), which is why the reporter saw it intermittently and why switching model is a workaround. Auto-review itself is not at fault and is opt-in as designed — off since #72 introduced it, the Settings switch its only writer — though its own hint promises "the conversation stays usable", which is exactly what this broke. The fix is one line of shape — { ai4s: { source: "background-review" } }, the META_NS convention the session metadata already used and this one write had skipped — pinned by a test asserting every metadata value is an object, since the failure mode is entirely in the nesting. Two earlier public claims of ours are retracted in the process: an interrupted tool call is not the cause (the runtime backfills it) and a runtime bump is not the fix (message-v2.ts is identical through 1.18.23). Shipped alongside, because sessions already damaged by released versions cannot be un-damaged: runtime/history-plugin/history-guard.ts, a third plugin beside goal and browser-guard, repairing the message array in place inside the one window OpenCode gives us — experimental.chat.messages.transform fires on the very array handed to toModelMessages a line later. It writes nothing to storage, and it was verified to revive the poisoned session from the repro: same damaged part on disk, turn completes. It also covers the general shape (a part missing text, a tool result missing output), which is how it was found to cover this one. UI belt-and-braces for damage in a shape neither side recognizes: the error text no longer guesses a cause, and a history-repair block names the offending part and offers a rollback reusing the existing Revert path, saying first how many messages it discards; reoffered on reload, and honest when it recognizes nothing. Checked visually in three themes at 760px and 390px. 1163 desktop tests (44 new) and 171 osd-core (2 new), 7 locales, tsc, eslint, clippy and the build clean.
2026-08-24 09:40 · fix: every app launch strobed the page, and the app's own debug log said why. Four boots in debug.log all read connecting → error → connecting → error → ready at 250 ms intervals: startRuntime hands back a URL the moment the sidecar is spawned, so the first two or three connect() attempts fail against a port nobody is listening on yet. The SDK's setStatus("error") went straight through the store's status listener and the store's own catch published {status:"error", error} — which is the offline "run opencode serve" card, the red error banner and an error-toned status badge appearing and vanishing several times a second, in the header AND the content area, exactly as reported. connectRetry's comment already claimed failed attempts were masked; they were only masked BETWEEN attempts (set({status:"connecting", error:null}) after the retry bookkeeping), which is the wrong side of the flip. Masking now covers the whole window: a connectRetryDepth counter holds every non-ready status at "connecting" for as long as a loop runs, failures report through lastConnectError instead of the store, and only the loop's verdict (ready, gave-up-on-exits, window exhausted) is published. The store's initial status was the same bug one frame earlier — it started at "offline", so the "no runtime" card flashed before bootstrap's first await; it now starts at "connecting" wherever there is something to dial (isTauri || isGatewayWeb), and a failed startRuntime moves it to "error" so a real failure is not dressed as a launch. Since masking means a long start says nothing at all, a slow start (>6 s — a fresh install waiting on the macOS folder-permission prompt) gets one stable card explaining the wait, and the composer says "Starting the runtime…" instead of "Connect to chat", which asked for something the app was already doing; the card's body has a web-client variant because a phone on the gateway is not waiting on a local TCC dialog. Verified in the shipped bundle, not just in tests: the rebuilt DMG's app logs status → error (held as connecting) and the UI goes connecting → ready once, ~740 ms, no intermediate paint. 1118 desktop tests (4 new: no "error" and no error text may surface during a retry window; the three launch-state renders), 7 locales, tsc and eslint clean, tauri build --bundles dmg green.
2026-08-24 08:35 · fix(#119, cont.2): hiding a settings section from the web sidebar never closed its route. visibleSections(isGatewayWeb) drops the six desktopOnly sections from the navigation, but /settings/connectors typed or shared as a link still resolved and rendered the MCP card — whose "Add" is the same config write the gateway refuses, so it was the identical dead end this issue was about, one URL away. Some section bodies happened to be isTauri-gated internally and some were not, which is exactly the kind of per-card patchwork that leaves the next one exposed; the check now lives once, next to visibleSections (isDesktopOnlySection), and the page answers any hidden section with its own title plus one line naming where it lives. Placed after every hook so the hook order cannot change with the section. The companion test asserts the supported sections are NOT answered that way — the guard against over-blocking, which is the real risk with a single choke point. Mutation-checked: forcing the guard false fails precisely the connectors test and leaves the other three green. 1114 desktop tests (2 new), 7 locales, tsc and eslint clean.
2026-08-24 04:45 · fix(#119, cont.): a headless install could not register a custom endpoint at all, and osd auth set said it had. Follow-up to the entry below, which pointed web users at the desktop app — on a headless box (osd server IS the workbench, no window) there is no desktop app, so the note was a dead end and the claim needed checking rather than asserting. Probed the pinned opencode 1.18.18 directly with four config shapes: a known catalog id (anthropic, openai) carrying only options LOADS and takes its models from models.dev, so osd auth set anthropic --key K was always fine; an id the catalog does not know loads only when the entry lists models (npm alone → ABSENT from /config/providers, models alone → loaded). osd auth set can write only options.{apiKey,baseURL} — which is precisely the dead shape for a custom endpoint — and then printed "Saved the kny key for this machine." So the reporter's exact case (kny, an OpenAI-compatible gateway) was unreachable headlessly, and silently so. merge_config now takes a ProviderCredentials struct (seven positional string params was the alternative) and writes name/npm/models when models are given, merging into any existing map so adding one model does not retire the others and a probed limit survives; no limit is invented for a new one (#52). New --models id,id and --npm PKG on osd auth set (default @ai-sdk/openai-compatible, matching the desktop form), and --base-url without --models now says the entry will be ignored unless the id is a known provider — the one combination that writes something the runtime drops. A catalog id still gets no models key: writing an empty one would hide all 48 of openai's. Verified end to end, not just unit-tested: ran the built osd auth set both ways into a throwaway HOME, fed the config it produced to the real runtime, and read /config/providers back — the old shape ABSENT, the new one loaded with its model. The web note now names both places a key can be set. One defect caught in review before it shipped: defaulting npm on every call would silently flip an Anthropic-compatible endpoint to the OpenAI SDK the next time a model was added to it — only an explicit --npm replaces it now, checked by re-running the built binary without the flag. 1112 desktop tests, 169 osd-core (2 new), workspace builds, clippy unchanged at 17 pre-existing warnings.
2026-08-24 01:52 · fix(#119): the web client stops offering provider writes the gateway refuses, and a refusal now says why. The reporter added a custom endpoint from the gateway-served browser client and got "Failed to add the provider (403)"; they concluded their API key was wrong and filed an issue. It never reached their provider. addCustomProvider PATCHes /global/config with a provider block, and gateway.rs refuses every config write but the benign {model} one — by design, since keys must never cross the wire. The Providers card was rendered whole in web mode anyway (only its "Fetch models" and "Import login" buttons carried an isTauri gate), so the connect-a-provider key form, the custom-endpoint form and every Remove button were controls that 403 on submit — exactly what AGENTS.md says to hide rather than ship. In web mode the card now shows the connected list (reads are proxied with keys redacted) plus one line saying where to change it, and its subtitle stops advertising writes; gated on isGatewayWeb, not isTauri, because pnpm dev in a plain browser talks to OpenCode directly and those writes do work there. Second defect, the one that made this unreportable: apiError read only data.message/message, and the gateway states its reason as {error} — so "provider/model config is managed on the desktop" was dropped on the floor and every gateway policy block anywhere in the SDK surfaced as a bare status code. Adjacent and left alone: /settings/connectors typed by hand still renders the MCP card in web mode (its writes 403 the same way); the sidebar hides the section, so nothing links there. 1112 tests (3 new), tsc and eslint clean; the web-mode card screenshotted from its real rendered markup in both the populated and empty states — the empty one had the note's border-t doubling the card's own top border, now conditional on there being rows above it.
2026-08-20 23:05 · feat(thread): a running subagent is now reachable from the row that spawned it, and a failed step says why on its own line. A task row was the one row in an activity group that did nothing when clicked while it ran — there was no settled detail to unfold, and the subagent's actual transcript lives in a panel the reader had to find themselves; only after it finished did a click do anything, and then it unfolded the result in place. The row (and the live pulse under it) now opens the subagents panel with that subagent already expanded and scrolled to, carried as a {childSessionId, nonce} ask whose counter makes a repeat click distinct — otherwise re-asking for one the reader had collapsed was a no-op. A finished row keeps unfolding in place; nothing about it changed. Separately, why a step FAILED sat behind two folds (the group, then the row), so the first line of the error now rides the row in red with the rest on hover. Probing the rendered DOM to check the panel affordance turned up a defect no test could see: the running row's status dot measured 0×0. RunningDot sizes itself in px, which an inline box ignores — as a flex child (the group header, the subagent pulse, the panel) it was blockified and showed, but wrapped in the tool row's status span it collapsed, so for as long as the row existed the one step that was actually running was the one step with no mark at all. On the second question — why only thinking streams inline — the answer is the runtime, and it is not fixable here: in the pinned opencode 1.18.18, reasoning-delta calls updatePartDelta (which is where our reasoning.updated comes from) while tool-input-start/delta/end only call ensureToolCall, which creates the part once with state:{status:"pending",input:{},raw:""} and returns the existing one untouched thereafter. The file's content is published in a single event when the whole argument blob has parsed, so a write has no token stream to render — read from the shipped binary, not assumed. 1109 desktop tests (5 new; four mutations checked one at a time, each killing exactly its own test).
2026-08-20 22:30 · feat(thread): a streaming thought now types itself into its row. While a turn ran, the only motion in the thread was one spinner over "Working…" — the reasoning that WAS arriving, token by token, sat behind a fold or off screen, so a model thinking for two minutes looked identical to a hung one. Borrowed the shape from deepseek-harness (ReasoningRow + use-throttled-visual-update): the collapsed row renders the line being written right now — the tail after the last newline — in a nowrap box whose scrollLeft is pinned to the end, so the text slides left as it grows and the newest characters stay in view, with a caret at the write head and a faint band sweeping the row. Expanding it (a click, at any time, not only when done) follows the same stream to the bottom of the panel, and stops following if the reader scrolls up. The alignment is coalesced onto every third animation frame: reading scrollWidth on each of ~30 tokens a second is a forced layout per token. A settled thought keeps its opening line as the summary, head-anchored with an ellipsis, which also replaces the bare "Thought" label that said nothing about what was thought. The auto-expand-while-streaming behaviour is gone with it — the row itself now carries the stream, so unfolding a paragraph over the thread to prove liveness is no longer the price. The same one-line follow now drives the subagent activity pulse. The turn's status row went the same way: the spinner beside "Working…" is gone and the label itself carries the motion — a light travels left-to-right THROUGH its letters (a 250%-wide gradient clipped to the glyphs, its position animated), in the theme's blue, with the turn's own clock beside it counting from 1s. The spinner survives only where nothing is streaming for a shimmer to stand for: waiting on the user, and retrying a failed provider call. The running step's duplicate clock left that row — the step's own row in the thread already times it, and two clocks side by side timed different subjects. The turn clock anchors when the pane first sees the turn running, so a reload mid-turn counts from there rather than inventing a boundary it cannot know. 363 component tests, tsc and eslint clean; verified against a simulated stream in a throwaway vite page (collapsed tail, expanded follow, settled summary; shimmer motion across frames and the clock ticking, in both light and dark).
2026-08-20 20:10 · fix(#118): the config file gets write discipline, and an unreadable one no longer bricks the app. The reporter's v0.5.0 loops forever reconnecting with the port changing, while v0.4.2 works on the first try — measured against the pinned runtime, the mechanism is not in doubt: a config that is invalid JSON makes opencode 1.18.18 EXIT at boot (Config file … is not valid JSON(C)), as does a schema-invalid value (apiKey:null), while a BOM, a trailing comma, an unknown key, .json and .jsonc side by side, and every shape our own custom-provider writer produces (SCNET baseURL, a key ending in =, a slashed model id, context:0) all start fine. Two writers own that file with no coordination — the app from Rust and the SIDECAR itself, confirmed by driving PATCH /global/config and watching it rewrite the same opencode.json, which is exactly what "add a custom provider" does — and our side used fs::write, i.e. truncate-then-write, ~10 times per boot plus once while the old sidecar is still alive. The 0.4.2/0.5.0 difference is one line: as_object's unwrap_or_else(|_| json!({})) silently REPLACED any config it could not parse, and plugin registration re-writes on every launch, so 0.4.2 always started — at the cost of the user's providers, MCP servers and approval mode (#116's data loss). It follows that the reporter's file is invalid JSON rather than merely schema-invalid, and that his provider settings did not survive the rollback that "fixed" it. Fixed four ways: every config write is now a temp file plus a rename (a reader sees the old file or the new one, never half of either); a config neither side can read is renamed to <name>.broken-<ms>, replaced by an empty one the startup sequence re-seeds, and reported to the user with the path — both names are checked, since the runtime merges .json and .jsonc; the dying sidecar's last stderr line is kept, stripped of its colour codes, so the failure can be named instead of "could not open the event stream"; and the reconnect loop gives up after three EXITS instead of spawning ~120 doomed processes and changing the port each round — exits, not failed connects, so a first boot behind macOS TCC still gets its minutes. Clippy and tsc clean on everything touched. Then tested against the real thing rather than only unit-mocked: two end-to-end tests drive the bundled 1.18.18 in an isolated profile — a truncated config is quarantined and the sidecar then SERVES (remove the quarantine and the same test says "never served on http://127.0.0.1:…", i.e. the reported bug is now reproducible on demand), and a schema-invalid config makes the real runtime refuse, which must be reported rather than papered over. That second test found a defect in my own fix: the runtime prints a HEADLINE plus an indented detail line, so "keep the last stderr line" yielded ↳ Expected string | undefined, got 123 model — naming neither the config nor the file. It now keeps a bounded tail and reports from the last headline down. A new guard also checks every invoke("name") against Rust's generate_handler! list, because both new bridges are optional-by-design (.catch(() => null)) and a misspelled command would have shipped as a feature that quietly never fires — writing it immediately exposed that my own parser dropped the list's last entry. Nine mutations checked ONE AT A TIME; the first pass had the atomic write passing under fs::write (reading the content back cannot separate a rename from a truncation — an inode comparison can) and two frontend cases cascading through 147 unrelated tests because a spy leaked past a failed assertion. 167 Rust tests, 1103 frontend.
2026-08-19 09:30 · fix(#117): a quota error now says whose quota. All three messages the reporter quoted come from the runtime, not from us — "Free usage exceeded, subscribe to Go" is OpenCode Zen's FreeUsageLimitError branch (its own GO_UPSELL_MESSAGE, pointing at opencode.ai/go), so the free allowance of the built-in provider was spent; his title read "the model quota shows that it has been used up", which is exactly what a message naming no provider and no product invites. What was ours: the runtime puts an action on the retry status (reason, provider, an actionable sentence, a link) and the client kept only the message, so a spent allowance and a provider hiccup were the same event to the app — the pane called both "retrying (attempt N)". The action now survives, is the source the ending error line names its provider from, and labels a recognised account-state cause "waiting will not help" in all seven locales. Reviewing the first cut found three more of my own: it named Zen for ANY message containing "free usage exceeded", which would be a confident lie about another provider's endpoint (the match now needs the runtime's whole constant, and an action's own provider wins over any guess); the label spoke for reasons the runtime has not defined yet, which could well be transient; and a reloaded history still showed the bare error, i.e. the explanation vanished at exactly the restart that removed the user's context. Also corrected a stale claim in the SDK: the pinned runtime caps retries at RETRY_MAX_RETRIES = 5 — not the unbounded loop the comment described — which is why the explanation had to land on the final error line, and confirmed from the binary that action does ride the session.status payload we consume (its own TUI reads the same field), so the new label is not dead code. 1097 desktop tests (10 new; all four central paths mutation-checked, one of them only after a first attempt where two mutations masked each other).
2026-08-19 01:45 · fix(osd): six defects between the first 0.5.0 draft and this one, four of them mine and two found only by running the packages CI had just built. The bundled osd found no resources on Windows or Linux: lookup knew two layouts (resources/ beside the binary, macOS's ../Resources) and each packager uses its own — Windows puts binaries and resources in one directory, a .deb puts /usr/bin/osd beside /usr/lib/<Product>/. macOS was the one layout yesterday's "the bundled osd works" check happened to hit, so that claim was true and misleading at once; the real .deb says no bundled resources at /usr/bin/resources and deploys nothing, while the same package with the fix deploys the goal plugin, the browser guard and the agent profile. All four layouts are now found by a marker only a resource root has. Worse, yesterday's PR_SET_PDEATHSIG leak fix was itself a bug: the signal binds to the THREAD that created the child, and the desktop starts the runtime from a Tauri command thread, so on Linux the kernel would have killed the sidecar seconds after every launch — every spawn now goes through one thread that never returns, and a Linux test that models the command thread fails when the spawn moves back inline. Three more: osd model set fell back to the local machine when a NAMED gateway was unreachable (so --gateway http://box model set X reconfigured the laptop it was typed on), osd approval looked like it acted on a remote it cannot reach, and the approval hint printed the bearer token into stderr — a CI log, a journal, a pasted scrollback. Also osd model/osd approval exist at all now, --model/--base-url reached the help text, and --wait names what is waiting with both ways to answer it. Verified on a real headless Ubuntu: a bare 24.04 container with no packages added runs the archive, a systemd unit survives enable/restart/crash/stop with no orphaned sidecar (kill -9 outside a unit leaked one before the fix, none after — A/B on one box), and the whole configure-before-any-server-exists order works. Docs rewritten in seven languages plus the archive's README. The draft was re-cut twice for these; CI grew a concurrency group after one tag produced two runs racing over the same release. 237 Rust tests green, 1087 frontend, clippy/tsc clean.
2026-08-18 01:45 · release(0.5.0): a draft release with all six installers and, for the first time, the osd CLI as a release asset — built on GitHub, published nowhere. Version bumped in one commit across package.json ×2, tauri.conf.json, the workspace Cargo.toml, Cargo.lock (all three crates), CITATION.cff and the seven README BibTeX blocks; feat/osd-cli-headless fast-forwarded into master (15 commits) and v0.5.0 tagged there, so a tag that may later be published does not point at an unmerged branch. The build was green on all four targets in 21m17s and releaseDraft: true held — the release is a draft, and no existing release was touched. macOS took the two-stage path a tag build requires: the build job only queues the signed .submitted.app.zip, so the notarization workflow was dispatched twice (.submitted.app.zip → .submitted.dmg → final .dmg) and both arches now carry a real DMG. Verified on the RELEASED artifact rather than a local build: the aarch64 DMG was downloaded from the draft (asset id via the API — gh release download cannot see a draft), and xcrun stapler validate passes on both the DMG and the mounted app, spctl --assess answers accepted / source=Notarized Developer ID, and the bundle reports 0.5.0. Left for whoever publishes: the two .app.tar.gz updater bundles must be deleted, the changelog prepended, and the new Zenodo version DOI synced afterwards. Still untested by anyone: the four osd-0.5.0-* archives themselves — CI packages them but has never unpacked or run one.
2026-08-17 21:00 · ci(osd): the release pipeline is green on all four targets, and it caught two bugs nothing local could. The first dispatch died entirely inside a GitHub incident ("archive downloads … approximately 50% error rate") — every leg failed at downloading an action or a skills tarball, so nothing of ours ran; waiting it out beat retrying into it. Once the archive downloads answered 200 again (tested directly rather than trusting the status page, which was still red for an unrelated auth problem), the run got through and found what only CI could. Windows failed to COMPILE: reveal_impl is Windows-only and calls native_path, whose re-export I had trimmed on macOS's advice that it was unused — true everywhere except the one platform this machine cannot build. Then Windows built and packaged but uploaded nothing: upload-artifact is a Node action and cannot resolve the MSYS path (/d/a/…) that Git Bash hands it, so the step now passes a workspace-relative path. Both fixed, and the third run is clean on all four: Tauri build, Package the osd CLI, and artifact upload succeeded everywhere, with osd archives for all four targets (80-98 MB) beside the installers. The two things the target-directory move could have broken silently are checked rather than assumed: the installer globs still match (2 files on Windows = exe + msi, 2 on Linux = deb + rpm, 1 dmg per macOS arch — an empty glob would only have WARNED), and the macOS notarization step, which consumes the moved app_path/dmg_path, ran and produced its DMG on both arches. No release was created or touched, as intended for a dispatch: the release list is unchanged, the draft count is 0, and all four release-writing steps skipped on github.ref_type == 'branch'. Also confirmed on the real runners: the Windows packaging path measured earlier on a physical box (PowerShell + cygpath for the zip, node reading the version through a relative path) works there too.
2026-08-17 05:40 · verify(windows): built and ran osd.exe natively on Windows 11 — everything passes except the one thing this harness cannot deliver. Using the proxy on port 1087 (the box times out on GitHub and stalls rustup without it) the toolchain finished, the real 1.18.18 sidecar downloaded, and cargo build --release -p osd-cli produced an msvc osd.exe in 2m47s. On that binary: the CLI runs; osd server starts the real OpenCode sidecar; %APPDATA%\com.ai4s.workbench\runtime\gateway.txt is written exactly where Tauri's app_data_dir resolves; the REAL web client is served (index plus the 2.6 MB asset bundle) with 401 without a token; osd status finds the gateway with no arguments; a project is created at C:\Users\…\Documents\OpenScience\projects\…; and a full turn — session in that project, prompt, --wait — came back with the model's answer. The Windows-only code added yesterday is confirmed installed: SetConsoleCtrlHandler returns 1. What could NOT be confirmed is delivery: a synthetic CTRL_C_EVENT (AttachConsole true, GenerateConsoleCtrlEvent true) never reached the process, and neither did a console teardown. That was the harness twice over, and both artefacts were then identified and worked around: AttachConsole-after-the-fact does not deliver to the target, and start /B DISABLES Ctrl-C for the program it launches (cmd's own documentation: "^C handling ignored … ^Break is the only way to interrupt the application"). Generating the event from a process that was in the console all along, as a terminal does, settles it: osd=1 sidecar=1 port=4716 before, CTRL_BREAK_EVENT sent: True, then osd=0 sidecar=0 with the port record CLEARED and osd's own "Stopping…" in its log — i.e. the handler fired, the stop flag was observed, the gateway shut down and the sidecar was killed by us. Reproduced twice. The handler is registered for every console event, so a terminal Ctrl-C takes that identical path; only the launcher flags decide whether Windows delivers Ctrl-C in the first place. Also fixed two dead-code warnings that only appear off macOS (parse_scutil_proxy, xdg_documents_dir), so Linux and Windows builds are warning-free like the macOS one. Cleaned up afterwards: the test project I had created in the real workspace is removed, the stale port record cleared (it would have sent the user's own CLI to a dead address), no stray processes, C:\osd-test gone; the rust toolchain (~1 GB) and one empty test session remain. 219 Rust tests green.
2026-08-17 03:20 · verify(osd): ran the headless server on a real Linux server and audited Windows against a real Windows box — three more defects, one of them serious and pre-existing. Linux first, because that is what the whole split exists for: an AWS box with no DISPLAY and no webkit2gtk, where a Tauri build cannot even start. Everything worked there — the real web client and its 2.6 MB asset bundle served, 401 without a token and 200 with, osd status finding the gateway with no arguments at all, a project, a session inside it, a real model turn returned through --wait, SIGTERM leaving neither process behind, and the XDG data dir where Tauri would have put it. One thing did NOT: no git snapshot was ever recorded. The cause turned out to have nothing to do with headless and to predate this work entirely — creating a project git inits its folder, that repository has no commit until it has content, and git add -A in the PARENT then fails outright with "does not have a commit checked out". Snapshots are best-effort, so the error went nowhere, and EVERY workspace snapshot after the first project failed the same way: the workspace quietly stopped having any file history, which is the one thing that machinery exists to guarantee. Reproduced deliberately (file written outside the app captured in 5 s with no project present, never captured with one), fixed by letting git name the repositories it refuses and retrying once without them, mutation-checked, and re-verified on the same Linux box: the file that was lost is now in the snapshot. The desktop app has the identical exposure — its base workspace holds projects/ — so this fixes both. Windows was audited on echo-win, and two assumptions I had written down turned out to be false when measured: node -p "require('/c/…')" fails under Git Bash (MSYS only translates POSIX paths that are whole arguments, not ones inside a JS string) and Compress-Archive -Path '/c/…' silently produces no archive without cygpath -w — so the release workflow's Windows leg would have failed twice; zip and 7z are also both absent there, which is why the fallback chain matters. The third Windows defect was in shipped code: osd server installed only a SIGINT/SIGTERM handler on the theory that a console Ctrl-C reaches the child anyway, which is wrong for a process spawned with CREATE_NO_WINDOW (it does not share the console), so Ctrl-C would have left an OpenCode holding the port and the session database — now a SetConsoleCtrlHandler handler runs the same shutdown path, declared against kernel32 so it needs no crate, and type-checked against the msvc target since the workspace itself cannot be cross-checked from macOS. Also fixed: the Windows Documents folder was guessed as %USERPROFILE%\Documents, which OneDrive redirection makes wrong — and the desktop asks Windows properly, so on such a machine the two front doors would each invent their own workspace; now read from the registry, parsed against that box's verbatim output. A native Windows run of the binary is still not done: the box's network stalled rustup at 153 MB of ~700 MB and times out on GitHub entirely, so that verification waits for CI or a better link. Cleaned up after both machines; build-essential/libssl-dev and a $HOME rustup remain on the Linux box. 219 Rust tests green, 1084 frontend, clippy clean.
2026-08-16 22:20 · review(osd): eight defects in the headless split, six of them mine, all found by running the paths rather than reading them. Four were regressions the Tauri→std substitution introduced and the compiler could not see. tauri_plugin_shell's CommandChild::kill waited internally and std's does not, so every sidecar restart — approval mode, provider, agent model, skill install, and the reconnect loop's forced respawn — left a zombie; the test asserts the pid leaves the process table, and shows "Z" without the fix. The stderr drain lost its \r split when it became BufReader::lines(): a bare-CR progress line would never flush and would grow a String without bound, and decoding each 4 KiB read separately mangles any UTF-8 character straddling the boundary (agent output is routinely non-ASCII) — rewritten to hold bytes until a delimiter with a 64 KiB cap, and both halves mutation-checked, the first attempt at that test having passed against a \n-only implementation because it asserted on substrings instead of whole lines. osd server starts the sidecar before it binds, so a taken --port returned an error with an OpenCode still running and nothing driving it. And the desktop app and osd server share one gateway.txt: whichever stopped LAST cleared the recorded port, erasing a live gateway's address so no CLI on the machine could find it — now only the owner of that port clears it. Two more were pre-existing and in #81's family: a browser reading a session in another folder got NO events, because OpenCode serves each folder from its own instance and the stream stayed scoped to the previous one (the turn runs, the page shows nothing), and --wait reprinted its approval hint every two seconds while re-reading the whole transcript just as often. The last two were honesty rather than code: provenance and run records are written by the desktop client (record_provenance/record_run are no-ops outside Tauri), so the README section claiming "the same projects and provenance" was wrong headless — corrected in all seven languages, and the server now starts the git snapshot watcher so file history, which is the part it CAN keep, is actually kept (verified: an edit from outside the process produced a snapshot commit). Verified live rather than asserted: message timestamps are epoch ms so the status comparison holds, the sidecar does inherit the parent environment (ANTHROPIC_API_KEY reaches it — the documented way to configure a headless box), hostile project names and session directories stay inside the workspace, and two servers on one database do work despite the single-instance comment's warning. Also checked mechanically across the whole refactor: all 104 invoked commands still exist, no argument was renamed across 102 of them, and no command lost its async (one gained it). 215 Rust tests green, 1084 frontend, tsc/eslint/clippy clean, DMG rebuilt.
2026-08-16 21:55 · feat(#84): the workbench runs without a screen — osd server serves the real web UI headless, and osd drives it from a terminal. The issue asked for a CLI over the existing gateway and I had answered that the gateway is a thread inside the Tauri app, so a CLI on top of it would be a remote control for a running desktop rather than the headless runtime the reporter wanted. That answer still holds, and it is why this round starts with a split rather than a client: a Tauri app cannot start where there is no display (tao calls gtk::init() on Linux, and a compute node has no webkit2gtk), so osd server could never have been the desktop binary with the window switched off. The server half now lives in crates/osd-core, which the compiler keeps honest by not depending on Tauri at all — workspace layout, the OpenCode sidecar, the opencode profile and config, projects, runs, provenance, git snapshots, the browser MCP proxy, and the gateway. The desktop keeps every #[tauri::command] as a one-line shell over it plus what genuinely needs a screen (kernel, Jupyter, browser, ssh, ACP, dialogs, the file manager). The AppHandle those functions took only ever answered three questions — where is my data, where are my resources, which version am I — so it became Env, which answers them without a window; the desktop keeps AppHandle-shaped wrappers, so the GUI modules are untouched. Two substitutions were needed: the sidecar spawns through std::process::Command (stderr still drains to debug.log, and its EOF is now what tells the lifecycle the process died), and the gateway takes its web client through an Assets trait — Tauri's embedded bundle on the desktop, a copy compiled into osd by build.rs elsewhere — so a remote browser still gets the IDENTICAL UI, not a re-implementation. The three gaps I listed on #84 are closed: POST /v1/sessions takes a directory, validated exactly like file access (under the base workspace, or a registered project's own folder — same root cause as #81); GET /v1/sessions/:id/status says whether a turn is still running, using the sidecar's start time to tell a live turn from one whose runtime died, since both persist identically; and prompt takes model/agent/variant so a scripted run can state what it ran on. Two real bugs were found by RUNNING it rather than by reading it. First, --wait was wrong in the way that matters most: an unavailable model stores the prompt and never replies, the session is idle throughout, so waiting on "idle" alone reported success and printed the PREVIOUS answer — a script would have taken the last run's output as this one's. It now waits for a reply that was not there before, and says the turn never started otherwise (verified both ways against a live server). Second, unpacking the release archive and starting a server from it showed /goal was dead on every fresh profile — and not because of anything here: npm install @opencode-ai/plugin@1.18.18 writes ^1.18.18, the bundled marker holds 1.18.18, and deploy_goal_plugin_dependencies compared the range to the exact version as strings, so its own post-copy check failed, the plugin was never deployed and never registered. Every existing test had pinned the dependency exactly, which is why none of them saw it. Fixed for the desktop too, with a test in the shape npm actually writes (mutation-checked). Credentials keep the AGENTS.md boundary: osd auth set writes a key on the machine that will use it, the gateway still refuses every config write and redacts every read, and the sidecar inherits the process environment so ANTHROPIC_API_KEY=… osd server needs no file at all. Ships as a self-contained per-platform archive (osd + the three sidecars + the same bundled resources the installer carries, ~85 MB compressed) built by scripts/release/package-osd.sh and attached by CI. 213 Rust tests green (13 new), 1078 frontend tests, tsc, eslint and clippy clean; the desktop DMG rebuilt and launched from the built bundle (sidecar up, browser MCP proxy up, UI rendering, runtime ready) and the whole CLI walkthrough — project, session under it, prompt answered through --wait, SIGTERM leaving no orphan — run end to end against a server unpacked from the archive.
2026-08-16 16:40 · fix(#116): a config the app cannot read is no longer destroyed, and the browser lease refusal says what broke. Chasing the reporter's third round — clean reinstall, no file-write errors, still trusted conversation lease was not supplied — two things were established by measurement rather than argument, and both corrected an earlier claim of mine. First, the v0.4.1 Windows installer was unpacked (7z) and does ship the fixed single-export guard: browser-plugin/browser-guard.ts, 2286 bytes, SHA-256 63c323ec…, identical to the v0.4.1 tag — so the install-over-the-top theory from 08-15 was wrong and the reporter was right to rule it out. Second, on a real Windows 11 box (echo-win) the whole chain works today: plugin lists both paths, the deployed guard has one export, agent_browser_open/tab_list are allowed, and opencode.log holds zero failed to load plugin and zero lease lines. That box runs 0.4.2, so the gap was closed properly — git diff v0.4.1 HEAD over browser-guard.ts and browser_mcp_proxy.rs is empty, i.e. version is not the variable. Driving the proxy directly over stdio on Windows pinned the causality exactly: no session in the arguments returns that message verbatim, a valid osd- lease returns a full inventory — so the message means the guard did not inject, and nothing else. The leading suspicion (a JSONC config silently skipping ensure_browser_guard_plugin at opencode_config.rs's .ok()?) was then FALSIFIED by replaying the whole startup sequence against a JSONC config: seed_compaction goes through as_object, whose unwrap_or_else(|_| json!({})) discarded the file, so one launch reduced a config to {compaction, instructions, plugin} — provider keys, MCP servers, permission mode and model gone, and the guard registered again afterwards. That is a data-loss bug and the opposite of the reporter's symptom (their other features work), so it is not their cause. Fixed both anyway: parse_config now tolerates the JSONC the file may hold under its own .jsonc name and, when it still cannot parse, every writer leaves the file alone and logs instead of substituting {}; NO_LEASE_ERROR names the guard plugin, the plugin list and the log to check. Reviewing the fix on Windows then found the hole that matters most there: PowerShell's Set-Content -Encoding UTF8 and Notepad both write a UTF-8 BOM (verified on echo-win — ef bb bf, CRLF), and serde_json rejects a document that starts with one, so a user who merely OPENS this config in an editor to check the plugin list made the app unable to read it again. parse_config strips the BOM first. Measured on echo-win's live config, redacted on the Windows side so no credential left the machine and replayed through the whole startup sequence in five encodings: after the fix all five keep 7/7 top-level keys with the guard registered; before it, the BOM variant alone went 7 keys to 3, losing $schema, mcp, model and permission — the browser MCP server, the model and the approval mode, silently, in one launch. 197 Rust tests green (7 new; the three central ones mutation-checked — restoring the lenient parse fails the data-loss test, dropping the JSONC fallback fails the round-trip test, dropping the BOM strip fails the Windows-encoding test), clippy clean on both files, and both refusal and happy path re-verified against a rebuilt release binary rather than only the unit test. The reporter's own root cause is still open: their plugin array has not been seen.
2026-08-16 08:25 · release: v0.4.2 is out — 20 commits since v0.4.1, six installers, notarization verified on the published artifacts. The v0.4.2 tag had been cut the day before at c5590d8 and its draft was 12 commits stale, so the draft and the tag were deleted and the tag re-cut at 701bbdd: nothing had been published, so it only cost a rebuild. build.yml went green on all four targets in ~11 min; the "No files were found … *.dmg" warning on the macOS legs is by design (build.yml hands the signed app to the notarization workflow, which builds and staples the DMG). Both cron slots were missed, so finalize-macos-notarization.yml was dispatched twice by hand — one dispatch per stage, app→submitted.dmg then staple→final — and verified on the DOWNLOADED asset, not a local build: stapler validate on the DMG and the mounted .app, spctl --assess → accepted / source=Notarized Developer ID. Two things worth remembering. The asset set is SIX, not five: 0db58bb restored the .msi. And the released aarch64 DMG is 104 MB against 89 MB for the same commit built locally — nothing wrong, and consistent with v0.3.3/v0.4.0/v0.4.1 (102-104 MB): the .app payloads differ by only 1.6 MB (CI's own binaries), the rest is the container, because tauri's bundle_dmg.sh converts with zlib-level=9 while scripts/release/finalize-macos-notarization.sh runs hdiutil create -format UDZO at the default level and then appends a signature and ticket (compression ratio 0.349 vs 0.376). Passing -imagekey zlib-level=9 there would save ~13 MB per download; not taken this release.
2026-08-16 07:15 · fix(ui): importing a project opens that project's own Screen. Every other "start work in project X" entry point already did — newSessionIn opened a new Screen named after the project and aimed the new pane's own draft:<leafId> slot at its folder — but both import paths (the adopt-in-place shortcut for a folder already inside the workspace, and the copy/in-place dialog) only called navigate("/live"), so a freshly imported project landed in whatever pane the user happened to be reading and took over that conversation's view. The shared step is now openProjectScreen, called by all three. 1078 frontend tests green (3 new, covering both import paths and the failed-import case, all three verified to fail without the fix), tsc and eslint clean.
2026-08-16 05:35 · security: the gateway token no longer rides in a file URL, and no preview loads a whole file into memory. An audit for severe bugs against a clean tree (typecheck, 1075 frontend tests and 189 Rust tests all green beforehand, so both findings were latent rather than regressions) turned up two. First: previewUrl() appended &token=<gateway token> and FilePreviewInspector fed that URL straight to <iframe src> and to window.open — and a document can read its own location whatever its sandbox, so a single prompt-injected HTML artifact (the agent writes these routinely, from content it fetched) would have posted the token out and handed over prompt submission, permission replies and workspace reads on the LAN. The token now stays in an Authorization header: GET /v1/fs/ticket trades it for a 10-minute, single-file capability and only that goes in the URL. CSP: sandbox allow-scripts on every HTML response covers the case an iframe attribute cannot — a tab opened directly on the artifact — by forcing it into an opaque origin while leaving interactive plots working. Second: preview_server's Range branch allocated end - start + 1 bytes in one Vec, and bytes=0- is exactly what a <video> asks for first, so previewing a multi-GB research video allocated the whole file despite the function's own comment promising it would not; the 25 MB PREVIEW_CAP_BYTES guard only ever applied to the read_artifact IPC path, never to either HTTP one. Both servers now copy in 1 MiB chunks, so peak memory is flat in file size. Also hardened start() to refuse an empty gateway token (ct_eq("", "") is true, so Authorization: Bearer would have authenticated — unreachable today only because both callers mint a token first). 190 Rust tests green (1 new, covering a body spanning three chunks and a slice straddling a boundary), 1075 frontend tests, tsc and eslint clean. Not fixed, deliberately: DANGEROUS_BASH is a token-prefix blocklist, so /bin/rm -rf x and python -c "shutil.rmtree(...)" run without a prompt — a real gap against the "deletion requires approval" promise in AGENTS.md, but a design question rather than a patch.
2026-08-16 04:02 · fix(ui): hover controls that would not go away, and two close dialogs nobody needed. The Copy button and the new timing/context readout stayed lit after the pointer had left — in ONE conversation, which is what made it hard: three fixes in a row assumed a mechanism instead of measuring one (mark on pointer-leave of the conversation; clear when a Screen is hidden out from under the cursor; a single document-level pointer tracker replacing CSS :hover entirely). Each was a real hole and none was THE hole. A ⌘⇧H dump of what the machinery actually believed ended it in one round: rows=351 lit=1 marked=true inside=true — the DOM and the CSS were already right, exactly one row was visible by computed style, and the screen was showing several. Stale compositor tiles, in the one conversation long enough (351 rows in a single scroller) to have them. The fix is the property, not the mechanism: visibility rather than an animated opacity, which cannot be composited and so cannot be left behind. The tracker stays — it is right, and it fixed the paths it was written for (a scroll under a still pointer moves the content and dispatches nothing; a tiled pane hit-tests at 75% zoom; a hidden Screen delivers no leave) — but the reported symptom was never its fault. Also: closing a Screen now asks unless it is empty or the untouched preview a sidebar click opened, replacing a rule that read "can I SEE anything to lose" and was therefore wrong for every Screen a relaunch restored (they carry real work and look empty); closing a PANE follows the same rule, which was missing entirely. Diagnostics removed on the way out (the ⌘⇧H dump, the split-menu timer, and lib/switchProbe, whose question — 190ms to 30-70ms — is settled). 1075 frontend tests green (9 new), tsc and eslint clean.
2026-08-16 01:09 · perf(#92): a Screen switch no longer rebuilds anything, and the two costs that were left were MEASURED rather than guessed — 190ms down to ~30-70ms. Screens used to swap the whole pane tree, so every pane was torn down and rebuilt on each switch. Now the recently used ones stay mounted in three tiers: the last 5 keep their LAYOUT (hidden with visibility), up to 10 stay mounted but display:none, older ones unmount and are rebuilt on the click that returns to them — which loses nothing, since unsent text, scroll positions, threads and running turns all live outside the components. What the tiering needed was a strict split between "the user is looking at this" (visible — polling, auto-opening artifacts, claiming an app-wide draft) and "this can be measured" (laidOut — toolbar widths, scroll restore); conflating them was, by measurement, HALF the remaining cost: every reveal re-measured a pane whose layout had never been lost, forcing layout mid-commit. Instrumented the whole path (lib/switchProbe, one line per switch in debug.log: total/react/effects/paint/warm|cold) and let it kill two of my own hypotheses — a compositing-layer trick (opacity-0 + will-change) bought nothing, since WebKit keeps no raster for a transparent layer, and the post-switch runtime work (openSession, stream re-binding, per-pane queries) measures 0-4ms, not the culprit I claimed it was. React phase on a warm switch: 141ms → 3ms. What remains is genuine paint (26-70ms), which only gets cheaper by painting less (content-visibility), and that trades against the scroll restoration this same work just fixed — not taken. Also from the same pass, all found by reading the diff rather than by report: leaf-targeted layout actions only ever patched the ACTIVE Screen, so an async send that landed after switching away left its own pane an empty draft; inspector scroll positions were lost on every switch (an element with no box drops scrollTop); a background pane could open a notebook in the pane the user was actually looking at. 1049 frontend tests green (17 new), tsc and eslint clean.
2026-08-15 23:52 · fix(#62): the compaction marker was never rendered ONCE — not live, not on reload. Asked whether a compaction survives an app restart; it does not, and checking why showed it was never visible in the first place. Read the shipped binary rather than the SDK types (which do not even declare overflow): SessionCompaction.create opens a message with role:"user" carrying no text, purely to hang the part off — updateMessage({role:"user",…}) then updatePart({messageID, type:"compaction", auto, overflow}). Both our paths read compaction only on assistant messages, so both dropped it: live, OpenCodeClient's echoed-user-text guard returns before the part type is inspected; on reload, historyToThread scans parts solely in the assistant branch. So #62 shipped an event, a block type, a renderer and seven locales of copy with nothing feeding any of it. Reproduced with two tests FIRST (live emitted []; history produced no block), then moved the compaction check ahead of the role split on both sides; the marker message still renders no user bubble since it has no text. Mock server grew a streamCompactedTurn in the real shape. Also settled by asking rather than assuming: the usage readout stays hover-only on every message, per the user's call. 1054 tests green, tsc + eslint clean.
2026-08-15 23:20 · feat(ui): context usage is visible at last, and a compaction is findable. Two complaints, one root cause and one styling miss. (1) "I can't see how much context is used" — because the numbers never reached the app: OpenCode reports tokens {input, output, reasoning, cache{read,write}} + cost on every assistant message, and BOTH SDK paths narrowed them away (OpenCodeClient.getMessages destructured only id/role/time/error/agent; the message.updated SSE handler typed info as id/role/sessionID/agent and used it solely to record the role). Confirmed the shape against real stored messages in ~/.local/share/opencode/storage/message, not from docs. Now: MessageUsage in shared with contextUsed() = input + cacheRead + cacheWrite + output + reasoning (cache reads ARE context — they are what the model was sent), a new message.usage event republished throughout a turn, messageID on text.updated (carried in textStreams so delta-driven re-emits don't blank it), and the fold stamping usage onto every agent block that message produced. Denominator is the existing per-model contextLimit; new contextLimitFor() resolves the agent-owned model rather than modelForSession, which returns null exactly then. 0 ⇒ tokens with no percentage, never a guessed window. Rendered as MessageMeta beside Copy in the hover row (time · duration · 124k/200k (62%) · $0.42, warn accent ≥75%, full breakdown in the tooltip) — per the user's call on placement. ACP degrades to time-only; cost hidden at $0. (2) The compaction marker already existed since #62 but was an 11px muted pill on a hairline — invisible in a long thread; now warn-accented with a real seam. Also fixed: a reloaded compaction lost its "When:" line (the part carries no clock — dated from its message now). FOUND EN ROUTE, pre-existing and NOT fixed: no Tailwind color-opacity modifier compiles in this project — var(--warn) etc. are declared without <alpha-value>, so every bg-accent/15 / border-warn/30 in the codebase renders as nothing (borders silently fall back to currentColor). Verified by grepping the processed CSS for \/[0-9] utilities: zero. CompactionRow uses plain opacity-40 instead. Verified visually in warm + dark via headless Chrome (the port-9528 bridge is still down); 1051 tests green (16 new), tsc + eslint clean.
2026-08-15 09:52 · fix(windows): kill the sidecars before INSTALL too, not just uninstall (#116) — a user on a clean v0.4.1 install reported every browser call returning "trusted conversation lease was not supplied", the exact failure v0.4.1 was supposed to have fixed. Diagnosed without asking them for anything further, on our own Windows box: v0.4.1 there deploys the FIXED guard (one export, three plain functions), registers it in the config, and its debug.log contains no lease error at all while agent_browser_open/_inventory calls are evaluated and allowed. So the deployment chain is sound and their install is not. Their own report says why: Error opening file for writing: agent-browser.exe, and they clicked Ignore. The uninstall hook stops the bundled helpers, but a straight install-over-the-top never did — File cannot overwrite a locked helper, NSIS offers Abort/Retry/Ignore, and Ignore silently keeps the OLD binary. That yields an install that is part new and part old with nothing on screen to say so; in their case the stale half was the browser plugin. NSIS_HOOK_PREINSTALL now runs the same kill as PREUNINSTALL, at the top of Section Install before CheckIfAppIsRunning and before the first File write. Verified under makensis with both hooks expanded. Also closed #115 — the pop-up on every new window/screen was the console the proxy's raw spawn allocated, fixed in v0.4.1 by 5606a58.
2026-08-15 09:50 · perf(#92): read the whole Screen-switch path and fixed the two costs that could be fixed safely; the dominant one is still open and needs a profile in the real app. What a switch does today, with sources: every pane unmounts and remounts (PaneTree.tsx:50-54 keys leaves by id, deliberately, to stop #91's composer bleed), so every message re-parses through react-markdown; LiveSessionPage.tsx:139-149 calls openSession, which for a session in another folder does setWorkspace + kernelReset + connectRetry (runtime.ts:2997-3016) against a lazily-started per-directory OpenCode instance; syncPaneStreams then closed every background SSE stream and rebuilt the incoming Screen's, twice per switch since its effect depends on both paneDirsKey and workspace; clearResolvedPaths() on a folder change dumps the artifact-path cache added for #92 (artifactFile.ts:63-68), so every file mention pays a directory walk again. FIXED: the math plugins are no longer attached to messages with no $ (remark-math + rehype-katex are two extra passes over every node of every message), and a background stream now retires after a 60s grace period instead of on the spot, so flipping between two Screens no longer pays an SSE cold start each way — the foreground folder's own stream still closes immediately, or events fold twice. Both mutation-checked. MEASURED, and the reason the first fix is filed as minor: a 30-message conversation costs ~32ms to parse in jsdom and the math plugins are ~14% of that — nowhere near the reported multi-second freeze, so the parse is NOT the bottleneck. Since "Not Responding" means a blocked UI thread, the remaining suspect is DOM construction and layout for every pane at once, which jsdom cannot measure at all. NOT attempted, deliberately: keeping inactive Screens mounted behind display:none. It would kill the remount cost, but a hidden pane keeps running its effects — SessionView's notebook auto-open calls openArtifact, which docks into whatever Screen is ACTIVE, so a background pane would inject panes into the Screen the user is looking at. That needs a visibility prop threaded through, and there is no way to verify any of it here: the verify skill's Chrome bridge (port 9528) is down and the repo has no headless driver. 994 frontend tests green (5 new), tsc and eslint clean.
2026-08-15 09:35 · fix(sessions): quitting the app mid-turn no longer leaves a session on "Working…" forever — diagnosed live over ssh on the reporting Mac, and the root cause was the user's own hypothesis, not any of mine. The stuck session's last stored message is role: assistant with a time.created and NO completed and NO error; turnStillStreaming reads exactly that shape as "streaming right now", because a turn in flight and a turn whose runtime died mid-stream persist identically. Timeline confirmed it: session last updated 14:24, current opencode process started 23:20, no outbound sockets, no error, no retry — the running process had never touched that session. So the fix cannot come from the stored shape; liveness must be proven by something that dies with the process. Rust now stamps the sidecar's spawn time (runtime_started_at), the SDK carries each message's created, and a message created before the current runtime started can no longer be "in flight". Web/gateway clients pass no start time and keep the old behaviour, since calling a live turn dead is the worse error. Three of my own diagnoses were wrong along the way and each was killed by direct evidence: "runtime alive but not serving" (curl answered 401 in 14 ms), "compaction never ran" (2 compactions, non-zero window), "context too large" (the stuck session is 7 messages, 57 KB) — the first two were conclusions from a different machine applied without re-checking. Also shipped from the same investigation: restart_runtime plus a forced respawn after 8 failed reconnects, for the case 0.4.1 still cannot recover from — a sidecar that is alive, so nothing terminates and nothing clears the lifecycle, but no longer serving. 1010 frontend + 189 Rust tests green.
2026-08-15 08:29 · feat(split-pane): a new pane no longer always means a new folder — Cmd+D used to hand every split a draft with no destination, so its first message called newDatedWorkspace() and the work landed in ~/Documents/OpenScience/sessions/<date-time>, away from the project the user was in; the only way out was the composer's folder chip and a trip through the native picker. A split now continues where it came from: split() returns the new leaf id, and inheritedDraftFolder resolves the source pane's destination (a bound pane's session directory; an unbound one's own aim — so "new folder" propagates as faithfully as a path, and the active workspace is never consulted, since it follows whatever session was last opened, #69). The other answer stays one click away in the pane itself: an empty split pane shows DraftDestination — continue in <folder> / new folder <dated name> / choose another — chosen over a Settings toggle because the decision is per-pane and only exists while the pane is empty. Needed a second map, draftOrigins, kept beside draftWorkspaces: the aim is cleared the instant the user picks "new folder", so without a record of where the pane was born the choice would have been one-way. Both are retired together when the session is created — a test asserting that failed first, because the graft destructured only draftWorkspaces out of forgetDraftFolder and silently dropped the rest of its result. Nothing touches the filesystem or moves the active folder until the first message, so a split that is never used still creates nothing. 989 frontend tests green (10 new), tsc and eslint clean, keys added to all 7 locales. NOT verified visually: the project's verify skill drives Chrome over port 9528 and nothing is listening, with no headless driver in the repo — the card's DOM and states are covered by tests, its appearance is not. Behavior change worth flagging: inherit is now the DEFAULT for a split, where every previous version made a new folder.
2026-08-15 07:20 · fix(windows): MSI restored, and the installer now says what it deletes — dropping the .msi in the #113 fix was overreach on my part. The bug's actual cause was sidecars holding the install directory open, fixed by the uninstall hook; the WiX-migration path was a hypothesis I never confirmed the reporter had hit, and I removed a shipping artifact on it. Download counts settle it: the MSI has taken 6-10% of Windows downloads every release (v0.3.3 105 vs 1288, v0.3.0 99 vs 1051, v0.4.0 20 vs 332) — that is the shape of institutional deployment, which is exactly who cannot use a per-user .exe under Group Policy or Intune. Both are back, but labelled by audience rather than offered as equals, since the real hazard was never the MSI existing, it was presenting two mutually-unaware installers as interchangeable. Also overrode the installer strings via customLanguageFiles (a custom language file REPLACES the built-in set, so nsis/English.nsh carries all of them): "Delete the application data" — a ticked box on a screen people click through — now reads "Also delete my sessions, run history and settings (cannot be undone)"; "Unable to uninstall!" now names both ways out instead of dead-ending at the issue tracker; and the upgrade choice reads "Install over it, keeping my data (recommended)" rather than a coin toss whose wrong side is where the data loss lives. 1005 frontend tests green, tsc clean. Unverified locally: customLanguageFiles path resolution and the NSIS strings only compile on a Windows build, same as hooks.nsh — needs a dispatch before the next tag.
2026-08-15 06:05 · release(v0.4.1): published — five installers (the .msi is gone, so the set is 2 dmg + exe + deb + rpm), both DMGs verified on the RELEASED artifacts (stapler validate twice, spctl → accepted / source=Notarized Developer ID), latest repointed, Zenodo minted 10.5281/zenodo.21950242 and it is synced into CITATION.cff and all seven README BibTeX blocks (the badge keeps the concept DOI). The first tag was cut before two fixes landed, so the draft and tag were deleted and v0.4.1 re-cut on 79de8eb — nothing had been published, so this cost only a rebuild. Ships: NSIS uninstall (#113), the 1.18.18 compaction root cause and the done-on-failure lie (#114), full-mode path prompts, the unknown-context-window warning, the browser lease guard that had never loaded, the Zen model-list filter, and sidecar crash recovery. Also fixed the release pipeline itself: finalize-macos-notarization.yml uploaded by tag, which cannot resolve a DRAFT release — it answered 404 once and 422 once, both AFTER stapling and submitting to Apple, so each retry re-submitted the same build and the queue never advanced past the first architecture. That is the real explanation for the standing note that "the cron keeps skipping its slots and needs two manual dispatches": it was not the cron, it was this line failing every time. Now uploads by release id and confirms the asset in the listing instead of trusting the exit code — after which one dispatch advanced two stages and the second went through first try.
2026-08-15 05:16 · fix(runtime): a crashed sidecar is now recoverable — 0.4.1 still "hung" on Windows, and the logs (pulled over ssh from the user's box) showed a failure unrelated to the compaction one: opencode died on its own (terminated: code=Some(1), an Effect ServeError surfacing through its top-level Unexpected error catch), preceded two minutes earlier by MaxListenersExceededWarning: 11 event listeners at ~effect/Effect/evaluate → runLoop → afterScheduled while THREE sessions and three models ran concurrently. The user's hypothesis that open-science-browser caused it does not hold: the first crash preceded the first browser call by 20 seconds, the browser was then approved and the agent ran three more minutes over six steps, and our browser_mcp_proxy logged nothing at all. The crash is upstream. What is ours is that it was UNRECOVERABLE — CommandEvent::Terminated only wrote a log line, so lifecycle.child/url kept pointing at a dead process and start_runtime's "already running" early return handed the frontend a dead port forever, while connectRetry only ever retried the socket and startRuntime was called exactly once, at bootstrap. Verified live on the box: opencode.exe absent, app alive, port 14614 free — a respawn would have worked and nobody attempted one. Fixed on both sides: Terminated clears child/url (generation-guarded so a late exit cannot wipe an already-replaced sidecar; the port is deliberately kept so the frontend URL survives the respawn), and connectRetry calls startRuntime on every failed attempt — a no-op while alive, the repair when dead. 1005 frontend + 189 Rust tests green, 1 new.
2026-08-15 05:05 · fix(models): the picker offered 29 OpenCode Zen models that Zen does not serve, so the only way to find out was to pick one, type a prompt, and read a provider error. From a user report on deepseek-v4-flash-free ("upstream request failed, endpoint is unavailable") and ling-3.0-flash-free ("is not supported"), reproduced against the gateway from the command line — and the two errors turned out to be different problems. ling-3.0-flash-free answers 401 {"type":"ModelError","message":"Model … is not supported"}: retired. Sweeping all 25 *-free catalog entries, 19 answer that way; only mimo-v2.5-free, nemotron-3-ultra-free, nemotron-3.5-lightning-free, laguna-s-2.1-free, hy3-free and deepseek-v4-flash-free are live. deepseek-v4-flash-free is NOT retired — it answers 503 … Upstream request failed: Endpoint is unavailable, a transient upstream outage — so it stays selectable; only retirements are filtered. Root cause is that the picker's list comes from models.dev via /config/providers, a superset of what the gateway runs: 29 of its 91 zen entries are gone, including 10 paid ones (gemini-3-pro, claude-opus-4-1, grok-code, kimi-k2, …). GET https://opencode.ai/zen/v1/models is the authority and is a strict subset of the catalog (nothing it lists is missing from models.dev), so it can only remove entries, never invent one; it is fetched WITHOUT credentials on purpose, so the answer is the full public catalog rather than an account-scoped view and no key leaves the keychain. Fetched in Rust (model_probe::zen_models, same CORS reason as the custom-endpoint probe) and via a new GET /v1/zen-models gateway route so the web client filters identically; verified live from the Rust path (62 ids, mimo-v2.5-free present, ling-3.0-flash-free absent). Marking, not deleting: ProviderModelInfo.available rides the catalog, baseOptions drops retired models from every list a user picks from (favorites and recents included), while lookups by key still resolve so the configured model keeps its name — the composer explains a retired selection instead of the row silently vanishing, and fallbackDefaultModel deliberately does NOT self-heal it (that would swap a free-tier user onto a paid model without asking, and its candidates are now selectable-only). Fail-open throughout, which is what keeps custom endpoints out of it: only provider.id === "opencode" is touched, available defaults to true, and an unreachable endpoint, an empty list, or an answer recognising none of the runtime's zen models all mean "unknown" and hide nothing. 1004 frontend tests green (25 new), 189 Rust tests, tsc/eslint/clippy clean, DMG rebuilt (89 MB, SHA-256 7333314e4edba2d1d00210431c17c42222839337f888b2bbcca10fbfc8d51dad). Not verified: behaviour with a paid Zen key (no account), and the gateway route only through its unit path, not a live web client.
2026-08-15 03:20 · fix(browser): the browser-lease guard has never loaded, not once since it shipped in v0.4.0 — user-supplied patch, verified against this machine's runtime log before applying: 51 level=ERROR message="failed to load plugin" lines from 2026-08-10 to 08-13, every one tool.startsWith is not a function. Cause is the shape of the module, not its logic: OpenCode's external-plugin loader iterates the module's exports and calls each one as a plugin factory, so sanitizeBrowserToolArgs — the first export — ran with no arguments, threw, and took the whole module's registration with it before BrowserGuardPlugin was ever reached. Only the factory is exported now. Corrected the patch's account of the damage on the way in, because it matters for the write-up: the guard and the MCP ownership proxy landed in the SAME commit (2ce3bf6, 08-10 04:38), so there was never a window where leases were "silently unenforced" — the proxy validates a lease the plugin was supposed to inject, so every guarded browser call was rejected with "trusted conversation lease was not supplied" (the failure is a tool result the model reads, not a log line, which is why nothing surfaced), and the one path that did get through was a model inventing its own osd-* session, which sanitize_tool_call deliberately preserves — i.e. exactly the conversation-ownership hole the guard exists to close. Two new tests: the export shape is pinned, and every export must survive being called with no arguments; both mutation-checked by re-exporting a helper (both fail). No migration needed — deploy_browser_guard_plugin copies over the deployed file on every start, so an upgrade repairs existing installs. 981 frontend tests green (2 new), tsc clean, DMG rebuilt and verified by content — browser-plugin/browser-guard.ts inside the bundle has the single export (SHA-256 c28306b1b41906f7a283263fe32cfcf2f95784c2a03c0e33343a9ab3aa46bfc0, superseding the 02:57 build). That check first read as "the plugin is not bundled at all": 14 DMGs from earlier builds were still mounted, so the new one landed on /Volumes/Open Science 13 while /Volumes/Open Science was an old image whose Resources predate the plugin.
2026-08-15 02:50 · fix(browser): the agent reached for the browser on anything that touched the network, so the escalation ladder now sits on the three surfaces the app owns, each carrying only what its position justifies. Resident context gets one sentence: protect_tool_list prefaces agent_browser_open — and only that tool, the one that launches Chrome — with "try the built-in fetch and search tools, and CLI tools like gh or curl, before this". The reasoning and the full ladder go where they are read on demand: the generated skill's description: (which decides whether the skill loads at all) and a new first adapter bullet in the SKILL.md prepend. Tool names for search are left generic on purpose — opencode 1.18.18 registers websearch only when Exa or Parallel is enabled (St(providerID,{exa,parallel}) in the tool registry) and this repo configures neither, so the app ships no cheap way to find a page and naming a tool that isn't there would just burn a turn. Approve mode also had the friction backwards: webfetch asked while every browser call matched opencode's builtin {action:"*","effect":"allow"} and ran silently. Reversing opencode 1.18.18 settles that it is fixable from config — MCP tools are wrapped in the same ask({permission:<tool id>,patterns:["*"],always:["*"]}) the builtin tools use, permission keys are glob-matched (match(action, rule.permission)), and the config schema is StructWithRest(..., [Record(String, Rule)]), so an unknown key is valid rather than a config error. Approve mode now writes open-science-browser_agent_browser_*: "ask", with migrate_browser_permission back-filling installs that chose their mode earlier (approve only; "full" means no approvals, and a key already present is never touched). always:["*"] means "allow always" saves a project rule, so this is one prompt per project, not one per browser step. 189 Rust tests green (3 new). Also learned, for the open question of keeping the MCP out of resident context: opencode has no skill-gated tool loading — it does not even parse a skill's allowed-tools — and the only lever is disabled()/visibleTools(), which hides a tool when its last matching rule is pattern:"*", action:"deny"; since rulesets merge per session, a session-scoped deny→allow flip is the shape a "browser off until this conversation needs it" feature would take. Two grep -q self-checks now fail the fetch if either half of the policy is lost. Verified end-to-end after the merge: fetch-agent-browser.sh re-ran for real (v0.32.1) and the regenerated SKILL.md carries both the new description and the bullet, and npx tauri build --bundles dmg produced Open Science_0.4.0_aarch64.dmg (85 MB, SHA-256 d025b732605d7808c441231bbbdb5d7ea990856e6964235c2d64c77bb98cf973) whose release binary contains both the escalation preface and the _agent_browser_* permission key. The console window itself remains unproven — that needs a Windows box.
2026-08-15 02:49 · fix(windows): the black console window users saw on every new screen (#114, second report) was ours — browser_mcp_proxy spawned the bundled agent-browser with a raw std::process::Command, and since the proxy is the app executable re-invoked with --browser-mcp (GUI subsystem, windows_subsystem = "windows", so it owns no console) Windows allocated a fresh console for the console-subsystem child and kept it on screen for the life of the MCP server — one per server the runtime started. The reporter's screenshot confirms the chain exactly: the title reads D:/Open Science/agent-brow… with FORWARD slashes, which is the path shape only runtime.rs's .replace('\\', "/") produces when it writes the proxy command into opencode.jsonc. The tauri shell plugin already sets CREATE_NO_WINDOW for sidecars (verified in tauri-plugin-shell 2.3.5 source), which is why the opencode sidecar itself was never visible. Both proxy spawns now go through runtime::quiet_command, as do the other shipped raw spawns found in the same audit (ACP agent child, ssh -O check/-O exit, pkill, explorer); the raw Command::new sites left in kernel.rs/acp.rs/ssh_session.rs are all inside #[cfg(test)]. Added shipped_code_never_spawns_with_a_raw_command, which scans every src/*.rs up to its first #[cfg(test)] — the invariant was written as a comment above quiet_command and four files had already drifted from it. 187 Rust tests green (1 new), and the guard was mutation-checked (reverting the proxy fix fails it). Unverified on a real Windows box: only the flag semantics are proven, not the absence of the window.
2026-08-15 02:44 · fix(models): surface an unknown context window, which silently disables auto-compaction — root cause of the "spins for 48 minutes" reports. Reversing opencode 1.18.18: Dl() gates compaction on if (model.limit.context === 0) return false, evaluated at every step-finish. Our own SDK deliberately writes context: 0 for custom models whose window we cannot probe (comment at OpenCodeClient.ts:768, from fixing #52 where a guessed 128k manufactured false overflows) — but 0 disables compaction outright, not just overflow accounting, which that comment does not say. Consequence, confirmed on this machine via /config/providers: apevon/gpt-5.6-sol and every deepseek/* report {"context":0} while catalog models report 256k–262k. A user's log then showed one turn running 165 steps with zero compaction events, stalling at step 117 — each step resends an ever-larger conversation until the request stops going through. Explains every reported symptom: a new window works (empty context) then degrades, Codex on the same model does not stall (its own window management), and the compaction UI has never been seen because CompactionRow can never render. Fix surfaces the state instead of guessing a number: listProviders now carries contextLimit (absent and 0 normalised), and the model picker shows a warning — where the model was chosen, one click from Settings → Models, in all seven locales. Settings already had a context-window field; nothing had ever said that leaving it blank costs compaction forever. 979 frontend tests green (3 new), tsc clean, DMG rebuilt. Not yet done: a context-usage meter, and a fallback for models.dev fetch failure (seen timing out in a local smoke test — if it cannot be reached, every window is 0 and compaction is off app-wide).
2026-08-15 01:04 · chore(runtime): pinned OpenCode 1.18.12 → 1.18.18, the root-cause fix for #114 — 1.18.15 stopped repeated compaction dropping orphaned tool results and fixed message chronology, 1.18.17 made compaction keep complete recent turns, which is exactly the malformed message array the AI SDK was rejecting with "does not match the ModelMessage[] schema". Three pins must agree (fetch-opencode.sh, the SDK constant the app displays, fetch-goal-plugin.sh) and a test enforces it; only one had been bumped at first. Goal-plugin smoke test passed against the real app profile in an isolated XDG sandbox: goal still registers as a command, so the vendored 0.1.24 plugin's Effect Schema literals still parse. DMG rebuilt and verified by mounting it — bundled sidecar reports 1.18.18. 978 frontend tests green. The smoke test also caught something the upgrade did not cause: browser-guard.ts fails to load because OpenCode's external-plugin loader calls EVERY module export as a plugin factory, so the three exported helpers ran with no arguments and threw tool.startsWith is not a function; the live 1.18.12 log has 51 such lines across three days, meaning the conversation-owned browser leases shipped in v0.4.0 have never once been enforced — app-owned args were never stripped and the session was never pinned to the conversation. Another agent owns that file, so the fix (unexport the helpers) plus rewritten hook-driven tests and two regression guards were handed over as a patch rather than committed; the rebuilt DMG does contain it, so DMG and tree differ by that one file.
2026-08-15 00:22 · fix(thread): a failed turn no longer reports "done" (#114) — several users hit Invalid prompt: The messages do not match the ModelMessage[] schema mid-session and the UI printed a cheerful done right under the red error, so a request that never ran read as a completed one; the server emits session.idle after a FAILED turn too and the fold appended the line unconditionally. Now suppressed when the thread's last block is an error status-line, which also covers the Stop path's "Interrupted". explainRuntimeError gained an entry for the schema error, following the existing "Request blocked." precedent: the malformed history is resent every turn so retrying reproduces it exactly — the way out is editing/deleting the last few messages or a new session. Root cause is NOT ours: we pin opencode 1.18.12 and upstream 1.18.15 fixed "repeated compaction dropping orphaned tool results" plus message chronology (1.18.17 hardened compaction again), which is precisely the malformed-message-array the AI SDK rejects and explains why a fresh session works and a long one breaks. 976 frontend tests green (2 new), tsc clean. Outstanding: the runtime bump to 1.18.18 (138 MB download + goal-plugin smoke test) is the actual fix and has NOT started; the issue comment says the done half is fixed on master, so this commit had to land to make that true.
2026-08-14 23:42 · fix(permissions): "full" approval mode never actually silenced the path prompts — reversing the bundled opencode 1.18.12 binary showed why: its builtin ruleset is "*": "allow" at the tool level but external_directory carries its own {"*": "ask"} sub-map that pre-allows only the worktree and opencode's private <tmpdir>/opencode scratch dir, so every path outside the workspace (a literal /tmp/x.py included) prompts. Our full mode wrote "permission": {} — zero rules — which overrides nothing and leaves that ask fully in force; the comment claiming the builtin was a blanket allow was true only of the top level. Rules evaluate as one flat list via findLast with user config appended last, so writing our own wins and is additive, never replacing the builtin worktree allows. Full now writes external_directory: {"*": "allow"} (genuinely no prompts, matching the en copy "Runs every command without asking", which had been false); approve pre-allows the OS temp roots only (std::env::temp_dir() + /tmp on Unix, with the macOS /private aliases, in both <root>/* and <root>/** form since the check asks with the target's parent joined to *) and keeps the builtin ask for everything else outside the workspace. Crucially added migrate_external_directory, run at spawn: a mode the user already chose is never re-seeded, so without a back-fill no existing install would ever get these rules — verified against this machine's live config, which reads as full mode with external_directory absent, i.e. exactly the broken state. 186 Rust tests green (3 new), clippy clean on both touched files, DMG rebuilt. Open question left for the user: AGENTS.md still lists "The agent may only access the current workspace" as non-negotiable, but there is no path jail — the sidecar merely gets the workspace as cwd — and full mode now grants outside access explicitly.
2026-08-14 00:32 · fix(windows): hardened the NSIS upgrade path after issue #113 ("Unable to uninstall!" going 0.3.3 → 0.4.0) — the packaging did not change between those tags (identical bundle config, both built by @tauri-apps/cli 2.11.4), so this is machine state, not a regression: the bundler's reinstall page aborts if the old uninstaller exits non-zero or if ai4s-workbench.exe survives in $INSTDIR, and our sidecars are exactly what would keep it there — CheckIfAppIsRunning only ever looks at the main binary, so a leftover opencode.exe/agent-browser.exe locks the install dir and the uninstaller's Delete/RMDir fail silently. Three fixes: a NSIS_HOOK_PREUNINSTALL hook (src-tauri/nsis/hooks.nsh) that kills the three sidecars first (leaves shared msedgewebview2.exe alone), Windows now ships NSIS only (--bundles nsis) because the parallel .msi gave one app two independent uninstall registrations and pushed the NSIS installer down its WiX-migration branch, and installMode: "currentUser" is pinned explicitly. Verified locally with makensis 3.12 (brew): the hook compiles both as definitions-only and with all three insertions expanded (19 instructions, no label collisions — it uses ${If} rather than labels precisely so repeated insertion is safe), with the two plugin calls stubbed as Push "${name}" to preserve stack discipline; the plugin function names and their one-arg/Pop convention are taken verbatim from the bundler's own utils.nsh at CLI 2.11.4, under the same currentUser gate. What that cannot cover is installerHooks path resolution and the real plugin link, so run 31782205512 (workflow_dispatch on master) closed the gap: the Windows job went green with --bundles nsis, and makensis 3.11 compiled the template against the real nsis_tauri_utils 0.5.3 DLL with the hook !included — a missing hook file would have failed config resolution and a bad hook would have failed the bundle, so the whole chain is proven at compile level. It also emitted exactly one bundle (Open Science_0.4.0_x64-setup.exe, no msi/ directory at all) and tauri-action's own "Found artifacts" list — which is what attaches assets on a tag push, independent of our upload-artifact globs — now resolves to that single file. Still unproven, and only provable on a real Windows box: that the hook actually kills the sidecars at uninstall time. Reporter answered on the issue with the in-place "Do not uninstall" route and a warning that the uninstaller's "Delete application data" checkbox wipes %APPDATA%\com.ai4s.workbench (all sessions/runs).
2026-08-13 18:50 · security(deps): cleared the Dependabot board, and the clean-up found a real one — going to dismiss the four alerts as stale, a fresh pnpm audit reported a high I had told the user did not exist: CVE-2026-67213 (nanoid < 3.3.18, GHSA-2v37-7h3g-55p8) was published after v0.4.0 was tagged, and the lock carried 3.3.17 through postcss via vite, tailwind and autoprefixer. Pinned with a nanoid@3 override on its existing major line; audit back to 0 high, build and 974 tests pass. No re-release: nanoid is build-tooling only and appears nowhere in the shipped bundle (dist/assets has no reference, and it is not a desktop dependency). The four alerts were then dismissed as inaccurate, each with the resolved lock version in the comment (brace-expansion 1.1.18/2.1.4/5.0.9, js-yaml 4.3.1, nanoid 3.3.18, postcss 8.5.25); 0 open alerts remain. Lesson: "Dependabot is stale" is only true as of the moment it is checked — re-run the audit rather than reusing an earlier verdict.
2026-08-13 12:55 · release(v0.4.0): v0.4.0 is published — all four platforms green on the re-tagged build, the new "Verify bundled resources" step confirmed the corrected agent-browser skill on each, both DMGs notarized and stapled ("accepted / source=Notarized Developer ID"), 8 installers attached, and releases/latest now points at v0.4.0, which is what the in-app update check reads. Zenodo minted 10.5281/zenodo.21918783 seven seconds after publish; that version DOI is synced into CITATION.cff and all seven READMEs' BibTeX, while the badges keep the concept DOI (10.5281/zenodo.21351225) since it always resolves to latest. All seven READMEs also gained an ACP entry — the headline feature had been absent from every one of them. Still outstanding: the five Dependabot alerts are all stale (the lock carries nanoid 3.3.17, js-yaml 4.3.1, brace-expansion 1.1.18/2.1.4/5.0.9, postcss 8.5.25, each above its advisory range) and could be dismissed, and three moderate transitive advisories through exceljs/pptx-preview have no in-major fix.
2026-08-13 11:20 · fix(build): the v0.4.0 tag build failed and the failure was worse on the platform that PASSED — fetch-agent-browser.sh embeds the adapter prose inside awk's single-quoted program, so the apostrophe in "this conversation's browser" (added with the browser-lease work in 2ce3bf6) closed the quote and left the file unparseable; bash -n had been failing ever since, unnoticed because no release was cut in between. Linux and Windows failed the step outright, which is why the tag produced no installers for them; macOS did not, because bash 3.2 reports the parse error and still exits 0 — and since the script aborts at the awk but the plain cp above it has already run, the macOS bundles shipped skills-agent-browser/open-science-browser/SKILL.md as upstream's UNMODIFIED core skill: name: core (which OpenCode requires to match its directory) and allowed-tools: Bash(agent-browser:*), precisely the CLI guide the adapter exists to replace with the leased MCP tools. Verified by replaying the broken script in a scratch tree rather than inferring it. The prose now lives in a quoted heredoc that awk reads, where apostrophes are inert (output byte-identical to the known-good SKILL.md), and because a fetch can fail without failing its step, the workflow now asserts the bundled resources exist instead of trusting exit codes. v0.4.0 was re-tagged onto the fix; the draft release and its four bad macOS assets were deleted unpublished.
2026-08-12 20:15 · release(v0.4.0): cut v0.4.0 — 45 commits since v0.3.3, headlined by ACP in both directions (#14): AcpRuntime drives any ACP agent through the ordinary UI, and external editors drive Open Science via acp-server.mjs from inside the bundle. Pre-flight is clean: 974 frontend tests, 183 Rust tests, tsc --noEmit, eslint, cargo clippy (warnings only, all pre-existing) and a production vite build; pnpm audit reports 0 high and 3 moderate, all transitive from exceljs/pptx-preview (uuid <11.1.1, echarts <6.1.0) with no fix inside their current major, so they are left for upstream rather than force-bumped across a major. Version bumped in the four manifests, CITATION.cff and the seven READMEs' BibTeX; the Zenodo DOI still points at v0.3.3 until the new deposit publishes. Also corrected the eight PROGRESS entries that had been dated one day ahead of their own commits.
2026-08-12 00:50 · fix(subagents): an opened subagent transcript was laid out as a chat and read as text randomly indented — the expansion reused the main conversation's blocks unchanged, and the opening one is a UserMessage: a right-aligned 85%-wide bubble whose bg-surface-2 matches the row behind it, so in a narrow panel the bubble is invisible and only the ragged indent remains; it is also not the user at all but the brief the PARENT handed the subagent, so it now sits at the top full width and quiet as the task it is, with the steps and answer following. Also stepped the type down (the thread's 15px body is sized for the main conversation and overwhelms a nested read; applied once in CSS since the block components set their sizes explicitly) and added a left rail so the expansion reads as its row's content rather than a sibling of the rows around it. 974 frontend tests, tsc and lint clean.
2026-08-12 00:35 · fix(subagents): none of a reloaded conversation's subagents could be opened — the rows had no chevron at all, which was the panel correctly reporting it had no session to open: the live fold reads the spawned session from the task tool's state.metadata.sessionId, but the history type never declared that field, so historyToThread rebuilt the block without it and every subagent link was lost on reload; both paths now read the same field. Notably this survived review AND the test I wrote for the feature, because a subagent watched live in the session that spawned it keeps the id in memory and opens fine — which is what the test seeded and what a reopened conversation never has. 974 frontend tests, tsc and lint clean.
2026-08-12 00:15 · fix(interaction): the agent's questions can now always be answered in the user's own words — the free-text field existed but was gated on the asking model setting custom: true, which nothing obliged it to do, so an "Other" option routinely had nowhere to type; worse, a single-select question is quick-pick, so clicking "Other" submitted the bare word as the answer and could not be taken back. Every question now carries a dashed "Something else…" row that reveals the field: behind a row so a good option list stays clean, but always present, because whether the user can speak for themselves is not the asking agent's decision to make by omission. The typed text is the answer (never the row's label), opening the field ends quick-pick, an empty field keeps Submit disabled, single-select drops its pick while multi-select keeps picks alongside the text, and a model that DID ask for free text still gets the field outright. 973 frontend tests, tsc and lint clean.
2026-08-11 23:55 · fix(review): reviews were appearing inside subagents with auto-review switched OFF — not the app's doing (shouldAutoReview already refuses when disabled and when the session is a subagent), but the reviewer agent shipping as mode: all, which is primary AND subagent, so OpenCode offered it to the task tool and any model could delegate to it with no app code involved and the user's setting never consulted; verified the mode vocabulary against the pinned sidecar (general/explore/oracle = subagent, title/summary = primary) and set mode: primary, which takes it off the delegation menu while leaving the app's auto-review intact since that pins the reviewer as its own background session's agent — the primary role, not a delegation; a test pins the profile so it cannot silently regress. Reviewing a subagent stays a future feature to design deliberately rather than inherit. 969 frontend tests, tsc and lint clean.
2026-08-11 23:35 · fix(ui): second UI pass, including two misses from the first — a finished subagent row only toggled on its title text so clicking the icon or elapsed time read as "the panel does not respond" (whole row is the button now, and an empty transcript says so instead of spinning forever); the compact toolbar kept its labelled geometry (px-2.5 plus a chevron whose only job is to announce a menu) so icons still ate the row; header tool labels were gated on solo (pane COUNT, not room) when a lone pane in a narrow window is equally cramped — both toolbars now measure their own element via a shared useCompactWidth hook; every running row span its own wheel, so only the turn's status line spins and the rows below breathe with a pulsing dot; and interrupt left in-flight steps marked running so their spinners turned forever on a finished turn — they now settle to "pending", exactly what reloading already renders, for the stopped session and the subagent subtree the abort took down. 968 frontend tests, tsc and lint clean.
2026-08-11 23:05 · fix(ui): four chrome defects from a UI review — the composer toolbar wrapped in a tiled pane because "Approve for me"/"Build"/the model name cannot fit side by side, so below 440px (measured by ResizeObserver on the composer itself, since the pane's width is what matters, not the window's) it keeps icons and drops words while every button retains its aria-label and title; sidebar icons carried lucide's stroke-width 2 against the collapse button's 1.5 and sat in muted grey, fixed by one sidebar-scoped CSS rule (so folder/chevron icons and anything added later inherit it) plus text colour on the nav rows; Settings, the pane header row and every inspector panel header now opt out of text selection the way the sidebar and Screen tabs already did — deliberately NOT a body-level opt-out, since nothing marks message text as selectable and that would have silently broken copying an answer; and the window no longer rubber-bands, since app chrome is not a document — html/body neither scroll nor overscroll, and scrollers no longer chain their leftover gesture to ancestors, which is what let a bounce start from inside a list. 966 frontend tests, tsc and lint clean.
2026-08-11 21:20 · feat(subagents): the subagent panel now opens each row into that subagent's own transcript — it could previously say which subagent, on what and for how long, but a running one showed only a single truncated tool title and a finished one showed nothing, which was the part worth reading; this was a rendering gap rather than a data one (the child thread already lives in the same threads map, loadHistory is session-scoped so it needs no workspace switch, and BlockList renders any blocks array), so rows are collapsed by default and fetched on first open — mounting several tool-heavy child threads at once is exactly the cost behind #92 — with the one-line current step kept only while collapsed and a never-started task left as a plain unopenable row; 964 frontend tests, tsc and lint clean.
2026-08-11 20:05 · fix(perf,ux): corrected my own #92 diagnosis and fixed the real cause — re-measuring with realistic content showed a tool-heavy 50-round session mounts in 76ms (groupToolBlocks collapses tool runs), so transcript length was never the driver; the freeze is resolve_artifact running as a plain #[tauri::command] ON THE UI THREAD while every agent message that names a file resolves it on mount, each miss walking up to 10,000 directory entries — in the reporter's 4 GB / ~40,000-file workspace that is seconds of "Not Responding" per pane switch, and it explains why plain-text sessions stayed smooth at 10 screens while complex ones froze at 2; now (async) like the read_artifact beneath it (whose comment already stated the rule) plus a resolution cache including misses, cleared on workspace change. Also: a permission reply batch used Promise.all so one already-resolved request (404) raised a global error over a click that worked — API errors now carry their HTTP status and 404 means "already answered"; per-agent model/effort changes restart the sidecar and the Settings page collapsed to its connect prompt meanwhile, now masked by the existing switching path; skill adoption failures no longer vanish into the debug log (#103). react-router 7.18.1→7.18.2 clears the only real high — the four Dependabot flags are stale, already patched in the lock, and audit now reports 0 high. 962 frontend tests, tsc, eslint, cargo check/clippy clean.
2026-08-11 02:35 · fix(chrome): traffic-light alignment CONFIRMED fixed on a packaged build, after the previous attempt changed nothing — the inset lived in three places and the JSON was not the one that won: macos.rs re-pinned the lights from its own hardcoded constant on every focus/resize/theme event, after window creation, so editing both JSON configs was silently reverted on the next focus; that triple copy (with the deciding one being the least visible, its comment merely asking to be kept in sync) is the mechanism behind this regressing repeatedly, so the re-pin now READS trafficLightPosition from the window's own merged config and the constants remain only as a fallback — one source of truth, and the configured 26 finally applies; header drag also confirmed fixed. Lesson recorded: when a fix "changes nothing", look for a second writer of the same value before adjusting the value again.
2026-08-11 02:10 · fix(chrome): two defects in the Screen-tab header, both measured rather than guessed — the collapse button and the tabs sat 8 device px (~4pt) below the traffic lights' centre (located by pixel in the reported screenshot; the relative offset survives any doubt about the capture's scale or crop), because the strips centre content in a 48px box while the native lights are placed by trafficLightPosition, so the lights move down to meet the content in BOTH window configs since macOS replaces the whole windows array on merge — the silent divergence that most likely caused this to regress once already, now pinned by a test; and only two hairlines of the header could drag the window, because Tauri 2.11.5's drag.js resolves a bare data-tauri-drag-region as el === composedPath[0] (direct clicks only, no inheritance) while the flex-1 tab row covering all the width beside the tabs carried no attribute — the row now has one, so empty header space drags while tabs and the + button stay interactive (a <button> blocks drag outright); 956 frontend tests, tsc --noEmit and lint pass, alignment value is empirical and awaiting confirmation on a real build.
2026-08-10 23:50 · fix(notebook): fixed a freeze I had just shipped — #98 made a cell's index renumber on every structural edit to keep the [n] label positional, but index is also the React key, so inserting or deleting near the top changed the key of every cell below and React destroyed and rebuilt them all (outputs and figures included), which on any real notebook is a visible hang; index is now a stable identity and the displayed number comes from position at render time, with selection tracking an id and j/k stepping by position; the reproduction that mattered asserts DOM-node identity survives an insert/delete — the rapid-interaction stress test passed even before the fix because jsdom has no layout or paint cost, a reminder that "tests green" in #98 was not evidence of no regression; 952 frontend tests, tsc --noEmit and lint pass.
2026-08-10 20:45 · fix(models): a configured per-agent model now actually runs the turn — every composer send used to pass an explicit per-turn model, which overrode exactly that setting, so the build row in Settings did nothing to the messages you send and Plan mode ignored its own plan model; precedence is now conversation pick > agent config > global default, with two guards (nothing inferred from a catalog lacking the agent, and the default still pinned when no agent model is set so #8 holds), and the UI half is fixed too — the chip mirrors the send's precedence instead of advertising a model the turn will not use, the popover names a non-default source only when there is one, a per-session pick can finally be cleared, and Settings states which agents run your messages (new strings in all 7 locales); separately, #92 was profiled rather than guessed at: thread mount is ~0.8 ms/message and linear (1000 messages ≈ 827 ms), about half markdown+KaTeX parse and half React DOM construction, with no cross-mount cache — and caching rendered markdown was measured and REJECTED (reusing React elements does not skip a mount), leaving windowing or keep-panes-mounted as the two real options, both architecture calls left open; 948 frontend tests, tsc --noEmit and lint pass.
2026-08-10 19:40 · fix(session/notebook): cleared the zero-reply community backlog — image attachments now ride as OpenCode file parts so a vision model sees the figure instead of its filename (probed the pinned 1.18.12 sidecar first: a data: url stores, a file:// url returns 204 and then silently drops the whole message), and the notebook editor gained Jupyter's edit/command split (Esc/Enter, a/b insert above/below, j/k selection, discoverable insert buttons, renumbering so [n] stays positional) where previously "Add cell" only ever appended to the end and no shortcut reached the notebook; a bubbling bug caught by test — keys typed in a cell reached the command handler, so "ab" inserted two cells — is guarded and regression-tested; answered #84 (the /v1 surface is ~70% of the proposed CLI, but the gateway lives inside the Tauri app so it is a remote control, not the headless HPC story, and project-scoped session creation / --wait / model pinning must land first), #86 (interactive fallback is buildable now, automatic fallback is blocked on RuntimeErrorEvent carrying only free text — no status code to classify 429/5xx/timeout without regex-matching provider prose), #80 and #81; 942 frontend tests, tsc --noEmit and lint pass; open bug #92 (split-pane slowness) still unreproduced and #96 (Plan mode ignores its per-agent model) awaits a precedence decision.
2026-08-10 07:55 · fix(session/layout): cleared the community backlog's split-pane state bugs and the non-Latin provider-name report: switching screens no longer carries one screen's unsent composer text into the next (the active pane was rendered without a React key, so React kept the outgoing pane's component while the header switched sessions — one Enter from sending a prompt to the wrong session), each pane's draft and attachments are now parked while off-screen and handed back on return, and preview screens stop piling up without limit (setActiveGroup pinned the tentative screen on the way out, so three sidebar clicks left four screens and users reported dozens); merged #90 for non-Latin custom endpoint names and followed up so two names sharing an ASCII skeleton (音云 API / 星河 API) can no longer claim the same provider key and silently overwrite each other's config; every fix landed behind a failing-test-first reproduction, 928 frontend tests, tsc --noEmit and lint pass, and the unsigned Apple Silicon DMG builds (Open Science_0.3.3_aarch64.dmg, SHA-256 6a141084e59acc50fd2cf6dde4a0d5de56330072e044f157fc71b5e0c6cac1be); #92 (split-pane slowness) is not fixed — thread rows are already memoized, so the stranded-screen multiplier is the live suspect and the reporter has been asked for pane-vs-conversation-size detail.
2026-08-10 02:33 · fix(browser): replaced the prompt-only browser lifecycle workaround with an app-enforced MCP ownership boundary: every OpenCode conversation gets a deterministic isolated lease, inventory exposes only its own browser/tab details while other conversations remain aggregate-only and external user Chrome is never attached or inspected, empty-launch and cross-session/close-all escape paths are blocked, copied-profile startup tabs are pruned before model access, and normal close plus a 10-minute idle/app-exit fallback reclaims app resources; the packaged proxy/schema, two-conversation privacy and no-empty-window flows, production build, lint, typecheck, 913 frontend tests and 183 Rust tests pass, and the mounted unsigned Apple Silicon DMG verifies and contains the official skill/guard (Open Science_0.3.3_aarch64.dmg, SHA-256 de6f8b968cd41026fc82f6c58edc189129ec2793dc82a0e1cd185684450be91c).
2026-08-09 23:58 · fix(browser): replaced prompt-only lifecycle guidance with a runtime guard that strips model-supplied profile-incompatible allowedDomains and app-owned session/launch overrides before MCP execution, migrates existing connector configs to the private namespace and 3-minute timeout, and closes legacy default sessions once; the recorded failing arguments, config migration, typecheck, lint, production build, 911 frontend tests, and 179 Rust tests pass, and the mounted unsigned Apple Silicon DMG contains the guard and strengthened official skill (Open Science_0.3.3_aarch64.dmg, SHA-256 cf6cad83ee51d9608153c6b1d8ab125870e807926590e1fe7efe1e153653acf0).
2026-08-09 05:50 · fix(browser): prevented Chrome-profile/allowedDomains conflicts, isolated Browser Control in an app-owned namespace, added active close on reconfigure/disable/exit plus a 3-minute idle fallback, and taught the bundled official v0.32.1 skill to reuse and reclaim its session; the exact example.com → bioRxiv one-tab flow, idle teardown, typecheck, production build, 909 frontend tests, and 177 Rust tests pass, 11 legacy orphan daemons were removed without touching ordinary Chrome, and the verified unsigned Apple Silicon test DMG is Open Science_0.3.3_aarch64.dmg (SHA-256 7596876fac3c82ea4f1ba4bded343405f37d8386cc3d753237b156d185cc0c80).
2026-08-07 09:43 · package(browser): built and checksum-verified an unsigned 84 MB Apple Silicon test DMG containing the official agent-browser v0.32.1 skill (Open Science_0.3.3_aarch64.dmg, SHA-256 7be1eb90970a3906f8437770e48b10019f19d8f7e62cf54761745124226ea4af); the mounted bundle contains the expected skill and version marker and fails strict code-signature verification as intended.
2026-08-07 02:33 · fix(browser): bundled agent-browser's official version-matched v0.32.1 skill with a thin MCP lifecycle adapter, renamed the connector to open-science-browser, migrated existing configs while hiding the incompatible legacy user skill, and made sidecar downloads atomic; 906 frontend tests, 24 config tests, typecheck, ESLint, production build, generated-skill checks, and the restored sidecar version check pass.
2026-08-07 01:32 · security(deps): raised Vite to 6.4.3, Vitest to 3.2.7, brace-expansion to patched per-major floors, and js-yaml to 4.3.1, removing every applicable high/critical npm finding (including one critical Vitest issue not yet shown by Dependabot) while preserving the Node 20 build baseline. The sole remaining audit record, GHSA-qwww-vcr4-c8h2, affects only React Router's unused unstable RSC APIs; this client-only SPA imports none of them, while the first patched release requires an unrelated React 19.2.7/Node 22.22 migration. Frozen installation, 906 frontend tests (3 real-agent tests skipped), typecheck, ESLint, production build, and all 173 Rust tests pass (1 live-host test ignored).
2026-08-07 00:59 · fix(#72): turning auto-review off now immediately aborts the active hidden reviewer, removes every queued review, and rechecks the switch before async setup or queue draining can start more work; ACP settings no longer show the OpenCode-only control. Three cancellation/race regressions and one ACP visibility regression are covered; all 906 frontend tests pass (3 real-agent tests skipped), along with typecheck, ESLint, production build, and all 173 Rust tests (1 live-host test ignored).
2026-08-07 00:28 · fix(#14 client): removed a real supervisor race behind opaque packaged-App ACP handshake timeouts — the exit waiter previously started before the child was registered, so an immediately exiting agent could be mistaken for a locally stopped one and disappear without the acp:exit event while the frontend waited the full 60 seconds; children are now registered before monitoring starts, exit observation and removal are atomic, and bounded diagnostics record only PID, first stdin/stdout byte counts and exit status (never commands, protocol content or credentials). An unsigned bundled .app then drove the cached codex-acp entry through the real WebView → Tauri → Rust → child path: initialize wrote 168 bytes, received the first 689-byte response about 0.41 seconds later, reached ready, and completed connect OK, proving signing is unrelated; the temporary localStorage test entry was removed and the installed app restored. The immediate-exit regression and all 174 Rust tests pass (173 passed, 1 live-host test ignored), as do 898 frontend tests (3 real-agent cases skipped), typecheck, ESLint, production build and the final unsigned App bundle; Clippy completes with 16 pre-existing Rust 1.96 warnings outside acp.rs and no warning from this change.
2026-08-06 07:23 · package(#72): rebuilt the merged background-review fix from master as an 83 MB arm64 DMG (SHA-256 b25ce5ddb78158a80c0cf0f0fdbcff5eb9fb5405ab92694289c95a124459e216); the app, bundled sidecars and DMG pass strict Developer ID signature verification, and the mounted image passes hdiutil verify. This local test build uses an offline signature without an Apple timestamp because the timestamp service was unavailable, so it is not notarized for public distribution.
2026-08-06 06:44 · fix(#72): auto-review now runs in an archived checkpoint fork instead of a foreground turn, keeps the parent usable, persists structured findings back onto the reviewed assistant checkpoint, scopes the review to files changed by that turn, and explicitly aborts hidden reviewers when their parent is deleted or the runtime disconnects; rebased onto the current ACP runtime without exposing unsupported background review there. 901 frontend tests pass (4 real-agent/artifact tests skipped), plus typecheck, eslint and production build.
2026-08-06 05:37 · test(#14, macOS): a manual ACP smoke test against the rebuilt 0.3.3 DMG proved the server direction at the real gateway boundary with the official @agentclientprotocol/sdk 1.3.0 client — initialize, a streamed Chinese turn with monotonic deltas and completed read/bash tool calls, session/list, session/load, and the explicit mismatched-directory refusal all passed; disabling remote access produced the expected readable connection error, and 42 focused protocol/MCP/permission tests plus all three real codex-acp tests passed. The desktop client direction is blocked in the shipped app: selecting the Codex preset starts the child, but initialize times out after 60 seconds and retries; replacing npx with the cached package's direct node dist/index.js entry fails identically, while the same entry in the same LLM-train cwd initializes successfully through the official SDK, isolating the failure to the packaged app's ACP child bridge/front-end call path rather than codex-acp or the workspace. Zed was configured with the shipped-equivalent agent entry and opened on the matching workspace, but its custom-drawn agent menu was not accessible to automation, so Zed's visual tool/permission/history rendering remains unverified.
2026-08-06 01:35 · fix(#14): verified the ACP work against the OFFICIAL library rather than only against ourselves — and it immediately found a real interop bug. Everything up to now had our own client on one end and our own server on the other, so a shared misreading of the spec would have passed every test. @agentclientprotocol/sdk 1.3.0 (the reference implementation editors are built on) now drives the SHIPPED artifact — runtime/acp-server/acp-server.mjs, spawned as a real child process against a stand-in gateway — through a whole editor workflow: initialize → session/new → a streamed turn → session/list → session/load, with every message decoded by the published schema. The bug: the official McpServerStdio has env: z.array(...) with NO .optional(), while toAcpMcpServers omitted env whenever a connector needed no environment variables — which is most of them. An agent validating with that SDK (codex-acp does) would have refused session/new outright, so the connectors feature shipped yesterday would have failed for exactly the common case; the real-agent test missed it because it sends no connectors at all. env now always travels, empty list and all, and the mapping's output is pinned to the official McpServer type at compile time so the next drift is a type error rather than a refusal in someone's editor. The interop test skips when the bundle has not been built (pnpm --filter @ai4s/desktop build produces it) and is deliberately about the artifact, not the source. 898 frontend tests (+2), 172 Rust tests, typecheck, eslint and clippy pass; the client half re-verified against real codex-acp (all three cases), and the DMG rebuilt (84 MB arm64, SHA-256 e5d483b3d18347b89554543065b5ea193ed3b8a1cbc4cd1c9f6a8fe0fe356a1e). Still unproven, and only provable on a machine with the editor installed: how Zed/JetBrains render our tool calls and permission prompts.
2026-08-06 00:45 · fix(#14 server direction): a self-review of the ACP server found two defects, both about pretending. (1) Permission asks were relayed to the editor for EVERY session in the workspace — the runtime's event stream is workspace-scoped, so an approval for work the user started in the desktop window popped a dialog in an editor that never asked for it, and whichever surface answered first left the other holding a prompt that could never resolve. Relayed now only while that editor has a turn in flight for the session, which is what makes the approval attributable to the surface that asked for the action (AGENTS.md); a subagent's ask, whose session is not the editor's, stays with the desktop. (2) session/new silently dropped the mcpServers the editor sent: this runtime brings its own connectors and has no per-session ones, so the editor was left believing tools were available that never arrive. Refusing the session over something optional would break the integration, so the session is created and the omission is stated through a new onNotice seam — wired to stderr, which is where an editor shows agent logs and the only channel ACP leaves free (stdout is protocol-only). 896 frontend tests (+2, one per defect), 172 Rust tests, typecheck, eslint and cargo clippy (one pre-existing warning in untouched HTTP-parsing code) pass; DMG rebuilt (84 MB arm64, SHA-256 480fe22417aedd58a96f4142f76ff4a0498582a04cb6dcb9b73ad542e48705d8).
2026-08-06 00:25 · feat(#14 server direction): Open Science is now an ACP AGENT other editors can drive — Zed, JetBrains, Neovim, anything that speaks the protocol. The other half of the same seam: where AcpRuntime makes this app a CLIENT of Codex, AcpAgentServer exposes our AgentRuntime in ACP's dialect, so nothing about sessions, streaming, permissions or history is reimplemented — the file is a translator over an already-normalized stream. The inverse of the client half's hardest detail lives here: our runtime emits the FULL current value of a text part while ACP carries DELTAS, so every chunk is diffed against what was already sent (sending the full value would make an editor render "ok" as "ook"), and the round trip is the test — our own client drives our own server through a pipe, so a bug in either half shows up as mangled text. Also mapped: history → session/load replay notifications (a frozen mid-run tool reads as failed, not eternally running, since an editor has no way to resolve it), our sessions → session/list with the folder each belongs to, session/cancel → abort with stopReason "cancelled", a failed turn → a JSON-RPC error rather than "end_turn" (which would tell the editor the work finished), and permission asks → session/request_permission in the EDITOR — approval belongs where the user is working, and anything that is not an explicit selection is a rejection, never an inferred approval. One refusal is deliberate: session/new with a cwd other than the workspace the desktop is in is rejected, naming the folder to switch to, because a session created anyway would edit files in a directory the editor is not showing. Transport-wise there was no choice to make — stdio is the only transport v1 stabilizes (Streamable HTTP is still a draft proposal), so the integration IS a spawnable process: scripts/build-acp-server.mjs bundles the SDK into one acp-server.mjs that ships inside the app (Contents/Resources/acp-server/), it connects to the shipped gateway with a token exactly as the LAN web client does — no new surface, no new credential — and Settings → Remote Access shows the agent entry to paste into an editor, with the token in the ENV rather than the argument list (an argv is visible to every process on the machine). Verified as the artifact, not just as code: 894 frontend tests (+11, including an end-to-end that stands up an HTTP+SSE stand-in gateway, runs serveStdio over real node streams and drives it with our own AcpRuntime), and the DMG was MOUNTED to confirm acp-server.mjs is inside the app bundle and runs from there (84 MB arm64, SHA-256 27342be980bc41020d56abeb6c821b54f3ab8fcae4435d005509740517f4be73). Typecheck, eslint, 7-locale parity and cargo check pass. Not done: an editor's own fs/terminal client capabilities (we advertise none, so the agent's tools stay ours), image prompts, and verification against a real third-party editor — the client used here is ours.
2026-08-06 00:05 · fix(#14): a signed-out ACP agent now says what to do instead of dying with a raw refusal. ACP's auth_required (-32000) is what an agent answers when it needs ITS own sign-in — Codex's ChatGPT login, Gemini's Google account — and this app holds no credentials for it, so there is nothing to fix in Settings and the bare error was a dead end. session/new (and a restore, which a logout can strand too) now rethrows it as " is not signed in: <the agent's own words>. Its sign-in is its own — run the agent's login command in a terminal (it offers ChatGPT), then reconnect in Settings → Runtime", naming the methods the agent listed at initialize; every other failure passes through worded exactly as the agent worded it, with a test pinning that. Deliberately NOT wired to ACP's authenticate, after checking what other clients do rather than assuming: agent-shell (Emacs) sends no authenticate request at all by default and documents that login is handled externally ("run agent login once outside Emacs"), and vscode-acp implements it only as a fallback after an auth_required error, never as the primary path. A user who configured npx … as the agent command has the terminal that login needs, and codex login persists for every client on the machine; in-app login only earns its keep where there is no terminal (a phone) or where the agent authenticates by pasted key — neither is our ACP mode, which is desktop-only. Also not built for the same "is it actually stable?" reason: the agent providers capability, which is JetBrains' draft RFD (providers/list/set/disable), not v1. 883 frontend tests (+2), typecheck and eslint pass; DMG rebuilt (84 MB arm64, SHA-256 020b79ffe8ec56a2b1537590f1d23314481e393cc8c01eac687059ad3290fcae) with the sentence verified present in the shipped chunk.
2026-08-05 23:45 · fix(#14): a session survives the agent process restarting — session/resume, which turned out to be a correctness repair rather than the speed optimization the roadmap called it. The stranding case: the ACP child restarts (a crash, or the user switches agents and back) while the conversation is still on screen, because the thread lives in this app's memory; the next prompt then went to a session the NEW process had never heard of and failed with "unknown session". sendPrompt now restores an unknown session first — session/resume when the agent advertises it (the spec's own guarantee: it restores the context and reconnects the MCP servers and MUST NOT replay), falling back to session/load with its replay diverted and discarded, since the transcript is already displayed, and refusing outright only when the agent can do neither. The session is registered BEFORE the call so the selectors and title the agent reports while restoring have somewhere to land, and removed again if the restore fails, so a dead id cannot look live. It restores into the folder the session BELONGS to: session/list results are now remembered by id, because a conversation from another project must not be resumed into whichever folder the app happens to be standing in. Verified against the real agent in the way that actually proves it: two separate codex-acp PROCESSES, the first told to remember a word, the second — which never created the session and never loaded it — asked what the word was, and it answered "pangolin". 881 frontend tests (+4: resume, the load fallback keeping its replay out of the thread, the refusal, and the folder), typecheck and eslint pass; DMG rebuilt (84 MB arm64, SHA-256 f0373ce1946e0b50a4e888f21889351b74788c74a4886391b70f017543626bc3), the shipped chunk carrying session/resume. Also answered from the spec rather than guessed: the agent's providers capability is JetBrains' still-DRAFT "Configurable LLM Providers" RFD (providers/list/set/disable, for pointing an agent at a proxy or self-hosted model), so it is deliberately not implemented; the agent's own sign-in (authMethods + authenticate + auth.logout) is the stable one and is unrelated to our keychain — it is Codex's ChatGPT login, not our provider keys.
2026-08-05 23:15 · feat(#14): an ACP agent now gets THIS app's MCP connectors. The gap was structural, not a bug: ACP takes MCP servers per SESSION (session/new / session/load) while OpenCode keeps them in its global config, so our session/new had been sending a literal empty list — an ACP agent ran with none of the science databases the user configured, which reads as the feature being broken rather than unimplemented. toAcpMcpServers translates the config (a local entry's command[] splits into ACP's command + args, environment becomes name/value pairs; a remote entry becomes the HTTP transport with its headers), and the runtime filters by what the agent NEGOTIATED — every agent must support stdio, HTTP and SSE are optional mcpCapabilities, and sending one an agent never advertised is a protocol violation on our side. Disabled connectors stay disabled: the agent cannot see OpenCode's config, so passing one through would quietly switch it back on. The config is read through a throwaway OpenCodeClient (the sidecar runs either way — it backs the workspace, kernels and gateway — while getClient() keeps answering null so Settings hides a provider surface that is not driving anything), awaited only when the child is fresh: every new session reconnects, and paying a config round-trip each time would put the sidecar's latency in front of every "New". A connector's own credentials travel with it, which is what makes it work and what the bundled runtime already does; no provider key of ours is in that payload. 878 frontend tests (+5), typecheck and eslint pass, and the real-agent test still passes against codex-acp unchanged. DMG rebuilt (84 MB arm64, SHA-256 6a6448dcaba564bb88b6c2a515c206c2311f8db891df8bcaea442a24025a8cbf), the shipped chunk carrying mcpServers and the mcpCapabilities gate.
2026-08-05 21:20 · fix(#14): the ACP work was built against a STALE reading of the protocol, corrected here against the spec repo and a real agent's source (both on GitHub, not blog posts). What the current v1 actually has (schema 1.6.0, 2026-07), all capability-gated in initialize: session/list, session/load (history replayed as notifications), session/resume, session/close, session/delete, additionalDirectories, and session/set_config_option + configOptions with semantic categories (model, model_config, thought_level, mode) — which the spec calls "the preferred way to expose session-level configuration" and says will replace modes. agentclientprotocol/codex-acp, the agent we ship a preset for, advertises loadSession plus sessionCapabilities.{list,delete,resume,close,additionalDirectories} in CodexAcpServer.ts. So three things this app's own comments called impossible in ACP — listing, history, model selection — were merely unimplemented, and the prior entry's "listing and replay are one later slice" was reasoning from a wrong premise. The costliest correction is process lifetime: the spec requires a session's cwd to be used "regardless of where the Agent subprocess was spawned", so ONE child serves every folder, while we killed and respawned the agent on every workspace move — which is every new session, i.e. an npx cold start per session, taking every other session that child held down with it. (recailai/jockey, the closest open-source sibling — Tauri + Rust, multi-agent ACP — goes further the same way: a connection pool keyed by app-session × runtime × role, with prewarmed children.) Changed, all behind the same AgentRuntime seam: the ACP runtime now OUTLIVES reconnects (module handle + teardownClient(keep) + an idempotent connect() + setCwd for where the next session lands, with existing sessions keeping their own folder); capability getters replace the hard-coded refusals; listSessions walks session/list (paged, 20-page bound, deliberately UNfiltered by cwd — filtering would hide every conversation outside the current folder, and the sidebar groups by folder itself — and falling back to the local list on failure, since a transient RPC error must not read as "your history is gone"); getMessages runs session/load and collects the replayed notifications as HISTORY instead of emitting them (a ReplayCollector, the inverse of the live fold, marking every message completed — an assistant message with no completion time is how this app recognises a turn in flight, so a reopened session would otherwise show a spinner and a Stop button for a turn that ended days ago); deleteSession calls session/delete when advertised and puts the local entry back if it fails; and the composer renders the AGENT's own selectors (AcpConfigPicker) where OpenCode's model picker sits, wired to session/set_config_option — whose answer replaces the list wholesale, because picking a model can change which reasoning levels exist. Two guards each worth a line: a replay is refused while a turn is running (the diversion would swallow the tokens that turn is streaming, and an empty history correctly reads as "still running" to the reconciler), and config/title updates are handled BEFORE the replay diversion because they are session STATE, not conversation — a reopened session must show the model it is actually on. The store test's mock client gained getStatus(): the store now takes the status from the runtime after connecting instead of waiting for a transition, since a reused agent is already ready and emits nothing. Verified against the REAL agent, not only the fake: @agentclientprotocol/codex-acp via ACP_TEST_COMMAND=npx ACP_TEST_ARGS="-y @agentclientprotocol/codex-acp" answers list: true, replay: true, delete: true and exposes FIVE selectors on a fresh session — mode=agent, collaboration_mode=default, model=gpt-5.6-sol, reasoning_effort=high, fast-mode=off — i.e. exactly the model and reasoning-effort switch this app had been hiding on the grounds that "ACP has no model selection". The extended real-agent test asserts the CONTRACT rather than one agent's features (if it says it can list, the session just created is in the list; if it says it can replay, the history comes back non-empty, completed, and without emitting a single live event), and it round-trips session/set_config_option by setting a value to itself. 873 frontend tests (+8, plus 1 real-agent test that stays skipped without ACP_TEST_COMMAND), typecheck, eslint and 7-locale parity pass; no Rust change. DMG rebuilt (84 MB arm64, SHA-256 63c6c509c37888dea0162f53463e6c04d293aba395b78b17a69ff18f8c084b04) and verified by CONTENT — the embedded chunk carries session/list, session/load, session/set_config_option, config_option_update and the capability gating. Still not done on this axis: the ACP server direction, session/resume (a faster reopen than replay), and consuming an agent's providers/auth capabilities.
2026-08-05 18:36 · feat(#14): ACP is user-visible — Settings → Runtime now picks WHICH agent this app drives, and lib/runtime.ts's connect() builds an AcpRuntime over the Rust-supervised child instead of an OpenCodeClient when one is selected. The seam did its job: nothing in the thread, the send lifecycle, provenance or the run ledger changed, and a turn through @agentclientprotocol/codex-acp folds into the same blocks. Two defects the wiring exposed, both fixed with a test that fails without the fix. (1) A session/prompt is ONE request that answers when the turn is OVER, unlike OpenCode's prompt_async which answers when it is accepted — so performTurn took the running lock AFTER the turn's own session.idle had already cleared it, leaving a spinner turning under a finished answer until reconcileRunning polled 15 s later. ACP turns therefore go through the store's existing syncTurn path (lock before the call, cleared when it settles), which is exactly the case that path was written for. (2) acp_start is idempotent per agent id and the old transport's close fires acp_stop WITHOUT awaiting it, so after a workspace switch (teardown → connect, the path every new session takes) the start could adopt the still-running child — whose cwd is the folder the switch had just left; acpTransport now awaits the stop before the start. What is deliberately withheld in ACP mode rather than shown doing nothing (AGENTS.md): the composer's model picker and approval switch (ACP v1 has no set-model method and the agent asks permission on its own terms), the plan pill (already automatic — it needs a plan agent in the catalog), Settings' provider/MCP surface (getClient() answers null), and cross-folder background streams. Configured agents live in localStorage, not OpenCode's config — it is app behaviour, and OpenCode must not be asked to describe the runtime replacing it; deleting or unselecting one falls back to the bundled runtime rather than leaving the app with no runtime at all. Presets for the three agents actually probed (Codex, Gemini CLI, Claude Code) are one click, still ordinary editable entries afterwards. 865 frontend tests (13 new: the selector driving a fake agent end-to-end, the card, and the argument-line parser), typecheck, eslint and 7-locale key parity pass; no Rust change. DMG rebuilt (84 MB arm64, SHA-256 469cd20fe39e1c8f73058acb8fdfec4913a30507812d6d097fd7ec1d48db1c52) and verified by CONTENT — the embedded frontend chunk carries ai4s.acp.active.v1, the codex-acp preset, the acp_start bridge, session/prompt and the runtimeKind field, beside the Rust acp_start/acp:line symbols already in the binary. Known and NOT fixed here: an ACP session is not listed after a restart (listSessions is in-memory) and session/load replays notifications instead of answering with a transcript — listing and replay are one later slice, since replay alone would restore a conversation nothing can reopen.
2026-08-05 11:40 · feat(#14): the ACP child now has a supervisor — src-tauri/src/acp.rs spawns a configured agent, relays its stdout as acp:line Tauri events and accepts lines through acp_send, with lib/acpTransport.ts as the other end of that pipe (the JsonRpcTransport AcpRuntime takes). This is the half the SDK slice deliberately left out: the webview has no child_process, so the process lives beside the OpenCode sidecar instead. Safety, per AGENTS.md: nothing starts an agent on its own — a child appears only for a command the user configured and asked for — it runs in the active workspace folder and nowhere else, it inherits enriched_path() because an ACP agent is usually a node or cargo binary the user installed, and shutdown kills every child on app exit, since an agent process outliving the window keeps model access the user believes they closed. acp_start is idempotent per agent id (a double click cannot orphan a child holding a model session) and clears a dead entry so the next start really starts one. Three details that would each have cost a hung handshake: the frontend attaches its line and exit listeners BEFORE acp_start returns, because a fast agent writes its first line before the spawn call has even come back and a line nobody listens for is gone; acp_send appends the newline when the caller's line lacks one, since two merged JSON-RPC messages are one unparseable line; and the exit event exists so the peer fails its pending requests instead of waiting forever on a process that is gone. stderr is not protocol — agents log to it freely — so it is kept only as the tail of the exit reason, which is where "command not found" or an auth failure is actually diagnostic. The line splitter is the one piece with real logic and it is a pure function with tests: a read boundary lands mid-message routinely, and handing half a JSON object to the peer would drop the message it belongs to. 172 Rust tests (2 new), 852 frontend tests, typecheck, eslint and clippy (nothing new in acp.rs) pass. Still to come before this is user-visible: the Settings agent picker, the runtime selector in lib/runtime.ts that constructs AcpRuntime instead of OpenCodeClient, and session/load replay so reopening an ACP session shows its history.
2026-08-05 11:05 · feat(#14 client direction): the ACP client layer lands — AcpRuntime, a second AgentRuntime beside OpenCodeClient, verified against a real agent rather than the spec alone. The direction was settled by evidence, not by the roadmap: PRD §9 and RFC #25 both framed v0.4.0 as Open Science as an ACP server, but #14's reporter had asked in comments for the opposite ("I'd like OpenScience to be able to invoke Codex ACP, Cursor Agent ACP"), so client-first it is and §9 now says so. Probed three real agents before writing anything: @agentclientprotocol/codex-acp 1.1.9, gemini --acp 0.33.1 and @zed-industries/claude-code-acp 0.16.2 all answer initialize with protocolVersion 1 and the same shape, which is why no agent-specific code exists here. codex-acp is the one that reaches a full turn on this machine (gemini's session/new is refused for personal Google accounts — "migrate to the Antigravity suite"; claude-code-acp refuses to start inside another Claude Code session and warns that bypassing the check crashes every live session, so it was not bypassed). Three RFC open questions are now answered from that evidence rather than left for discussion: (3)+(4) the runtime takes an injected line TRANSPORT and never spawns the agent, because the webview has no child_process and the gateway web client has no local process at all — Node injects a spawning stdio transport (@ai4s/sdk/acp/stdio, node-only, kept out of the browser barrel and aliased ahead of the bare @ai4s/sdk prefix), while the desktop will relay a Rust-supervised child; (1) model selection is the AGENT's, since ACP v1 has no session/set_model and codex-acp encodes reasoning effort inside the model id (gpt-5.6-sol[high]), so setDefaultModel refuses loudly instead of pretending. Two properties are load-bearing and each has a test that fails without it: ACP streams agent_message_chunk as DELTAS while our text.updated carries the full current value keyed by partId, so the runtime accumulates (passing the delta through would render "ok" as "k"); and a permission is answered by RESPONDING to the agent's own blocked request, mapping our once/always/reject onto the agent's optionId by its kind — never by position, and an unmatched allow resolves to cancelled rather than silently selecting some other option. The real-agent test immediately earned its keep: it failed on a monotonic-growth assertion and the cause was real, not a bad assertion — codex-acp precedes its answer with an id-less chunk (a skills-budget warning), and the single shared fallback key would have glued every id-less message in a session into one growing block; the key is now per turn. Everything ACP v1 cannot do (history on reopen — session/load replays notifications instead of answering with a transcript — revert/unrevert, archiving, shell-outside-a-turn, questions) throws or returns empty with the reason, rather than failing silently. 852 frontend tests (14 new fake-agent + 1 real-agent, the latter skipped unless ACP_TEST_COMMAND names a binary, mirroring the ssh test that needs a host you can already log in to), typecheck and eslint pass. NOT done yet, and next: the Rust child supervisor + Tauri transport, the Settings agent picker, and session/load replay.
2026-08-05 09:55 · release: v0.3.3 published as Latest with six installers — the 15 commits that had accumulated since v0.3.2 plus the three defects the pre-release review found. All four build jobs green; both macOS DMGs verified ON THE RELEASED ARTIFACTS, not on a local build (stapler validate passes and spctl --assess answers accepted / source=Notarized Developer ID for aarch64 SHA-256 5e78de3554b1ccaeda16db2f29b7ae89ae1190a365aaf22f7bafbc46fed31555 and x64 SHA-256 14964b8b848ee116f96a2d31bb4153a9a4d52bded152ace038718968f69ebdb9). The notarization cron skipped its slots again (nothing ran between the 08:41Z dispatch and 09:42Z, so 08:47Z and 09:17Z were both missed) and BOTH stages were dispatched by hand — the same failure as v0.3.2, which makes it the rule rather than an incident. The two unused tauri updater .app.tar.gz bundles were dropped so the asset set matches v0.3.2's six. Synced the new Zenodo version DOI 10.5281/zenodo.21805331 into CITATION.cff + 7 README BibTeX blocks (concept DOI 10.5281/zenodo.21351225 stays in the badge). Version numbering deliberately stayed on the patch line: PRD §9 and the GitHub milestone both reserve v0.4.0 for ACP (#14, still open), and §9's own "Shipped" list is five releases stale — it still records only v0.1.x and v0.2.0 while the v0.4.0 headline deliverable (the gateway + LAN web + CLI) actually shipped in v0.2.3, so the roadmap could not answer the version question and is worth reconciling separately. Also triaged #77 (Linux UI unusable while a project saturates the GPU): the reporter's GPU-contention theory holds for WebKitGTK compositing and we ship no mitigation (no WEBKIT_* anywhere in the tree), but it does not explain a full minute — "+ new project" awaits switchWorkspace, which reconnects the event stream and reloads the catalog with the UI blocked, and foldEvent clones the block array plus re-scans the whole accumulated message text through splitReview on EVERY streamed token (the SDK emits accumulated text, not deltas), which #50's throttle never covered. Asked for version/distro/driver, whether "+" is slow with no turn streaming, and a WEBKIT_DISABLE_DMABUF_RENDERER=1 A/B; not fixed in this release.
2026-08-04 23:17 · release(0.3.3): a pre-release review of the 15 commits sitting unreleased since v0.3.2 found three real defects, all fixed here, and version bumped to 0.3.3 across the five manifests + CITATION.cff + 7 README BibTeX blocks. (1) The #73 managed ssh config was handed to EVERY ssh on every platform — -F in compute.rs and OPENSCIENCE_SSH_CONFIG for the sidecar, which the remote-compute skill passes on all 18 of its invocations — while the only thing that config adds is ControlMaster/ControlPath connection sharing, which Windows' OpenSSH does not implement; the gate now lives in config_path, so Windows gets exactly the pre-#73 plain-ssh behaviour instead of carrying options that buy it nothing and could take all of remote compute down with them (the Windows half of #73 was never verified by us — that is the point of removing the risk rather than reasoning about it). (2) A single ssh_connect tool call reaches the sign-in dialog through tool.updated, which the SDK re-emits per part update (pending, then running, …), and the handler acted on each one: two sign-ins raced for one ControlPath, the loser continuing WITHOUT sharing — so the code the user typed need not have reached the connection later commands look for — while the replaced Session dropped its Child, which on unix does not kill it. Deduped by callId, and the Rust command now claims the host under the lock BEFORE is_shared/openpty/fork (releasing the claim on every failure path, or a host stuck in "connecting" would make sign-in unreachable until restart). (3) Auto-review credited a mutating tool to the session that ran it, but a subagent's session is never reviewed on its own — so a turn that delegated ALL its file writes to task was reviewed by nobody, the opposite of what the gate's own comment promises; writes are now credited to the root of the subagent chain. Also dirty || dequeueReview(sid) short-circuited, leaving a stale queue entry for a session that was both dirty and already owed a review, which the drain turned into a second paid review as soon as that review ended early. A fourth suspicion was withdrawn, not fixed: deploy_profile_prompts writes into runtime_root/xdg-config, an app-private profile, so it cannot overwrite a user's own ~/.config/opencode/agent/reviewer.md. 838 frontend tests (3 new, each confirmed to fail against the old code), 170 Rust tests, typecheck, eslint and clippy (no new warnings; all 15 existing ones are in untouched files) pass. Not unit-testable and reviewed instead: the config_path platform gate and the pre-spawn claim, both of which need a live AppHandle.
2026-08-04 21:00 · fix(thread): "answer shows done, then a spinner keeps turning underneath for a while" is a real bug, and the user's own debug.log named it 62 times — every occurrence the same three lines: session.idle, then message.agent ~40 ms later, then reconcile: missed idle — unlocking ~15.3 s after that. message.agent was in ACTIVITY_EVENTS, the set whose members re-lock a session as proof its turn is still in flight. But the SDK emits that event ONLY for user messages (info.role === "user", OpenCodeClient.normalize), so it can never witness the assistant working — and OpenCode re-emits the turn's user message just after the turn ends, which re-locked the session the instant it finished. Nothing then cleared it until reconcileRunning polled the server, which also REPLACES the thread with freshly built history, so every turn ended with a needless full-thread rebuild on top of the phantom spinner. Removed it from the set; the reasons the set exists are untouched, since a turn started by another client is still caught by the assistant-side events (text/reasoning/step/tool/retry/question/permission) and by turnStillStreaming seeding the lock from server truth whenever a session is opened (the actual #59 fix). Regression test drives the real sequence and asserts genuine assistant progress still re-locks; confirmed to fail with the event put back. 835 frontend tests (1 new), typecheck, eslint pass. DMG rebuilt on all seven of today's fixes (84 MB arm64, SHA-256 cb6af42adc187c81bd4986cbc4894f2af6cf0ed407a16a752fcb6cd8271ba38c), verified by content: the embedded chunk's activity set reads ["text.updated","reasoning.updated","step.updated","tool.updated","session.retry","question.asked","permission.asked"] — message.agent gone — alongside the content-filter guidance, the log redaction, the review-queue dedupe and the "1.18.12" pin, with the app's own sidecar reporting 1.18.12.
2026-08-04 20:31 · diag: a user-reported "Request blocked." on a ChatGPT-Pro (Codex OAuth) subscription is the PROVIDER refusing the prompt, not our bug and not a quota problem — established from the runtime's own SQLite rather than guessed. The persisted error is APIError from POST https://api.openai.com/v1/responses, HTTP 400, body {"type":"invalid_request_error","code":"invalid_prompt","message":"Request blocked."}, isRetryable: false; the response's x-codex-* headers show plan prolite, active limit premium and primary-used-percent: 8, which rules out rate limiting or entitlement. invalid_prompt is OpenAI's content-filter rejection of the PROMPT, and on the Responses API a prompt is the whole conversation — so "continue" resent the same history and failed identically three times (20:11:42, 20:12:01, 20:17:11), and only switching provider (kimi-for-coding) recovered, exactly as reported. Neither our code nor OpenCode 1.18.12 contains that string (upstream's own gateway-block wordings are far longer), confirming it is passed through verbatim. Two real product gaps this exposed, both fixed: (1) diagnosing it required reading SQLite by hand because debug.log recorded event ← error <sid> with the message DISCARDED — the message is now logged, run through a new redactForLog first, since a provider echoing a failed credential back must never land in a file users attach to bug reports (known key shapes, Bearer/Basic values, any 40+ char opaque run, then a 300-char cap); (2) the UI showed the bare "Request blocked." with no way forward, so the app looked broken — the existing "model not found" hint grew into explainRuntimeError, which keeps the provider's own words and adds that the filter rejected the CONVERSATION rather than the last message, that every retry resends the same history, and that the ways out are editing recent messages, a new session, or another model/provider. Both are pure exported functions with tests; the pass-through case asserts an error merely mentioning "blocked" is left alone. 834 frontend tests (5 new), typecheck, eslint pass.
2026-08-04 19:52 · fix(#72): two defects in the auto-review queue, both costing unattended paid model turns or silently skipping one, found by walking the state machine rather than by a failure. (1) The queue is the SET of sessions owed a review, but onTurnIdle pushed unconditionally while another session held the single slot — so a pane finishing three file-changing turns during one review queued three copies of itself, and because the drain that starts a review consumes only one entry, each leftover became another paid review of the same state. It now dedupes on push, matching the guard startAutoReview already had. (2) changedFiles computed dirtyTurns.delete(sid) || dequeueReview(sid) before checking reviewable, so on the two NON-reviewable paths (user interrupt, session error) the dequeueReview side effect fired and its result was thrown away — silently cancelling a review earned by an EARLIER turn, in direct contradiction of the invariant stated two lines above it ("the files that earned it are on disk whether or not this turn touched anything"). The queue entry is now consumed only on an idle that can act on it. Also closed the real gap in the 1.18.12 verification: SSE payload shapes, which endpoints answering 200 says nothing about. Captured a live event stream through a real shell turn on the new binary — message.updated still carries info.role/id/sessionID/agent, message.part.updated still carries part.id/sessionID/messageID with type text and tool and a state.status running→completed carrying output, and session.idle still carries properties.sessionID. Nothing the SDK's normalize() switches on was renamed or reshaped. 830 frontend tests (2 new, both confirmed to fail against the old logic), typecheck, eslint pass. DMG rebuilt on top of all four of today's fixes (84 MB arm64, SHA-256 4353627045488ec01e8957c87a11fcf16cfe5580b0eb7d86433ca508d0566ba6) and verified by CONTENT, not just by a clean-tree build: Tauri embeds the frontend in the binary, so the shipped chunk itself was grepped — it carries the innerWidth > 768 early return, pathKey's line-break-only strip, the review-queue includes guard and the "1.18.12" constant — alongside the installed app's own sidecar reporting 1.18.12, reviewer.md + science-review.md under Resources/profile/, and the goal plugin pinned to plugin SDK 1.18.12.
2026-08-04 19:41 · fix(review): self-review of today's four commits found two defects, both in code written today, both in the new helpers rather than the call sites. (1) useIsMobile compared innerWidth × zoom against the breakpoint, but the store's zoom factor lands one render BEFORE the webview has applied it, so for a few frames a new zoom is paired with the old innerWidth; on zoom-OUT that product is too small and the shell would flash into phone layout on a window that is only getting roomier (800 px window → 50%: 800 × 0.5 = 400). It now requires the un-corrected width to be narrow too, which is not redundant — it makes the mismatched pair safe in either order, so a transient can only ever be over-cautious, and it settles identically once resize fires. (2) pathKey used trim(), which would make …/notes and …/notes compare equal — two real directories on POSIX, where a folder name may end in a space; it now strips line breaks only, which is the malformed-producer case the helper exists to absorb. Also swept for what was NOT broken: every remaining === on a path in the frontend compares two values from the SAME source (browser profiles, workspace-relative artifact paths), the Rust gateway's fs_base and is_registered_project_path canonicalize both sides so they stay consistent with the now-native ProjectInfo.path, ensure_base_layout does not re-canonicalize so workspace_path/workspace_base really do return the native form, and the reconcile effect cannot clear a just-chosen effort (the select only offers levels the model has) nor loop (the deleted key is gone on re-run). 828 frontend tests (2 new), 170 Rust tests, typecheck, eslint pass.
2026-08-04 11:10 · feat(#74): bundled runtime 1.17.13 → 1.18.12, so the model catalog and reasoning efforts match a current OpenCode instead of a nine-release-old one. The reporter's diagnosis was right and needed no app change for max itself: listProviders surfaces whatever variants the runtime reports with no allowlist, the ordering already knew max, unknown levels are kept rather than dropped, and sendPrompt already forwarded the selection — the app was faithfully displaying a stale runtime. The bump is three pins (SDK constant, sidecar fetch script, plugin-SDK version), so the work was verification, run against BOTH binaries rather than reasoned about: a 19-endpoint probe of every v1 route the SDK uses (/config/providers, session create/prompt_async/message/shell/abort/rename/delete, /agent, /command, /skill, /mcp, /provider, cursor-paged /experimental/session, /question, /permission, /instance/dispose, /global/config, SSE /event) answers identically on both — 1.18's "v2 server" work is additive and did not move the v1 API. The whole P0-7 security model still holds on the new binary (no/wrong password → 401 on /global/config, /session and /event; correct Basic → 200; the EventSource-only ?auth_token= path streams; tokenless SSE → 401), all 80 approve-mode bash ask rules plus webfetch: ask still land in the resolved ruleset, and the reviewer agent's edit/task denials survive even though Agent.Info's permission field changed from a map to a rule ARRAY (our AgentInfo reads only name/description/mode, so the schema change is invisible to us). The goal plugin, rebuilt against plugin SDK 1.18.12, still registers /goal; /science-review still loads beside OpenCode's builtin /review. Two findings the issue did not predict, both from diffing the two runtimes' variant catalogs on the same models: catalog-driven reasoning_options moves vocabularies DOWN as well as up (deepseek-v4-flash-free lost medium; mimo-v2.5-free and nemotron-3-ultra-free lost their efforts entirely), and 1.18.12 accepts a stale agent.<name>.variant without complaint and then applies nothing — so a per-agent effort pinned under the old runtime became dead config that Settings still displayed. AgentModelsCard now reconciles it away once the catalog is known, while deliberately leaving alone an effort whose model is absent from the catalog (a dangling model says nothing about which levels are legal). Settings' runtime row now shows the bundled OpenCode version, which the issue asked for and nothing displayed before, with a test asserting the three pins agree so the app cannot advertise a version it is not running. NOT verified by us: the OpenAI-OAuth-specific half of the report (the gpt-5.6-* max levels and the OAuth alias filtering) needs an OpenAI OAuth account, which neither maintainer has — the mechanism was confirmed in the 1.18.12 source instead. 170 Rust tests, 826 frontend tests (12 new), typecheck, eslint, clippy (no new warnings) and an 84 MB arm64 DMG (SHA-256 2af731dde880042b315f8df93ad2cdaddfd0929f36259973bb21dc41bb9d9b24) pass, with the installed app's own sidecar confirmed to report 1.18.12 and the reviewer agent present under Resources/profile/.
2026-08-04 10:35 · fix(#63): zooming no longer restructures the desktop into phone layout. Auditing the last three items on #63 found two already shipped — paragraph spacing (p: my-3.5/my-4, a full blank line between blocks) and the active-session highlight (accent tint plus inset accent ring, not a shade of the hover colour) — but a second, platform-independent half of the zoom bug was still live, distinct from the macOS titlebar fix of 2026-07-29. Page zoom shrinks the CSS-pixel viewport, and the mobile breakpoint was a plain (max-width: 768px) media query, so a 1200 px window at 175% reported ~686 CSS px and the whole shell flipped to the phone layout — sidebar becomes an overlay drawer, model picker becomes a bottom sheet — because the user asked for bigger text. useIsMobile now measures the WINDOW (innerWidth × zoom), which is what that breakpoint always meant to describe; at 100% it is exactly the old behaviour, a genuinely narrow zoomed window still gets phone layout, and the factor counts only in the desktop app, where ZoomProvider actually applies it (a browser's own zoom is invisible to us and the stored factor stays 1). 821 frontend tests (4 new), typecheck and eslint pass.
2026-08-04 10:13 · fix(#76): on Windows every project showed "no sessions" even with sessions inside it, and the cause is bigger than the report's inference. A project's path comes from Rust canonicalize(), a session's directory from the OpenCode sidecar, and on Windows those name one folder in two spellings that can never be string-equal: canonicalize() returns the VERBATIM form (\\?\D:\…\视频总结, which the reporter's inference missed — it is not merely backslash-vs-slash) while the sidecar reports D:/…/视频总结. Every exact-match lookup between the two therefore missed, which broke more than the sidebar: project-scoped memory silently fell back to global (MemoryCard), save-to-memory could not resolve its project (SelectionActions), the active-project accent never lit, and History's move-to-project offered the project a session was already in. Fixed at both ends rather than one: Rust no longer hands out or persists verbatim paths (native_path in artifact_file.rs, now shared with project.rs's info_of and runtime.rs's set_workspace/set_workspace_base), and because the separator difference survives that, one frontend helper (lib/workspacePath.ts) reduces either form to a comparison key — verbatim unwrapped, \→/, trailing separator dropped, and case folded ONLY for drive-letter/UNC paths, since those filesystems are case-insensitive while POSIX ones are not (/home/u/Work and /home/u/work must stay distinct). workspace_dir/base_workspace_dir also unwrap on READ, so an install that already persisted a verbatim path is repaired without the user re-picking a folder. Supersedes community PR #70, which normalized trailing slashes only — the reported Windows case needed the other two rules as well. The two component regression tests assert DOCUMENT ORDER, not mere presence: an unmatched session is still on screen, just below the flat "Sessions" heading instead of inside its project, which is exactly how the bug looked — and both were confirmed to fail against a pass-through key before the fix. 170 Rust tests (1 new), 815 frontend tests (10 new), typecheck, eslint, clippy (no new warnings) and the production build pass.
2026-08-04 07:31 · security(deps): closed both open Dependabot alerts. #7 (postcss ≤ 8.5.17, GHSA-r28c-9q8g-f849 — a sourceMappingURL in processed CSS makes postcss auto-load and disclose an arbitrary .map file) is a direct devDependency, so apps/desktop now asks for ^8.5.25. #6 is a SECOND brace-expansion advisory (GHSA-mh99-v99m-4gvg, unbounded expansion length crashing the process out of memory), distinct from the ReDoS one the existing overrides were written for, and its fixed versions are higher on every major line — 1.1.17 / 2.1.3 / 5.0.8 against the old 1.1.16 / 2.1.2 / 5.0.7. The 5.x line (via minimatch@10) had no override at all and sat at 5.0.7, exactly in range; a third pin brings it to 5.0.9. The two existing pins resolved to versions that happen to be clear (1.1.17, 2.1.4) but their ^1.1.16 / ^2.1.2 floors still ADMITTED vulnerable versions, so both floors were raised to the new advisory's — a clean install can no longer land back in range. Every brace-expansion and postcss resolution in the lockfile is now outside all ranges of both advisories. 805 frontend tests, typecheck, eslint and the production build (which is what exercises the postcss/Tailwind pipeline) pass.
2026-08-04 00:55 · feat(#73): interactive SSH sign-in, so clusters that demand a password or a one-time code on every connection stop being unusable. BatchMode=yes (compute.rs) can never answer such a prompt, and answering one per connection would be worse — an agent run makes dozens of ssh calls. So the app opens ONE authenticated master per host over a real pty (portable-pty), relays whatever the server asks to a dialog, and routes every later ssh — its own probes and the agent's ssh/scp/sbatch — through an app-managed config whose ControlPath they share, so one sign-in covers the working session (ControlPersist=8h, dropped on app exit). Prompts are deliberately NOT classified: the dialog shows the server's own words (Duo/PAM/OTP wording varies too much to pattern-match) plus the surrounding output, masked input, multi-step flows clearing the field between steps. Secrets go into the pty — never an argument, so they cannot reach runs.jsonl, provenance or logs — and nothing is persisted. The agent reaches the same dialog mid-run through a new ssh_connect tool (the tool call is the UI's signal; it then polls ssh -O check and returns once the channel is up), and the remote-compute skill now passes -F "$OPENSCIENCE_SSH_CONFIG" on all 18 of its invocations. Verified against the real ssh, not just in theory: ssh -G confirms the user's own ~/.ssh/config wins for their hosts (Include first, first-value-wins) while ours fills the gap, and that %C is 40 hex characters — which caught two real bugs, a socket path 105 bytes long on this machine (macOS caps at 104, so control_dir now tries shorter names) and a world-writable /tmp fallback that would have let a local user sit between our clients and the master (now refused unless the directory is ours and 0700). Windows has no ControlMaster at all, so the row says so instead of offering a sign-in that would need repeating per command.
Since neither maintainer has a 2FA cluster to test on, the interaction is exercised against REAL OpenSSH instead of mocks, hermetically: (a) an encrypted key handed to a throwaway ssh-agent makes ssh-add ask for its passphrase on our pty — OpenSSH itself rejects a wrong answer ("Bad passphrase") and accepts the right one ("Identity added", key verified present in the agent), which is proof the relay reaches its terminal; (b) a rootless sshd -i over a pipe, with its own host key and authorized_keys, is signed in to for real — ssh reports Authenticated to … using "publickey" and claims its mux listener at exactly the path the managed config computes. On macOS the rest of that test is skipped for a documented reason (an unprivileged sshd cannot create a BSM audit session, so it hangs up right after auth). The property everything rests on was then proven against a real remote server instead — OS_SSH_TEST_HOST=git@github.com cargo test --lib shared_connection -- --ignored: the master comes up through the managed config, ssh -O check (literally what is_shared runs) reports it live, a second client with IdentityFile=/dev/null -o IdentitiesOnly=yes -o BatchMode=yes — no credentials and no way to ask for any — still reaches GitHub's authenticated command handler, and -O exit really closes it. That is why the agent's own ssh/scp/sbatch need no password after one sign-in. That test immediately earned its keep by failing with unix_listener: path ".../<hash>.8ekIBJ1L27i2WFP6" too long for Unix domain socket: ssh binds a temporary <path>.<16 random chars> before renaming, so the socket-length budget was 17 bytes short and macOS would have failed EVERY sign-in at bind time while passing the old check. Finally the actual two-factor case, against a real server rather than an analogue: scripts/dev/2fa-sshd/Dockerfile builds an sshd that authenticates through PAM with pam_unix + pam_oath (AuthenticationMethods keyboard-interactive), i.e. a password AND a one-time code, and the same test drives it end to end — it printed step 1: … asked "(tester@localhost) Password:", step 2: … asked "(tester@localhost) One-time password (OATH) for \tester':", both relayed verbatim and answered through the pty, then shared connection … is live, then a credential-less client (IdentityFile=/dev/null, KbdInteractiveAuthentication=no, BatchMode=yes) ran a command and got shared-okback, then sign-out closed it. Still unverified by us: a specific vendor's exact wording (which is precisely why prompts are relayed rather than classified — the OATH prompt above was not in any pattern list and worked), the app's own command layer abovespawn_pty` (bookkeeping, exercised only through unit tests and review), and the Windows half of the platform matrix. 169 Rust tests (11 new, 1 ignored for a host you can already log in to) and 805 frontend tests (11 new) pass, with typecheck, eslint, clippy and an ARM DMG build.
2026-08-03 20:35 · feat(#71, #72): per-agent reasoning effort and a reviewer agent with opt-in auto-review. #71 writes agent.<name>.variant alongside the existing model override (one shared field writer in opencode_config.rs, two new Tauri commands) and the Settings row gained an effort select built from the agent's EFFECTIVE model's own variants, dropping a pinned effort the newly chosen model cannot honour — verified against the pinned OpenCode 1.17.13, whose Agent.Info schema does accept variant (reviewer | variant: high, title | variant: low read back from a live /agent). #72 bundles an app-managed reviewer agent (mode: all, edit/task denied, prompt orchestrating the existing traceability-review / stats-integrity / domain-check skills and emitting the ```review fence the ReviewerCard already renders) plus a /science-review command, deployed into the profile's `agent/`+`command/` dirs on every sidecar start like the skill packs; the built DMG carries both under `Resources/profile/`, and the sidecar loads them without clashing with OpenCode's own builtin `/review`. Auto-review is OFF by default and gated: only a turn whose write/edit tool succeeded (bash excluded — indistinguishable from `ls`), never a review's own turn, never a subagent session, never an interrupted or ERRORED turn, one review at a time with a FIFO queue so a second pane is reviewed rather than dropped (#50's concurrent-subagent freeze), and its prompt is hidden from the live thread and reloaded history. A self-review pass caught the load-bearing hole: a session error ends a turn on a path that returns BEFORE the fold, so a review that died server-side (dangling model, rate limit) would have held the single slot forever and no session would ever be reviewed again — that path now does the same bookkeeping, and a regression test drives both halves (an errored turn is not reviewed; a dead review frees the slot for the queued session). Also verified against the pinned sidecar that a top-level prompt on a `mode: all` agent is accepted (the user message comes back stamped `agent: reviewer`) while an unknown agent is silently dropped — which is why the trigger also requires the reviewer to be present in the agent catalog. 158 Rust tests, 795 frontend tests (19 new), typecheck, eslint, clippy (no new warnings) and an ARM DMG build pass.
2026-08-03 02:50 · release: v0.3.2 published as Latest with six installers (both macOS DMGs Developer ID signed, notarized and stapled — verified with stapler/spctl on the released artifacts); the notarization cron skipped its 09:17Z slot so both stages were dispatched manually, and the two unused tauri updater .app.tar.gz bundles were dropped to match v0.3.1's asset set. Synced the new Zenodo version DOI 10.5281/zenodo.21771274 into CITATION.cff + 7 README BibTeX blocks (concept DOI 10.5281/zenodo.21351225 stays in the badge; v0.3.1 had shipped still pointing at v0.3.0's DOI).
2026-08-03 00:30 · release: v0.3.2 tagged with two field-reported fixes — the Skills page no longer reports the app's own bundled uv and managed Jupyter as "not found" (#68), and a session started in a project is created there rather than in whatever folder was last opened, each draft now carrying its own destination (#69); 156 Rust and 772 frontend tests, lint, typecheck and an ARM DMG build pass.
2026-08-02 20:10 · fix(#69): a draft had no folder of its own — where its session landed was computed at send time from two globals (the active folder, which follows whatever session was last opened, plus a boolean pin that did not remember WHICH folder), so a session started in a project could land in the folder of a session merely glanced at, and a new screen inherited the project just viewed; replaced the boolean with a per-draft-slot draftWorkspaces map, restored the draft's folder before creating the session, and aimed the pane's own draft:<leafId> slot from the sidebar's "+"; four store tests cover the reported sequences and fail on the old behaviour. An earlier \\?\-path theory was reverted — OpenCode's DB layer round-trips that prefix losslessly, so it never diverged.
2026-08-01 09:20 · fix(#68): the Skills-page environment probe now falls back to the app's own binaries when the host PATH has none — the managed Jupyter env (jupyter-lab, matching jupyter_status().installed) and its Python, plus the bundled uv sidecar, which had the same false negative — each row labelled app-managed, the "Python/R/Jupyter are not bundled" note corrected in all 7 locales; 155 Rust and 764 frontend tests, lint, typecheck and an ARM DMG build pass.
2026-08-01 08:45 · fix(release): pruned source, declarations, source maps and documentation from the bundled OpenCode plugin SDK while preserving production modules and licenses, reducing the goal-plugin resource from 62 MiB to 19 MiB and a real ARM DMG from 125.3 MiB to 80.7 MiB; a 24 MiB dependency ceiling, runtime-entry imports, offline OpenCode health/SSE/tool checks, the production desktop build, 762 frontend tests and lint pass.
2026-08-01 07:11 · release: published Open Science Desktop v0.3.1 as the latest GitHub Release with six cross-platform installers; both Apple Silicon and Intel DMGs are Developer ID signed, notarized and stapled, the public release URL returns HTTP 200, and all uploaded assets have GitHub-recorded SHA-256 digests.
2026-08-01 06:53 · fix(release): the asynchronous notarization finalizer now addresses draft releases by release ID, because GitHub's release-by-tag endpoint returns 404 for an existing draft; the first real v0.3.1 queue run reproduced the failure before downloading or mutating any queued asset, and workflow syntax, expressions and diff checks pass after the fix.
2026-08-01 06:33 · chore(release): prepared v0.3.1 from the current master with every package, Tauri, Cargo and citation version synchronized, the GitHub Release title aligned to the Open Science Desktop brand, and the complete two-stage macOS App/DMG notarization path enabled; 762 frontend tests, typecheck, lint, Cargo metadata, workflow validation and diff checks pass.
2026-07-31 03:30 · security(deps): the brace-expansion override now covers the 2.x line too, correcting the 2026-07-29 22:55 entry above, which pinned only 1.x and asserted "the 2.x/5.x copies other packages need are untouched". GHSA-3jxr-9vmj-r5cp carries THREE affected ranges, not one — < 1.1.16, >= 2.0.0, < 2.1.2, and >= 3.0.0, < 5.0.7 — so the 2.1.1 this tree carried (via readdir-glob → minimatch@5) was in range the whole time; only the 1.x and 5.x resolutions were actually clear. A second override pins the 2.x line to 2.1.4, leaving 1.1.17 and 5.0.7 as they were, and every resolution in the lockfile is now outside all three ranges. Dependabot alert #4 still reports the < 1.1.16 range and has not been re-evaluated since it was raised (created 07-29 04:04Z, updated_at never moved, while the 1.x fix landed 07-30 05:54Z), so it was never evidence about 2.x either way — the affected-range list is. Also pruned two orphaned copies (1.1.15, 2.1.1) left in the local pnpm virtual store, unreferenced by the lockfile and unlinked by any package. 762 frontend tests, typecheck and lint pass.
2026-07-31 01:45 · fix(runtime): a session with concurrent subagents froze the UI for minutes at a time (#50 follow-up). Diagnosed on a live frozen v0.3.0 build: only the WebView renderer was pinned (87–100% of a core, RSS 1.2 GB, 20 of 39 wall minutes in CPU) while the Tauri host and the opencode sidecar stayed healthy and kept streaming, so no work or provenance was lost; sample put 99% of main-thread time in one shape — SSE message → store update → a SYNCHRONOUS React render — reproduced identically across three snapshots and two separate freezes, both with a parent session driving five concurrent @general subagents. Cause: applyFold cloned runningSessions/shellTurns/stepCounts on EVERY folded event, so every streamed token from any session — including subagent sessions that are not on screen — handed a fresh identity to every whole-map subscriber, repainting each pane's whole shell and the sidebar. That defeated #34's per-field selectors and scaled with the number of live children; WebKit dispatches a whole SSE buffer in one task, so the sync renders stacked up with no chance to paint or accept input, and the backlog fed itself. Those three maps now change only on session.idle, SessionView reads them per session instead of whole-map, and its two per-render block-list copies became an in-place scan and a memo. A regression test asserts a streamed event leaves all three maps identical by reference, and fails on the old code with equal content but a different identity. 762 frontend tests, typecheck and lint pass.
2026-07-30 08:40 · fix(projects): a folder already inside the workspace is now ADOPTED instead of refused. Removing a project deletes only its .openscience/project.json and deliberately keeps every file, so a removed project leaves a real workspace with no metadata — invisible in the list, and impossible to get back: importing it hit "this folder is already managed by the app; use New project instead", while New project creates a DIFFERENT folder. Reproduced from the reported folder, which still has .openscience/ (compute.json, env/) but no project.json. Importing such a folder now registers it in place, keeping its id when it still has metadata (idempotent, no duplicate entry) and refusing only the base and the projects//sessions/ containers, whose adoption would swallow everything inside them; the copy-vs-in-place dialog is skipped for these, since neither mode applies and asking would promise a copy that is not made. 761 frontend tests, 151 Rust tests, typecheck and lint pass.
2026-07-30 00:10 · fix(chrome): right-clicking app chrome now opens the app's own menu instead of the WebView's — a sidebar session answered with "Open Link in New Window / Download Linked File" (it is an <a> only incidentally) and a Screen tab answered with "Look Up / Translate", selecting the tab's name on the way. The packaged app now suppresses the page menu everywhere EXCEPT editable fields and content explicitly marked data-native-menu (the conversation, file previews, the example thread), where Copy / Look Up / Translate are what a right-click should do; the browser gateway client is left alone, since there the app really is a web page. Sessions, projects, examples and Screen tabs each got their own menu (rename / add to project / archive / delete, new session / rename / open folder / pin / remove, hide, rename screen / close screen), and the rail and tab strip opt out of text selection so a right-click leaves no stray highlight. 751 frontend tests, typecheck and lint pass. Follow-up: the first attempt shipped the guard in the CAPTURE phase, which set defaultPrevented before Radix's trigger saw the event — and composeEventHandlers skips its own handler once that is set, so right-clicking a session, project or Screen tab produced NO menu at all. The guard now runs in the bubble phase and treats an already-handled event as "a component is opening its own menu"; a regression test drives the guard together with a real menu (the case neither earlier test covered, since the guard is packaged-app-only) and fails on the old version with the exact symptom.
2026-07-30 00:11 · fix(release): isolated the generated goal-plugin runtime as its own npm package before installing @opencode-ai/plugin, fixing the deterministic npm Arborist failure that stopped all four release jobs before signing, and excluded optional native accelerators that Apple rejects when embedded unsigned in app resources; the fetch-and-bundle script completes locally on Node 24 with the pinned plugin present and no native .node payload.
2026-07-29 22:55 · security(deps): closed all three open Dependabot alerts — the high-severity one (#4, brace-expansion < 1.1.16, exponential expansion of consecutive empty {} groups) was real and reached a PRODUCTION install through exceljs → archiver → glob@7 → minimatch@3, which no direct dependency can bump, so a pnpm override scoped to the 1.x line pins it to 1.1.17 (verified: minimatch@3 now links 1.1.17, 1.1.15 is gone from the lockfile, and the 2.x/5.x copies other packages need are untouched); the two react-router alerts (#2, #3) were dismissed as inaccurate after confirming both advisories are fixed in 7.18.0 while this tree has carried 7.18.1 since a0d1300 in package.json, the lockfile and on disk, with no other tracked manifest referencing it, and both alerts were raised ~12 h after that upgrade landed. 735 frontend tests, typecheck, lint and the production build pass.
2026-07-29 21:30 · feat(#65 follow-up): archive, restore and export, plus a session layer that holds up at career scale — load-tested against 5038 real sessions, which exposed two defects in the previous round: cursor paging SILENTLY SKIPPED sessions (1699 of 5038 shared an updated millisecond and the exclusive cursor ate the remainder of each boundary — 8 lost; fixed with cursor + 1 plus id dedupe and a minimum fetch size, now 5038/5038 in the same 26 pages), and the store pulled the entire history on every refresh (645 bytes/session — 3.1 MB at 5k, ~32 MB at 50k), now a bounded 200-row active window with the History page paging and SEARCHING on the server (measured: 27 ms for the window, 1-2 ms for a search, 166 ms to walk everything). Archive/restore is stored in OpenCode's session metadata — verified that it round-trips, is returned by the list, and is a clean replace — after confirming its own time.archived is a one-way door (null, 0, {} all leave a session hidden and there is no unarchive route); the full archive → hidden → visible-with-filter → restore cycle was run against the live runtime. Export writes the current filter's conversations as Markdown transcripts into a folder chosen in a native dialog, with an index, progress and a 5000 cap, and titles are sanitized so one can never escape the folder or hit a Windows device name. 735 frontend tests, 147 Rust tests, typecheck, lint, clippy and a 96 MB arm64 DMG (SHA-256 debe287523aefc93836728a2f666bb792ffaa3308a6450648d29c44c2d5d7991) pass; all 5038 load-test sessions were deleted afterwards.
2026-07-29 20:45 · feat(#62/#63): closed the rest of the two feature reports — persistent memory as two editable Markdown layers (global MEMORY.md in the app profile plus each project's own AGENTS.md, registered in OpenCode's instructions, with a switch and a "save to memory" action), verified end-to-end by pointing the pinned runtime at a capturing OpenAI-compatible endpoint and confirming both files reach the model's prompt; automatic context compaction switched on explicitly (compaction.auto) and its CompactionPart rendered as one expandable "Context compacted" seam so a long conversation continues instead of dying on "Input exceeds context window"; selected text in an answer now offers quote / explain / save-to-memory / copy; @ picks a workspace file and # attaches a past conversation as a bounded quoted excerpt; each agent (including the title/summary/compaction utilities) can run its own model via agent.<name>.model; a Subagents pane lists every task subagent with status, elapsed time and its live step; and the macOS overlay-titlebar strip now uses a counter-scaled MIN height, so zooming past 100% no longer squeezed the header into the conversation. 721 frontend tests, 142 Rust tests, typecheck, lint, clippy and a 96 MB arm64 DMG (SHA-256 76c6df8a8ff393285c5a6586383a2ef1658d0b58da32239bc6659783341ab5ce) pass.
2026-07-29 19:35 · fix(sessions #62/#63/#65): reproduced against the pinned opencode 1.17.13 that GET /experimental/session answers at most 100 sessions unparameterized, so every conversation past the newest hundred silently vanished from the app — listSessions now pages by cursor until the history runs out, a searchable /history page lists all of it grouped by age with rename / add-to-project / delete per row, the sidebar caps each group at 12 rows with a +N link so the uncapped list cannot flood the rail, sessions the runtime never auto-titled can be renamed by double-clicking a rail row (PATCH /session/:id), an existing conversation can be filed under a project (control-plane move-session, workspace files untouched), the selected session now carries an accent tint plus inset ring instead of a hover-colored shade, and chat markdown blocks sit a full blank line apart; 689 frontend tests, typecheck, lint, production build and a 92 MB arm64 DMG (SHA-256 a8d87e1d2709722776c14314ce6004d65b2f1132fe6317fde2c318035dafc8df) pass. Not covered: project/global memory and automatic compaction (#62), @/# mentions, per-subagent models, subagent status panel, memory toggle, selected-text actions and the zoom-scaling bug (#63).
2026-07-29 03:19 · test(merge): merged the in-place import, structured workspace, Screen close confirmation, Markdown-link guard, and macOS release-signing changes into the current master worktree without committing or pushing; 672 frontend tests, 136 Rust tests, typecheck, lint, production build, and the 88 MB arm64 DMG (SHA-256 5c5a9f721147170c035567f32662230ec6219c3e10f796e8d67f53aae6c04902) pass, with final bundle inspection confirming all three sidecars and protected-folder usage descriptions.
2026-07-29 01:35 · fix(sessions): new work no longer lands on top of work in progress — a skill install gets its own plain dated folder instead of being filed under whatever project is open, clicking a sidebar session from Skills/Runs/Files/Notebooks now navigates (it only rearranged panes, so those routes showed no change), and every "new session" entry (sidebar New, a project's new session, ⌘K new/workflow starters, the skill installer) opens one pane in its OWN Screen through a single shared layout action openInNewGroup instead of rebinding the focused pane; 669 frontend tests, typecheck, lint and an 88 MB arm64 DMG (SHA-256 19309a9cf48b4fdde8a9894b8e4639a1a1afb22a07c4b4be50cea3d5a14e45c1) pass.
2026-07-29 00:56 · fix(skills #61 follow-up): an agent-driven skill install now opens its OWN Screen with its own pane bound to the new session instead of binding onto whatever pane was focused (which took over a conversation in progress), and runs through the normal turn path so the pane echoes what the user typed, holds a running lock, streams and shows failures — the hand-rolled POST left the hijacked pane looking inert; the model now gets that text wrapped in locale-file install instructions (skills.install.agentPrompt, 7 languages) rather than hardcoded English, and adoption MOVES the skill out of the workspace so a project copy can no longer shadow the profile copy; 664 frontend tests, 15 Rust runtime tests, typecheck, lint, clippy and an 88 MB arm64 DMG (SHA-256 d1798b365914a2122b876557a306c36bc407a8f02163f174068e0830e75f2fa3) pass.
2026-07-29 00:54 · fix(macos): in-place import is now the UI and command default for existing folders, the app declares access reasons for protected/local/cloud/network/removable folders, and tagged macOS releases require notarization plus one Developer ID identity across the app and all three sidecars; 661 frontend tests, 133 Rust tests, typecheck, lint, production build, plist/workflow/script validation, and a hardened-runtime OpenCode health probe pass, but the repository currently has no Apple signing/notarization secrets so a signed TCC end-to-end build remains blocked on Apple Developer credentials.
2026-07-29 00:01 · fix(markdown): links in chat and Markdown file previews now open http(s) URLs externally and always suppress WebView navigation, so relative or unsupported document links cannot replace a Screen with an invalid route and blank its panels; 661 frontend tests, typecheck, lint, and production build pass.
2026-07-28 23:41 · feat(workspaces): existing-folder import now explicitly offers a managed copy or in-place use with the macOS TCC warning from #31, new projects and standalone sessions live under projects/ and sessions/ while legacy root workspaces remain compatible, and closing a Screen now requires confirmation; 659 frontend tests, 133 Rust tests, typecheck, lint, and production build pass.
2026-07-28 23:32 · fix(skills #61): the Skills tab now lists what OpenCode really loaded — reproduced against the bundled opencode 1.17.13 that the experimental /api/skill builds its list only from config-declared sources (so ~/.claude/skills and ~/.agents/skills, which sessions on the v1 API do load, were invisible), so listSkills reads v1 /skill?directory=; installed skills now land in the app profile's reserved <xdg-config>/opencode/skills/user/ (found by both skill loaders, skipped by bundled-pack pruning) via a model-free Rust install for a pasted SKILL.md or adoption of what the agent wrote when its session goes idle, each restarting the sidecar because discovery is cached per instance; 663 frontend tests, 15 Rust runtime tests, typecheck, lint, clippy and an 88 MB arm64 DMG (SHA-256 21436c096dc61a818fb765eb6f5ce619a3547ed07bb00f847b585f1db88e4b58) pass.
2026-07-27 23:52 · security(deps): closed stale Dependabot alert #1 as inaccurate after verifying master's manifest, lockfile, and installed dependency tree contain only patched react-router-dom / react-router 7.18.1 from a0d1300; GitHub now reports zero open Dependabot alerts.
2026-07-27 23:07 · fix(preview): session and inline artifact previews now stay bound to their owning workspace, wait through cross-session workspace switches instead of reading the previous root, and remain stable when another split pane gains focus; the reported 39 KB Luna PPTX was verified in the logged session directory, 658 frontend tests/typecheck/lint pass, and the read-only-mounted 87 MB arm64 DMG contains the matching release binary (SHA-256 4c96fd61a03a4bc6d4c8d2a6f3a2cf05bbc8dc82f6d303ce91c0040a1aecf56e).
2026-07-27 19:30 · chore(deps): upgraded react-router-dom 6.30.4 → 7.18.1 (no import changes) to leave the unpatchable v6 open-redirect/XSS advisories GHSA-jjmj-jmhj-qwj2 and GHSA-wrjc-x8rr-h8h6; the one remaining audit hit (GHSA-qwww-vcr4-c8h2, RSC-mode CSRF, needs react-router 8 + React 19) is unreachable — the app ships no RSC/server-action code; 657 frontend tests, typecheck, lint, production build, and a mounted 87 MB DMG check pass (SHA-256 bbc590c9688cddf32646a1d0634ebc344ba820cce84bee9c8089890b47f48875).
2026-07-27 07:31 · fix(runtime): fresh desktop profiles now receive the pinned OpenCode plugin SDK from the installer before the goal plugin is registered, removing the blocking first-run npm install from /event; 12 runtime tests pass and a clean plugin-enabled event stream reaches first byte in 142 ms with the offline dependency tree.
2026-07-27 01:07 · fix(artifacts): AI panel placement now honors bottom — a repeat present_artifact call for an open panel moves it to the requested side instead of silently refreshing it in place (unrequested placements still leave the pane where the user put it) — and the bottom-right placement is gone from the tool, types, and layout; 657 frontend tests, typecheck, lint, real sidecar tool-schema check (enum ["right","bottom"]), and a mounted 77 MB DMG resource check pass (SHA-256 89cc5c8523ed93ca61198629deebcce2a2edb2a89b796e68e6045e31fcdb1ff9).
2026-07-26 20:28 · build(release): built and read-only mounted Open Science_0.3.0_aarch64.dmg from desktop master@6a1dea3; verified the 0.3.0 app, tools/present_artifact.ts, all 7 scientific skills, and bundled ai4s-skills@2519e8d (77 MB, SHA-256 103a02ef8d84378ed775ca90f752b2fbc34e7002d79e22f2a68174ce507e5dc4).
2026-07-26 20:26 · chore(skills): desktop packaging now pins ai4s-research/ai4s-skills@2519e8d, including ordered citations, flexible figure/table placement, publication-layout QA, and recency-profile checks; the exact GitHub archive was fetched and its bundled revision verified.
2026-07-26 20:14 · fix(session): Stop now always reaches the server (#59) — the interrupt is no longer gated on this app's in-memory running lock, a failed abort keeps the lock and surfaces the error instead of claiming "Interrupted", streamed activity and server history re-seed a lock the app lost (reload), and an interrupt drops the pending approval the stopped turn was blocked on, which OpenCode deletes without publishing any resolved event; 656 frontend tests (8 new, each verified to fail without its fix), typecheck, lint, production build, and mounted DMG verification pass (ae5fd94000a6e0396abc29dbdd06532bffbe7b20c5013eaa52750b0b95c9a776).
2026-07-26 02:52 · fix(publication-skill): the first-party publication skill now governs figures and tables delegated by paper, survey, and experiment workflows, rejects generic diagram-tool output, resolves wide tables structurally before shrinking, and requires rasterized final-size collision/margin QA; skill validation, 648 frontend tests, typecheck, lint, production build, and mounted DMG verification pass (98b0593464facbb8f764cd47099128ad8d5995e00466df97f8dc59d6a6ac6979).
2026-07-26 00:32 · correction(output-quality): reverted the mis-scoped equation/chart generation rules after confirming the supplied examples belonged to another project, restoring the existing publication style and presentation-only agent contract; 647 frontend tests, typecheck, lint, production build, and mounted DMG resource verification pass (a62ad701f32b284630dc09aa7e134b37827d1abe0406e619b41522b351a92576).
2026-07-25 22:57 · fix(layout): Screen-level Session and AI Artifact panels now require an in-app confirmation before × removes them, explicitly preserving the session, conversation history, and workspace files; 647 frontend tests, typecheck, lint, production build, and mounted DMG verification pass (b0891b502ac41495baf04203c4553ae00d9c1b95ab19375bfda9bdceb9d61d52).
2026-07-25 20:24 · feat(artifacts): AI presentation panels now share the tiled Session pane's 32px/faint-border header baseline, and present_artifact can target the current Screen, a named new Screen with the source conversation, or a real titled dedicated Agent Session in a new Screen; 645 frontend tests, 128 Rust tests, typecheck, lint, real OpenCode tool-schema validation, production build, and mounted DMG verification pass (154465524b4e1114f3d2d34504980ad0a3ce71e55831cbffc62c21935615bb9c).
2026-07-25 19:38 · fix(artifacts): normal prompts now carry an explicit host-presentation contract so requests to show a generated/existing file must call present_artifact, and the tool forbids treating reads or path links as successful display; 640 frontend tests, 128 Rust tests, typecheck, lint, real OpenCode prompt-schema validation, production build, and mounted DMG resource verification pass (b0fb89e6f62adae5d7c634a7a5a4811397820da095cb4f7f283107ce7c556ba6).
2026-07-25 10:32 · feat(artifacts): added the native present_artifact agent tool with inline chat rendering and AI-created Screen panels (right, bottom, or bottom-right), responsive web/mobile fallback, persisted layouts, and same-file refresh; 639 frontend tests, 128 Rust tests, typecheck, lint, production build, real OpenCode discovery, and a mounted 77 MB Apple Silicon DMG resource check pass (d6d626c7e9a3962b18c9585b1c4ee57b887bb18f719c29b5967da339a693f9bb).
2026-07-25 04:22 · feat(session): agent answers now expose one-click copy feedback, existing sessions open at the latest messages with a non-yanking ↓ Latest control, and global/session file browsers persist their last directory with safe root fallback; 634 tests, typecheck, lint, production build, and a verified 77 MB Apple Silicon DMG pass (85a64967dcb79b2b84672ed779831548dc83700c00ecbdd1e19c86c927a9fe4e).
2026-07-24 20:53 · test build: packaged the notebook-header responsive fix as a verified 77 MB Apple Silicon DMG (Open Science_0.3.0_aarch64.dmg, SHA-256 ee91e080500828c50a45ae6790ba323c075b0926c0da2039844a89e46a33dfa5).
2026-07-24 20:45 · fix(notebook): removed the redundant cell-shortcut hint from the notebook editor header so narrow inspectors retain their close control, and collapsed notebook filename chips into one icon on tiled/mobile panes (one notebook opens directly; multiple notebooks use an accessible localized dropdown); 626 tests, typecheck, lint, and production build pass.
2026-07-24 09:18 · fix(#53): Amazon Bedrock provider setup now requires and persists the key's AWS Region before saving its bearer API key, reloads any existing region, validates region syntax, and ships the field in all seven locales; 626 vitest tests, typecheck, lint, and production build pass.
2026-07-24 07:30 · release: v0.3.0 published — feat/split-pane merged to master, version bumped in 13 files, tag v0.3.0 built all 4 platforms via CI (8 installers: mac aarch64/x64 dmg, Windows exe/msi, Linux deb/rpm, 2 .app.tar.gz), draft published as Latest. Headline: N-ary split-pane tiling (drag-to-dock, screens/groups, per-pane model) + round-8 UX (focus-independent vibrancy, deferred session creation with per-pane drafts, tentative preview screens, cross-screen drag). Synced the new Zenodo version DOI 10.5281/zenodo.21535396 into CITATION.cff + 7 README BibTeX blocks (concept DOI 10.5281/zenodo.21351225 stays in the badge).
2026-07-24 01:50 · feat(layout): split-pane UX round 8 — four asks. (1) Vibrancy no longer brightens on focus — windowEffects.state → inactive, so the frosted glass stays the lighter (unfocused) look whether or not the window is key. (2) Split now DEFERS session/folder creation: a split button / Cmd+D docks a DRAFT pane (sessionId:null) that creates its own session on first send; the runtime's single global DRAFT_KEY became a per-leaf draft:<leafId> slot (new draftKeyFor, threaded through performTurn/sendPrompt/runShell/runCommand + the draft→session graft) so several unbound panes each keep an independent draft; newTiledSession removed. (3) Clicking a sidebar session no longer clobbers the focused pane — it opens the session full-screen in a "tentative" screen (browser preview-tab model): a new ephemeralGroupId on the layout store; reused (session swapped) while untouched, PINNED by any real interaction (type/send/split/dock/open run·file·figure/switch screens); tentative tab shows italic. (4) Cross-screen drag: dwelling a drag ~400ms over a top screen tab (data-group-tab) switches screens (hitTest+dwell in dragPane), then dropping on a pane docks there; a pane dragged across screens MOVES (new moveLeafToActiveGroup). 619 vitest green, tsc + lint clean; DMG rebuild pending for the native vibrancy check.
2026-07-24 00:45 · fix(layout): split-pane UX round 7 (on-device). (1) Background panes now load their history on launch instead of a skeleton-until-clicked — new loadHistory(id) runtime action fetches getMessages (session-scoped, no folder switch, unlike openSession) and folds it in; LiveSessionPage loads every visible non-focused pane. (2) Composer frost re-done: a separate masked layer behind the input — tint (color-mix --surface, ~68% max) + backdrop-blur both fade out toward the TOP via a mask-image gradient, so it's most transparent at the top and gradually frosts toward the bottom with NO hard boundary line; the input itself stays crisp (not masked). Much more transparent overall. 619 vitest green, tsc + lint clean.
2026-07-24 00:35 · fix(composer): split-pane UX round 6 (on-device). (1) The floating composer now SCALES with the pane zoom (input box height + text shrink at 50/75%) — zoom is applied to the composer inner, and its measured height still drives the conversation's bottom clearance. (2) Composer action row is flex-wrap with the model-picker+send grouped ml-auto, so on a narrow (tiled) pane the controls wrap to a second line instead of spilling outside the input box. (3) Composer background is now frosted glass — a backdrop-blur-md + color-mix --surface gradient (fading to clear at the top) so the conversation behind is softly blurred, not raw see-through (messy) nor a hard opaque bar. Width stays a proportion of the pane. 619 vitest green, tsc + lint clean.
2026-07-24 00:20 · fix(layout): split-pane UX round 5 (on-device). (1) Per-pane model + reasoning effort now PERSIST — sessionModels/sessionVariants (runtime, keyed by real session id) saved to localStorage and restored on relaunch, and grafted draft→session; a restored split keeps each pane's model/effort instead of falling back to the global default. (2) Composer now FLOATS over the conversation (absolute, pointer-events-none transparent gutter, input opaque) so the chat stays visible and scrolls behind it — the conversation pads its bottom by the composer's MEASURED height (ResizeObserver, real px outside the zoom) so the last message always clears it. (3) Composer width is a PROPORTION of the pane (w-[88%] tiled / max-w-[760px] solo), zoom-independent, so it never spans edge-to-edge (75%) nor gets too narrow (50%); the pane zoom now scales only the chat content, leaving the input a usable size. 619 vitest green, tsc + lint clean. Still deferred: per-pane drafts (split creates a session eagerly).
2026-07-24 00:05 · fix(layout): split-pane UX round 4 (on-device). (1) Pane-header title drag no longer selects conversation text — message text sets its own user-select, which body-level user-select:none can't override, so the drag now adds a .pane-dragging class (* { user-select:none !important }) + clears any live selection each move. (2) Tiled-pane composer is centered + capped (max-w-[560px]) instead of edge-to-edge, which looked cramped when zoomed. (3) INSTRUMENTATION for the 4-pane slow-first-token report: debug.log now records per-session async-prompt POST-accept latency (send POST ok … Nms, plus live stream count) and first-token latency (first token ← … Nms) — a slow POST points at a cold/locked per-directory sidecar instance or connection pressure from N persistent SSE streams; a fast POST + slow first token points at the model/provider. 619 vitest green, tsc + lint clean. Still deferred: (per-pane drafts) splitting creates a session eagerly.
2026-07-23 23:25 · feat/fix(layout): split-pane UX round 3 (on-device). (1) Pane-header title drag no longer sweeps a text selection across the conversation — the drag controller sets body user-select:none from pointer-down (restored on release). (2) Tiled panes get a compact header (h-8, tighter padding) — the h-12 titlebar wasted space. (3) Composer gutters are pointer-events-none (input -auto) so the empty area beside the input never blocks the conversation; tiled padding tightened and full-width. (4) Per-pane model + reasoning effort: switching a model in one pane no longer changes the others (or reconnects the sidecar) — sessionModels/sessionVariants in the runtime, sent per-turn; ModelPicker takes an optional sessionId (global default when absent), drafts graft their override onto the real session. 619 vitest green, tsc + lint clean. DEFERRED to next round (needs generalizing the single global draft to per-pane drafts): (5) splitting still creates a session eagerly — should stay a draft until the first message like a normal session.
2026-07-23 22:50 · fix(#52): custom-endpoint context-window regression — root-caused to #49 writing a blind 128k limit.context, which OpenCode's V1 overflow accounting (session/overflow.ts, usable = context − reserved) treats as the hard window; on models with a larger real window (e.g. GPT-5.6 Sol 1.5M) it manufactured a ~108k ceiling → forced compaction → hard "too large to compact" abort (v0.2.3 with context=0 short-circuited, so it worked). Fix keeps context-limit resolution as OpenCode's job and only corrects the value we feed: addCustomProvider no longer writes a guessed default (probe/typed only, else leave 0), and the connect-time backfill became clearDefaultCustomModelContextLimits (resets the exact blind {context:128000,output:0} → {context:0,output:0}, merge-safe, idempotent, leaves probed/hand-set alone). 4 new SDK tests; 601 vitest green, typecheck+lint clean.
2026-07-23 22:40 · fix(layout): split-pane UX round 2 (on-device). (1) Pane-header title drag no longer selects text — the title handle is select-none + draggable=false, so grabbing it drags the pane. (2) In a tiled (non-solo) pane the files/artifact/runs inspector now FILLS the pane (chat+composer hidden) instead of a side column that squeezed the chat / overflowed the pane; solo panes keep the resizable side column. (3) Tiled-pane header toggles are icon-only (folder name / "Runs" label hidden) to save space. (4) Tiled panes default to 75% zoom (node.zoom ?? (solo ? 1 : 0.75)), still overridable per pane. 619 vitest green, tsc + lint clean.
2026-07-23 21:15 · feat(layout): split-pane v2 UX round (on-device feedback). (1) Per-pane close ✕ button in the header (sole pane has none — closing the last empties the group to onboarding). (2) Removed the per-pane green "ready" dot — the ConnBadge now shows only trouble (connecting/error). (3) Per-pane zoom: a "NN%" header menu (50/75/100/125/150) applies CSS zoom to that pane's conversation + composer, stored per-leaf. (4) Empty group New: the onboarding has a "New session" button (→ reset(null)), and sidebar "New" fills an empty active group / turns the focused pane into a fresh draft. (5) Layout persistence: groups + trees + per-leaf session/sizes/zoom + active group saved to localStorage (ai4s.layout.v2) and restored on relaunch; id counters bump past restored ids; deleted-between-runs sessions reconciled by pruneSessions once the list loads. Also fixed the sidebar drag being hijacked by native <a> link-drag (draggable={false}), and added visible split-right/split-down buttons to each pane header (discoverable entry, no shortcut needed). 619 vitest green (22 layout), tsc + lint + build clean. NOTE for tester: install THIS DMG over the old app (xattr -cr) — earlier symptoms matched the pre-feature build. Known gap: a restored background pane shows a skeleton until focused (history loads on focus).
2026-07-23 20:10 · feat(layout): split-pane v2 — vertical docking, drag-to-dock, and layout groups (on branch feat/split-pane, in a dedicated worktree). (A) Binary split tree → N-ary (children[]/sizes[]): docking a sibling re-equalizes to 1/N (2→½, 3→⅓), with normalize flattening same-axis nesting; PaneTree renders N children + N−1 dividers. (B) Drag-to-dock (lib/dragPane.ts, pointer-based, not HTML5 DnD): drag a pane header or a sidebar session row; a shrunk session-card ghost follows the cursor; hovering a pane highlights the top/bottom/left/right half; drop docks there (session already tiled → moved not duplicated). (C) Groups/screens (groups[]/activeGroupId, active group mirrored to top-level tree/focus/zoom): GroupTabs top strip (add/close/rename/switch, owns the macOS traffic-light clearance), EmptyGroup onboarding, Cmd+T new group / Cmd+Shift+[ ] switch; first drop into an empty group fills it. Desktop always renders the tree (lone pane is a drop target); web/phone keep the single-pane fallback. Adversarial review found 6 issues, all fixed: pointercancel/blur drag teardown, prune ALL groups, cross-group Back (switch instead of clobber), stale-drop-target guard, one-shot click suppression, zoom preserved across prunes. 617 vitest green (20 layout incl. group-store), tsc + lint + build clean. Visual verify blocked by a broken browser-MCP session — drag/groups to be confirmed on-device.
2026-07-23 19:15 · release: v0.2.5 published — tag v0.2.5 built all 4 platforms via CI (8 installers), changelog applied (headline: #48/#40 inline composer model + reasoning-effort switcher, #51 bracket-delimited \(…\)/\[…\] LaTeX math fix), draft published as Latest. Synced the new Zenodo version DOI 10.5281/zenodo.21522590 into CITATION.cff + 7 README BibTeX blocks (concept DOI 10.5281/zenodo.21351225 stays in the badge). Split-pane WIP (not ready) parked on branch feat/split-pane (d82a15b), kept out of this release.
2026-07-23 07:20 · fix(layout): resolve 7 code-review findings in the split-pane refactor before shipping. Critical: (1) URL↔focus effects looped/clobbered deep-linked /live/:id under StrictMode — fixed by seeding the initial pane from window.location and making focus→URL only ever navigate TO a real session (never bare /live; /clear navigates explicitly); (2) a draft's first send blanked the pane because SessionView read the lagging sid prop while performTurn had already grafted threads[DRAFT_KEY]→newId — SessionView now displays an effective id (eid: real panes use their own id, the focused draft follows currentId), and performTurn moves the per-session send-lock across the graft via a tracked lockKey. Also: sidebar nav to an already-tiled session focuses that pane instead of duplicating it; subagent question/permission replies route via the ROOT session's folder; WorkspaceChip reads the draft's own send-lock not the global OR; first-send pushes a history entry; and background streams are dropped in the mobile single-pane fallback. Deep-link render re-verified in a harness (no loop); 606 vitest green, tsc + lint clean. Uncommitted on master.
2026-07-23 06:35 · feat(layout): cross-folder concurrent streaming for split panes — 6 sessions in 6 different projects now stream live simultaneously. Additive design (foreground client/connect/openSession untouched): a Map<dir, OpenCodeClient> of background streams (streamClients) covers every non-foreground folder shown in a pane, all folding through one captured sharedEventHandler (events are sid-keyed, no demux). New syncPaneStreams(dirs) action (driven from the LiveSessionPage wrapper by the set of pane folders) opens/closes background streams; connect() drops any background stream for the incoming foreground folder and syncPaneStreams excludes the foreground folder, so no event is ever double-folded. Sends/aborts/history/revert are session-scoped (any client); question/permission replies route to the session's-folder client via clientForSession(). 606 vitest green (incl. 85 store tests exercising the shared handler), tsc + lint + build clean. Uncommitted on master.
2026-07-23 03:30 · feat(layout): ghostty-style split panes — tile multiple sessions side-by-side with a recursive split tree, drag-resizable dividers, click-to-focus ring, and shortcuts (Cmd/Ctrl+D vertical split, +Shift+D horizontal, +Alt+Arrows focus-nav, +Shift+Enter zoom, +W close pane); sidebar Cmd/Ctrl/Alt-click opens an existing session in a new pane. New lib/layout.ts (in-memory pane-tree store + pure ops, 9 tests), lib/useDragDivider.ts (shared pointer-drag hook extracted from RightPane/Sidebar), components/session/{SessionView,PaneTree}.tsx (SessionView extracted from LiveSessionPage, now bound to a sessionId prop not the singular currentId; LiveSessionPage is a slim wrapper syncing URL↔focused pane + openSession). Runtime actions (sendPrompt/runShell/runCommand/interrupt/edit/revert/setAgentMode/pane setters) now take an optional sessionId; sending split into per-session sendingSessions so panes send concurrently. degrades to a single focused pane on phone-width + web gateway. Visually verified two-pane render; 606 vitest green, tsc + lint + build clean. Uncommitted on master.
2026-07-22 20:20 · feat(composer): inline model + reasoning-effort switcher (issues #48, #40). A compact chip left of the send button opens a picker — search, All/Favorites/Recent/per-provider filters, model list (reusing the Settings flattenModelOptions/modelPreferences helpers) — as an anchored popover on desktop/wide-web and a bottom sheet on phone-width (useIsMobile). Reasoning effort (#40) rides the same surface: SDK now surfaces each model's OpenCode variants (ProviderModelInfo.variants, parsed from /config/providers, ordered none<minimal<low<medium<high<xhigh<max) and sendPrompt forwards a per-turn variant; the store keeps providers + reasoningVariant and only sends the variant when the current model actually exposes it. The picker's "Advanced" section renders a segmented effort control built from the selected model's own variants, shown only for reasoning-capable models. Runtime-verified against the bundled opencode 1.17.13: /config/providers returns variants with per-model vocabularies (e.g. deepseek-v4-flash-free → low/medium/high/max, north-mini-code-free → none/high). Shown when connected && !webReadOnly. Also fixed a pre-existing lint error (SettingsPage k literal). +9 tests (SDK variant parse/order, store variant-forward guard, ModelPicker render/interaction), 594 vitest green, tsc + lint + build clean. On branch feat/composer-model-picker (uncommitted); DMG rebuilt for on-device testing.
2026-07-22 19:55 · fix(markdown): render \(…\) / \[…\] LaTeX delimiters (issue #51). remark-math only recognizes dollar delimiters, so bracket-delimited math from models showed as raw source; MarkdownViewer now normalizes brackets to dollars before parsing, skipping fenced/inline code and \\[2pt] row breaks. 3 new tests, 585 vitest green, tsc clean.
2026-07-23 00:52 · release: v0.2.4 published — tag v0.2.4 built all 4 platforms via CI (8 installers), changelog applied (headline: #49 local-model long-chat looping fix + custom-endpoint model/context auto-detect, #50 streaming-markdown throttle), draft published as Latest. Synced the new Zenodo version DOI 10.5281/zenodo.21501479 into CITATION.cff + 7 README BibTeX blocks (concept DOI 10.5281/zenodo.21351225 stays in the badge). Replied to and closed issues #49 and #50.
2026-07-22 07:48 · perf(thread): throttle streaming-message markdown re-parse (issue #50). The live agent message grew its text on every token and re-ran the whole react-markdown + KaTeX pipeline each time — the main UI-freeze/lag source in long turns. AgentMessage now feeds its markdown through a new useThrottledValue hook (90 ms trailing), capping the parse to ~11×/s; finished messages settle in one tick and never re-render. A second change (wrapping each BlockList row in content-visibility:auto for off-screen paint skipping) was implemented but dropped after a high-effort review confirmed two regressions the containment forces: single-shot scroll restoration (useScrollMemory) lands wrong on long transcripts, and paint containment clips card box-shadows/focus rings. Model-context compression is already handled server-side (#49), so no client summarization added. 3 new throttle tests, 582 vitest green, tsc + lint + vite build clean.
2026-07-22 03:09 · feat(settings): custom endpoint form auto-detects models + context windows (follow-up to #49). New Rust probe_endpoint_models command (webview CORS can't reach local model servers) probes Ollama native (/api/tags + /api/show → exact context length), then OpenAI-compatible /models (vLLM max_model_len, OpenRouter context_length, LM Studio max_context_length), then Anthropic-compatible; results render as toggleable chips (id · 131k) that fill the model field. Optional context-window input for hand-typed ids; per-model contexts flow into the OpenCode config (form value > stored limit > 128k default). Desktop-only button (isTauri); 7 locales updated. 7 new Rust tests (incl. 2 live-HTTP fallback tests), 579 vitest green, DMG rebuilt.
2026-07-22 02:21 · fix(sdk): custom-endpoint models without a configured context limit default to 128k (issue #49 — OpenCode 1.17.13 never auto-compacts a model with limit.context === 0, so long chats on local models silently overflowed and looped). addCustomProvider writes limit: {context: 128000, output: 0} only when no limit is configured; a once-per-run connect-time backfill repairs pre-existing endpoints; hand-set limits always win (verified against the live sandboxed sidecar: add, backfill, idempotency, re-add). DMG rebuilt.
2026-07-21 20:39 · fix(web): 7 mobile/web issues — drawer now closes on any navigation incl. same-path "New" (location.key, not pathname); FilesPage single-pane on phones; logo/brand click returns to /live; vertical CJK text fixed (settings Row stacks below sm, ProjectsPage drops the 14rem Sources column below md, Beta badge removed — it kept squeezing the brand name); the POST /session 403 was a read-only gateway token — the web client now reads mode from /v1/whoami, disables the composer with a "read-only" placeholder and hides New/starters instead of failing opaquely. 579 tests green, DMG rebuilt.
2026-07-21 15:20 · release: v0.2.3 published — re-pointed the v0.2.3 tag to include the drag-drop fix (385d50e), rebuilt all 4 platforms, applied the changelog, and published the GitHub Release. Synced the new Zenodo version DOI 10.5281/zenodo.21477879 into CITATION.cff + README BibTeX (concept DOI 10.5281/zenodo.21351225 stays in the badge). README refreshed for the new features: Remote Access (CLI / LAN web / phone) + browser control, with 4 new webp screenshots (mobile home + completed run, own-Chrome harvest, remote-A100 reproduce).
2026-07-21 15:10 · fix(desktop): drag-drop file into chat no longer copies it ~150× into project root (issue #44). Root cause: Composer's native onDragDropEvent effect keyed on [onSend], which is a fresh function every LiveSessionPage render, so it re-subscribed (leaking listeners) during stream churn — one drop fired every live listener. Now subscribes once ([]) with the drop logic held in onDropRef. Also fixed the secondary dup: add_paths_to_workspace now references a file already inside the workspace by its relative path (new workspace_relative helper) instead of copying it to root as foo-1.png. typecheck/lint/ComposerAttach + 12 Rust artifact_file tests green.
2026-07-21 14:45 · release: v0.2.3 — bumped version across package.json (root + desktop), tauri.conf.json, Cargo.toml/lock, CITATION.cff (+date), README BibTeX. Headline: Remote Access gateway (drive from CLI / LAN web / phone) + this session's web/mobile fixes and the parallel browser-control fixes. Tag v0.2.3 triggers .github/workflows/build.yml → draft GitHub Release (4 platforms). Post-publish TODO: prepend changelog + publish the draft, then sync the new Zenodo version DOI into CITATION.cff + README BibTeX (concept DOI 10.5281/zenodo.21351225).
2026-07-21 14:30 · feat(web): one-click copy link with token — Remote Access URL copy now copies <url>/#token=<token> so opening it on another device connects with no manual token entry; the web client reads #token= (query ?token= fallback) on load, stores it, and scrubs it from the address bar (hash keeps it out of server-seen requests). openHint reworded across 7 locales. (Also reverted the orphan cloudflared-tunnel i18n keys — feature not built.) typecheck/lint/579 frontend green. DMG rebuilding; not yet committed (test-first).
2026-07-21 14:10 · fix(web): notebooks display + projects collapsed by default — (1) opening a .ipynb rendered nothing in the browser: NotebookEditor loaded via readArtifact (Tauri, null in web); it now reads the raw .ipynb from the gateway (/v1/fs/read, session-dir scoped) and renders cells + saved outputs read-only (execution stays desktop-only). Live-reload poll routed through the same reader. (2) Web sidebar opened with every project's sessions expanded — now projects start collapsed in the web client (once, when no saved preference; a manual toggle persists and wins later). typecheck/lint/579 frontend tests green. DMG rebuilding; not yet committed (test-first).
2026-07-21 13:45 · fix(web): artifact preview works on mobile — (a) FilePreviewInspector used readArtifact (Tauri, null in browser) for text/bytes, so .py/.md/.csv and .docx/.xlsx/.pptx showed a misleading "desktop only" note; web now fetches text + bytes from the gateway /v1/fs/read (images/PDF/video/html already worked via the served URL), + a universal open/Download action and a friendly "can't preview — download" fallback. (b) 404 on images: the gateway resolved root=workspace to the HOST's active session, but a web client views a DIFFERENT session — now the client passes the VIEWED session's dir (from SessionMeta) as ?dir= and the gateway resolves under it (sandboxed to the base workspace) with basename search (locate_under) like the desktop preview server. +2 i18n keys ×7 locales. 11 gateway rust tests + typecheck/lint/579 frontend green. DMG rebuilding; not yet committed (test-first).
2026-07-21 13:30 · feat(desktop): confirm before turning the browser window off when reusing a real Chrome login — agent-browser drives your live Chrome in place (no profile copy; verified in browser.rs + the bundled binary's --headless=new/AGENT_BROWSER_HEADED behavior), so a named profile can't truly go headless (Chrome's single-instance hands the launch to your visible window). Toggling off now pops a ConfirmDialog explaining this and pointing to the private browser for real headless; private/isolated profiles switch without a prompt. +3 i18n keys across all 7 locales; 579 tests + typecheck/lint green.
2026-07-21 13:00 · fix(desktop): browser-control "Show the browser window" toggle can now be turned off — OpenCode's config PATCH deep-merges the nested environment, so re-adding with the flag omitted left the stale AGENT_BROWSER_HEADED="true" behind (same class: profile swap, private-browser switch, cleared domain allowlist). enableBrowser now removes the entry first (waiting for the sidecar restart), then re-adds a clean config; self-heals on next apply. +2 setup-store tests; typecheck/lint/5 tests green.
2026-07-21 12:45 · fix(gateway web): token re-auth + stable port on rotation — (1) an invalid/rotated token now drops the web client back to the token gate: a window.fetch guard (installed in main.tsx before OpenCodeClient binds fetch) catches same-origin 401s → clears token + AppShell shows WebTokenGate; entering a new token reconnects. (2) Token rotation (and mode change) no longer changes the port — refactored the gateway so the running listener reads token/mode from shared state per-request (Mutex + AtomicBool); regenerate/mode-change update in place, only enable/disable or loopback↔LAN rebinds. 11 gateway rust tests + typecheck/lint/577 frontend green. DMG rebuilding.
2026-07-21 12:15 · fix(gateway web): model-set, mobile Files pane, project actions — (1) allowed the benign default-model write (PATCH /global/config {model} only; response still redacted, key/provider/auth writes still 403) so web model switching works. (2) ROOT CAUSE of "can't see workspace files" + folder button "does nothing" + messy files on mobile: RightPane was hidden lg:block (display:none < 1024px), so the Files/inspector pane never rendered on a phone — now full-screen overlay on mobile (verified the gateway /v1/fs API returns files via curl, so it was purely the hidden pane). (3) Hid host-local project actions in web (rename/remove/open-folder/pin + sidebar new/import); folder-path button disabled in web. +1 gateway test (model-only write); 11 gateway rust tests + typecheck/lint/577 frontend green. DMG rebuilding.
2026-07-21 11:40 · feat(gateway web): read-only Projects + Runs, hide local-only features, fix mobile header — verified feature-by-feature what the web client can/can't do (agent-mapped to data sources). Added read-only gateway endpoints /v1/projects, /v1/runs, /v1/runs/query, /v1/runs/log (call the existing list_projects / list_runs / query_runs_cmd / read_run_log fns) + web branches in listProjects/queryRuns/readRunLog/listRuns + refreshProjects now runs in web → projects & run history now show. Hid local-only affordances in web: Notebooks nav, Skills environment section, composer approval-mode switch; fixed FilesPage empty-state label (listing works via /v1/fs). Fixed mobile double-header (my top bar no longer stacks on pages that own a header). Green: gateway compiles, typecheck/lint/577 frontend tests. Deferred: #3 folder/Files-pane toggle on mobile (needs screenshot), model-switch (blocked by design). DMG rebuilding.
2026-07-21 11:00 · feat/fix(gateway web): model picker + mobile pass — (1) config reads (/global/config,/config/providers,/provider/*) were 403'd, breaking the web model picker ("model not set"); now proxied with API keys REDACTED (recursive strip of apiKey/secret/token/password/authorization/credential; writes + /auth still 403) so model/provider names load but keys never leave. (2) Mobile (<768px): sidebar becomes an off-canvas overlay drawer (backdrop + hamburger in a mobile top bar, auto-close on nav) instead of squeezing content — desktop untouched. (3) Web client hides desktop-only Settings sections (runtime/connectors/browser/compute/remote). +2 gateway tests (config redaction, config-path); 10 gateway rust tests + typecheck/lint/577 frontend tests green; DMG rebuilt. TODO from user feedback: top-right folder button unclickable on mobile (needs screenshot), settings mobile polish.
2026-07-21 10:35 · fix(gateway): API paths (SSE + all JSON) were served index.html — serve_asset ran before the OpenCode proxy and the Tauri asset resolver answers unknown paths with the SPA index.html fallback, so /event came back as text/html (EventSource "MIME text/html" errors) and /experimental/session etc. returned HTML (sessions/runs/notebooks blank). Now only static-looking GETs (/assets/* or known extension) hit the asset resolver; real API paths fall through to the proxy. +2 tests (looks_static, socket-blocking); 9 gateway tests green. Mobile-responsive web UI still pending (next).
2026-07-21 10:15 · fix(gateway): assets truncated / intermittent 400 in the web client — the accept loop's non-blocking listener yielded non-blocking accepted sockets (inherited on macOS/Linux), so mid-request read/write hit WouldBlock: request parse failed → 400, and a partial write of a large asset (the SPA JS bundle) → ERR_CONTENT_LENGTH_MISMATCH. Force each accepted socket back to blocking after accept(). +1 deterministic regression test (5 MiB body over the exact accept pattern arrives un-truncated); 8 gateway rust tests green; DMG rebuilt (0.2.2).
2026-07-21 10:10 · feat: Remote Access gateway (Phase 1) — new std-only Rust gateway.rs (zero new deps) turns the desktop into one bearer-token HTTP API (loopback default, LAN opt-in; sidecar stays loopback-only). It SERVES THE REAL DESKTOP SPA from the app's embedded assets and transparently proxies the OpenCode API, so a browser/phone runs the identical OpenCodeClient+UI (a window.__OS_WEB__ marker + token gate switch the same app into web mode; connect()/bootstrap()/artifactFile gained web branches) — not a re-implementation (deleted the earlier hand-rolled client). Secrets never cross the wire: /global/config,/provider,/auth → 403. Also a small /v1 contract (whoami/sessions/prompt/events/permissions/fs) for CLI/curl; full/read-only token ceiling; remote-created sessions surface locally via gateway:sessions-changed → refreshSessions(); Settings → Remote Access card in all 7 locales. RFC docs/rfc/remote-access-gateway.md. Green: 7 gateway rust tests + typecheck/lint/577 frontend tests; DMG built (0.2.2). NOT yet live-verified in a browser (see notes). CLI/tunnel/Feishu/allowlist-auto-approve/remote-Jupyter are later phases.
2026-07-21 07:38 · fix(desktop): example-session header no longer double-stacks the titlebar (added /example to AppShell.pageOwnsTitlebar; ThreadView header mirrors LiveSessionPage — fixed h-12, drag region, traffic-light clearance) + 3 new promo example sessions (browser-control web harvest, reproduce-scVI-on-A100 with provenance/review, multi-agent protein-LM survey→PDF); added on-brand SVG figure generators (browserShot/barChart/lineCurve); 577/577 tests green, DMG rebuilt.
2026-07-21 07:00 · refactor(sdk) #36 merged + roadmap reframed — landed BaseAgentRuntime base class (listener/status plumbing extracted from OpenCodeClient; verified locally: typecheck/lint clean, 18/18 sdk tests green, behavior-preserving, no scope creep) and rewrote PRD §9 roadmap around the analysis: CLI / LAN web (#3) / cloud tunnel / Feishu (#20) / ACP-server (#14) collapse into one northbound authenticated API gateway over the AgentRuntime seam (v0.4.0), and ACP-client (#14/#25/#28) / remote agent runtime / remote Jupyter (#35) are the same seam consumed southbound via pluggable transport (v0.5.0).
2026-07-21 06:15 · feat(desktop): guard message edit/revert as destructive ops — Edit and new Revert (revert-here + prefill composer, no auto-send) both go through a confirm dialog (default Cancel) warning that later messages + their file changes are dropped; verified the two file-history layers are git-isolated (our append-only shadow refs refs/openscience/snapshots/* in the workspace .git vs OpenCode's revert against an isolated git-dir under ~/Library/Application Support/opencode), so revert rolls back working-tree files but pre-revert state stays recoverable; +5 tests, 577 green, DMG rebuilt.
2026-07-21 05:40 · feat(desktop): edit past user messages — SDK gains session revert/unrevert (+ message id on history/live blocks); editMessage stops any running turn, reverts to the message (drops it + everything after, rolls back its files), and resends; user turns now render right-aligned in a content-hugging bubble with hover Copy/Edit and inline editing; +11 tests, typecheck/lint/573 tests green.
2026-07-21 02:44 · feat(#38): agent activity now visible — stream reasoning/"thinking" (was dropped in SDK), show model step count in the working line, mark the tool blocked on approval as waiting-approval in the transcript, and add a cross-session running spinner in the sidebar; +5 tests, typecheck/lint/557 tests green.
2026-07-21 01:25 · docs: v0.2.2 released (GitHub Release published, 8 installers live) + synced Zenodo v0.2.2 DOI 10.5281/zenodo.21465187 into CITATION.cff and all READMEs (concept DOI badge unchanged).
2026-07-20 03:39 · chore(release): v0.2.2 — Settings UI redesign (flat low-chrome controls), SSE render-storm fix (#34), model-switch self-heal guard + custom-provider disconnect auth (#37), privacy header fix. Tagged v0.2.2; CI (build.yml) builds the macOS/Windows/Linux installers into a draft GitHub Release. Zenodo DOI to sync after publishing.
2026-07-20 02:58 · refactor(desktop): redesign Settings UI (UI-only, no behavior change) — replaced bordered controls-inside-cards ("box-in-box") with one low-chrome language: borderless filled "well" inputs, right-aligned transparent dropdown chips (fill on hover), and soft-filled ghost buttons (inputCls.ts + btnGhost). Grouped Runtime's server/proxy/mirrors into one card of rows; flattened the surface-2 add-form panels in Providers/MCP/Compute and the ModelBrowser's inner frame + shaded sidebar (transparent filters, faint dividers, borderless rows); read-only paths (workspace, Python) are plain mono text; Appearance theme lost its segmented track and language became a chip; Privacy reworked into a two-side data-flow with tinted icons + hairline rows. +1 i18n key (runtime.serverLabel) across 7 locales. lint + tsc + vite build clean; 35 settings tests pass. (Worktree.)
2026-07-20 02:24 · fix(desktop): stop the connect-time self-heal from reverting a just-switched model (#37). Root cause of "model switch doesn't take": setDefaultModel does a masked reconnect, and connect() fires loadCatalog() UN-AWAITED — so loadCatalog's dangling-model self-heal (added for #18) runs a beat AFTER switching clears, reads the reconnecting instance's not-yet-complete provider list, judges the just-picked model "dangling" via fallbackDefaultModel, and re-points it back to an old model (repeats until the catalog warms → the multi-turn lag / "not recognized even after reboot"). The switching guard never covered this because the fired loadCatalog resolves after the flag is cleared. Fix (safe, no dispose — disposing to force a fresh list would kill the freshly-opened event stream, the exact reason setDefaultModel avoids it): remember the model the user last DELIBERATELY switched to + when (lastSwitchModel/lastSwitchAt), and skip the self-heal for that model within a 15s grace window. A genuinely-dangling model still heals once the window lapses (#18 preserved), and the send-time "model not found" error covers the gap. NOTE: the separately-observed 1-turn lag (asking right as you switch) is a benign race — a turn already dispatched can't change model — not this bug. Added a regression test (self-heal must not revert a just-switched model transiently absent from the catalog); 552 frontend tests green, tsc + eslint clean.
2026-07-20 01:39 · fix(desktop): cure the SSE tool-event render storm that froze the WebView (#34) + clear stale custom-provider auth on disconnect (#37). #34 root cause (the CPU/render half the last commit deferred): LiveSessionPage — the conversation on screen during a tool-heavy session — used a bare useRuntimeStore(), subscribing to the WHOLE store, so every SSE fold of every event for any session (background subagents included) re-rendered the entire conversation; combined with BlockList rendering all N blocks per event, that is the O(N·M) microtask/GC storm. Same trap the codebase already fixed for SettingsPage but never applied to LiveSessionPage/Sidebar/SkillsPage. Fix: (1) selector-ize those three pages; LiveSessionPage selects the ACTIVE thread on its own, so a background subagent's folds no longer touch it; (2) React.memo every block leaf (ToolRow, ToolCallRow, AgentMessage, UserMessage, DataTable, RunningJobsOverlay, StatusLine, ReviewerCard, StepSummaryRow, FigureBlock, ArtifactCard) and BlockList itself — foldEvent's [...blocks] copy preserves every unchanged block's reference, so a fold now re-renders only the one row it touched; (3) subagent activity moved into a self-subscribing SubagentActivity leaf (selector returns the activity string, re-renders only when it changes) so a child's folds never re-render the parent row. #37: disconnecting a CUSTOM provider removed only its config entry, leaving a panel-set key in the auth store keyed by the name-derived id — re-adding "only worked when the name matched exactly" as the stale key silently re-attached; now also best-effort removeProviderAuth. tsc + eslint + 551 frontend tests green.
2026-07-19 14:30 · fix(desktop): self-heal a dangling default model on connect (#18 follow-up) — a stale default model (its provider removed, its id renamed, or the config edited outside the app, e.g. kimi-for-coding/k2p7) made every send fail with ProviderModelNotFoundError: Model not found. The fallbackDefaultModel re-point existed but only ran after a Settings provider action (SettingsPage.refreshAll), which never fires while the user is just chatting. Moved the check into loadCatalog (runtime.ts) — it runs on every connect regardless of which page is mounted: now also fetches listProviders (via the concrete opencodeClient, since it's not on the AgentRuntime port), and if the persisted default is no longer in the flattened list, applies the closest surviving model and toasts settings:toast.defaultModelReset. Skips while switching (a switch owns the model) and when providers failed to load (empty ⇒ no fallback), so no false re-points; nested loadCatalog from the heal's reconnect is a no-op (valid model). tsc + eslint clean.
2026-07-20 03:20 · feat(desktop): workspace snapshots commit to dedicated PER-BRANCH refs, never a branch — commit() (git_snapshot.rs) stages into a dedicated index (GIT_INDEX_FILE=.git/openscience-index, leaving the user's .git/index untouched), writes a tree, and records it via commit-tree + update-ref refs/openscience/snapshots/<branch> — no refs/heads/*, HEAD, working-tree, or staging change ever. One snapshot chain per user branch (git-wip's refs/wip/<branch> convention; slashy names percent-encoded to dodge git D/F ref conflicts; detached HEAD → _detached bucket). First snapshot on a branch parents on that branch's tip (HEAD) so history reads continuously and diffs are meaningful; unborn branch → no parent. This lets us snapshot a repo the user brought in for the first time (branches/staged work stay byte-for-byte intact; inspect via git log refs/openscience/snapshots/<branch>); only imported plain folders (.no-snapshots) stay opt-out. Evaluated dura/git-wip/git-snapshot for reuse — all standalone daemons/scripts with their own watchers/refs, so reused the convention, not the code (~15 lines on our tested plumbing). oversize/bulk guards ported to the dedicated index (ls-files + update-index --force-remove); empty/unchanged-tree skip retained. UI provenance reads .openscience/ (not git) → backend-only. Tests assert the branch/index-untouched + per-branch guarantees; cargo test 107/107, clippy clean.
2026-07-20 02:40 · fix(desktop): workspace git snapshots no longer freeze the UI or spam commits (#32) — inline commit_best_effort on every file add ran git on the UI thread and produced one commit per file (46 commits in 47 s, window hung). Now a background debounced snapshotter (git_snapshot.rs): callers/watcher request_snapshot(root), a single "git-snapshot" thread coalesces via trailing debounce (3 s) + max-wait cap (30 s), commits off-thread. The four add_*/write_workspace_file commands enqueue instead of committing and are #[tauri::command(async)] (file IO off main thread too). Added a notify FS watcher on the active workspace (re-pointed in set_workspace, started in lib.rs setup) so external-editor/detached-process changes snapshot too, ignoring .git/ to avoid a commit loop. Existing oversize/bulk guards + empty-commit skip retained. +1 timing unit test; cargo test 106/106, clippy clean. (Done in a git worktree to avoid colliding with a concurrent agent on the main checkout.)
2026-07-19 21:12 · fix(desktop): #31 import-by-faithful-copy + sidecar log capture, hardened after high-effort review — import_project copies the picked folder into a base-dir project (sidecar only touches app-created paths, so ~/Documents no longer fails TCC/EPERM); copy preserves files+perms, .git history, and symlinks (skips FIFO/socket, rolls back on failure, drops the source's .openscience/); delete removes the whole copy via new imported_from marker; copied git repos get app files hidden via .git/info/exclude; sidecar stderr/exit now logged to debug.log; default workspace unchanged; cargo 105/105 + tsc green; Developer ID signing still deferred, real macOS TCC untested here.
2026-07-17 06:18 · fix(desktop): passive run-capture now sees through transparent launch wrappers — stripPrefixes (runs.ts, detection-only) strips a leading nohup/time/timeout <dur>/stdbuf <-flags> so the real interpreter head shows through; nohup python3 x.py & is now recorded instead of silently dropped. Original command stored verbatim (wrapper, redirects, trailing & all preserved — Reproduce re-runs it exactly); interpreter allowlist still gates, so wrapped non-runs (nohup rsync …) stay out. +2 unit tests; runs.test.ts 19/19, tsc green. (Root cause: gate matched only the command HEAD, and nohup isn't in the interpreter allowlist.)
2026-07-17 03:46 · feat(desktop): make the private/downloaded browser a first-class, always-visible choice — new "Private browser (no login)" option in the Browse-as picker (sentinel PRIVATE_BROWSER) selectable even when Chrome is installed. Picking it leaves AGENT_BROWSER_EXECUTABLE_PATH unset (agent-browser uses its own Chrome-for-Testing, never touches the user's Chrome) and surfaces the Download action (setup_browser_chrome → agent-browser install, proxy+progress); no Chrome ⇒ auto-coerced to private. enableBrowser gained useSystemChrome; config reverse-parse infers system-vs-private from executablePath. i18n browser.privateBrowser/privateNote across 7 locales (parity 13/13). tsc/eslint green; DMG rebuilt. (Before: download only appeared when no Chrome was detected, so Chrome users never saw it.)
2026-07-17 03:31 · feat(desktop): move browser control to its own reconfigurable Settings page — new "browser" section (SETTINGS_SECTIONS + nav.browser) instead of a one-shot Connectors card. Full config UI: enabled/disabled status, detected-browser row (+ download-when-none), Browse-as profile picker, Capabilities (--tools core|core,network|all), Allowed domains textarea (AGENT_BROWSER_ALLOWED_DOMAINS guardrail), show-window toggle, Enable/Apply/Disable. Current values reverse-parsed from the live browser-control MCP config; Apply re-PATCHes the same key. i18n browser.* expanded to 27 keys + nav.browser across 7 locales (parity 13/13). Verified tsc/eslint/parity; DMG rebuilt. Open: default-select first profile? finer capability multi-select?
2026-07-17 02:10 · feat(desktop): browser control via bundled agent-browser (vercel-labs v0.32.1) sidecar — registered as the browser-control local MCP (agent-browser mcp --tools core) through OpenCodeClient.addMcpServer, mirroring the science-connector pattern. New scripts/dev/fetch-agent-browser.sh (raw per-triple binaries, ~11 MB) + externalBin entry; Rust browser.rs (agent_browser_bin path resolve, agent_browser_profiles via profiles --json, detect_chrome, setup_browser_chrome download w/ proxy+progress). Settings "Browser control" card: Chrome-profile picker (reuse a copied profile, never modifies the original; isolated default), headed toggle, download-when-no-Chrome. i18n browser.* added to all 7 locales (parity test green). Reuses detected Chrome via AGENT_BROWSER_EXECUTABLE_PATH (avoids CfT download; clean cookie decrypt on macOS). Verified: cargo check, tsc, eslint, parity 13/13, and a live MCP stdio handshake (tools/list returns agent_browser_* + allowedDomains). Not yet: bundle binaries for non-macOS triples in CI/release; DMG rebuild for on-device test.
2026-07-17 00:09 · chore(release): v0.2.1 — bumped version across all six locations (root + desktop package.json, tauri.conf.json, Cargo.toml/.lock, CITATION.cff + date-released, README BibTeX). Ships since v0.2.0: Projects page (search/pin/recency/expandable sessions/delete-index), import an existing repo as a project, image upload (paste + drag-drop + paperclip), LaTeX rendering, and run-tracking/venv/model-binding fixes (#22, #23, #27). Tag v0.2.1 triggers CI (build.yml → tauri-action) to build all-platform installers into a DRAFT release; publish after prepending the changelog. Per-version Zenodo DOI stays v0.2.0's until minted on publish, then synced into CITATION.cff + README.
2026-07-16 23:49 · test(sdk): fix flaky opencode-client.node suite under parallel load — the mock server was shared across the file (beforeAll) and broadcasts turn events to ALL connected SSE clients, so under load one test's timer-driven turn streamed into another test's client and a stray session.idle satisfied its waitFor before the expected events (session.retry seen as undefined). Root cause of the last assertion was the maps time.completed test reading getMessages eagerly — the mock stores history inside the turn (on a 5ms timer), so an unsynchronized read raced it (empty history) once isolation removed the accidental cross-test leftover. Fix: per-test server (beforeEach/afterEach) for full isolation + wait for session.idle before reading history. 12/12 consecutive full-suite runs green (was ~50–100% failing that one test); no product code touched.
2026-07-16 22:18 · feat(desktop): paste & drag-drop files/images into the composer (#22 pt.2) — a clipboard image becomes a workspace file chip (pasted.png) via onPaste (finds an image/* item, guarded items ?? [], FileReader→base64) through a new std-only-base64 add_binary_to_workspace; a long-text paste still becomes pasted.txt. Drag-and-drop any files onto the window → chips: Tauri captures OS file-drops natively (DOM drop never fires), so we subscribe to its webview onDragDropEvent (absolute paths) and copy them via add_paths_to_workspace, with a drag-over highlight. All land in the draft's own folder (ensureDraftWorkspace) so the session sees them. Cross-platform: image/* clipboard + FileReader and Tauri's drag-drop event all work across macOS/Windows/Linux webviews. (Paperclip file-attach already accepted images; it just needed the draft-folder fix.) 547 FE tests (+ image-paste case, + Rust base64 round-trip), typecheck, lint, clippy pass.
2026-07-16 21:26 · fix(desktop): composer files (paste / attach) on a draft no longer orphaned — a long paste (→ pasted.txt) or the paperclip add-files wrote into whatever workspace was active at that moment, but sending a fresh draft first creates a NEW dated folder (datedWorkspaceName), so the file landed in the pre-send folder and the agent couldn't find it (repro: "Files added: pasted-1.txt" then the session's dated workspace was empty). New ensureDraftWorkspace store action materializes + pins the draft's dated folder before the write (no-op once a session exists / folder is pinned); Composer calls it before addTextToWorkspace and addFilesToWorkspace, so files and the session share one workspace. 546 FE tests, typecheck, lint pass.
2026-07-16 21:07 · feat(desktop): render LaTeX math in messages & markdown previews (#22 pt.1) — added remark-math + rehype-katex + bundled KaTeX to the shared MarkdownViewer, so $…$/$$…$$ render as math in chat bubbles and .md document previews (both variants). throwOnError:false shows bad expressions in red instead of blanking the message; a lone $5 stays literal. KaTeX fonts bundle into the offline build. 546 FE tests (incl. new KaTeX cases), typecheck, lint pass.
2026-07-16 03:22 · feat(desktop): Projects page + sidebar recency/pin + Codex-style create menu — new /projects route (ProjectsPage): search, sort-by-recency table (Name/Sources/Updated), expandable per-project Sessions (open on click), pin, inline rename, and Remove (confirm dialog). Remove deletes only the index — delete_project drops an imported project's stub or an app-created project's .openscience/project.json marker; files on disk are never deleted. New set_project_pinned/delete_project/open_project_folder commands; ProjectMeta/Info gain pinned. Clicking a project's Sources chip opens its folder in the OS file manager (via the opener crate; resolved server-side from the project id). Sidebar shows every pinned project + the 5 most-recent others (recency from newest session), with an "All projects (+N)" link and a clearly-clickable Projects heading (folder icon + chevron) → page. Surfaced session time.created/updated through SessionMeta (was dropped). New-project + is a Codex-style dropdown (Start from scratch → name modal / Use an existing folder → import). 543 FE tests, 104 Rust tests, typecheck, lint, clippy pass.
2026-07-16 01:58 · fix(desktop): record artifact versions for apply_patch writes — provenance only recorded writes whose tool exposed a path field (write/edit), but apply_patch names each file inside patchText and can touch many files per call, so deriveArtifact found no path and dropped every one → sessions editing via apply_patch had an empty .openscience/provenance.jsonl and the History panel showed "No versions recorded yet." Added parsePatchFiles (artifacts.ts) to split a patch into per-file sections and provenanceInputsFromEvent (provenance.ts) that fans an apply_patch event out to one record per add/update (delete skipped; add → full text, update → diff); runtime now dedupes per (callId, path). Verified against real session data: 22 apply_patch calls → 49 file-version records incl. 5 PROGRESS.md (was 0). Forward-only — past sessions aren't backfilled. 33 FE provenance/artifacts tests pass, typecheck clean.
2026-07-16 01:40 · feat(desktop): import an existing repo as a project (referenced in place), never auto-commit imported repos — New-project sidebar gains an "Import repo" action (pickFolder → import_project). An imported project is a lightweight pointer: a stub folder under the base dir holds project.json with sourcePath; the user's repo is not moved, not scaffolded (no harness seed), and never written a project.json. Safety: git_snapshot only ever commits into a repo it git init'd itself (marker .git/.openscience-snapshots), so a brought-in git repo is already skipped; mark_imported additionally plants a .openscience/.no-snapshots opt-out for imported non-repo folders (so a later commit never git inits them) and adds .openscience/ to the repo's local .git/info/exclude (not the tracked .gitignore). rename_project is now keyed by project id (an import's meta lives in its stub, not at its external path). ProjectInfo gains imported; imported projects show a badge. 539/539 FE tests, 103 Rust tests, typecheck, lint, clippy pass.
2026-07-15 23:18 · fix(desktop): run tracking recognizes path-form/venv Python + exact-interpreter env + old-session model rebind (#23, mona-aliye) — three independent fixes. (1) runs.ts classifies a run by the head command's executable basename (unwrap PowerShell &, strip dir prefix /+\, quotes, .exe/.cmd/.bat), so C:\proj\.venv\Scripts\python.exe, /proj/.venv/bin/python, ./.venv/bin/python, "C:\Program Files\…python.exe" all record — not only bare python. (2) provenance.rs capture_env now extracts the explicit interpreter path from the command and snapshots THAT venv's --version/pip freeze (cache keyed per-interpreter, not one global OnceLock); bare python/writes fall back to app default. (3) OpenCodeClient.sendPrompt passes the current default model {providerID,modelID} every turn so old sessions (OpenCode persists session.model at creation) follow a model switch — no SQLite rebind. 537/537 FE tests, Rust provenance/runs tests, typecheck, clippy pass.
2026-07-15 21:45 · feat(desktop): native permission-request notifications (#26, Moonerss, closes #21) — on a permission.asked event, fire a native OS notification (deduped by requestId) so a backgrounded user sees the agent is blocked; the in-app card stays the only approval surface. Adds @tauri-apps/plugin-notification + tauri-plugin-notification, notification:default capability. Fixed one regression in the PR: it dropped the tauri macos-private-api feature (breaks the vibrancy sidebar) — restored. Cross-platform build verified: CI matrix green on Windows + Linux + macOS×2 (run 29471963358). 534/534 FE tests, typecheck, lint pass.
2026-07-15 21:35 · feat(sdk): AgentRuntime interface boundary (#24, Justin Gao) — merged encyc's RFC Phase 1 (formalizes the packages/sdk seam AGENTS.md already mandates as interface AgentRuntime; OpenCodeClient implements it; store types its internal client against it while getClient() keeps the concrete OpenCode provider/MCP surface). No behavior change. Fixed one staleness: interface sendPrompt gained the agent? param plan-mode (#20) added, else typecheck failed TS2554. 530/530 FE tests, typecheck, lint pass.
2026-07-15 21:30 · feat(plan): Codex-style Plan mode (#20) — composer gains a Build/Plan switch (per-session, in-memory) that pins OpenCode's built-in read-only plan agent per prompt (PromptInput.agent; no config write/restart); plan mode tints the composer blue with its own placeholder; state syncs from user-message agent fields (new SDK message.agent event + history seed), so opencode's own plan_exit "Yes" flips the pill to Build automatically; switch hidden when the runtime lacks a plan agent. Verified on 1.17.13: agents list has plan, plan sends stamp agent:"plan", default sends resolve to build, plan turns are read-only. 530/530 FE tests, typecheck, lint pass, DMG rebuilt.
2026-07-15 19:40 · fix(ui): reopened /goal sessions showed the raw expanded command template as the "user message" — historyToThread's collapse only handled trailing-args templates; it now matches prefix+suffix around a mid-template $ARGUMENTS. And CJK-named deliverables (青云录_剧情.docx) never got a clickable artifact chip — extractArtifactRefs used ASCII \w; now Unicode \p{L}\p{N} with /u. 520/520 FE tests, typecheck, lint pass, DMG rebuilt.
2026-07-15 18:50 · fix(goal): pause→resume threw the plugin's StateDecodeError — goal_update wrote history type "pause"/"resume" but the plugin's Effect Schema only accepts "paused"/"resumed" (and status has no "blocked": the real enum is unmet/budgetLimited/usageLimited); the earlier "pause honored" pass was a false positive (the loop stopped because decode CRASHED). Fixed literals, pill now maps the real status enum (warn tone for limited states), and reads/writes self-heal poisoned history entries so broken state recovers on session open. Re-verified end-to-end on 1.17.13: pause honored (0 decode errors), resume+nudge completes the goal. 518/518 FE + 96/96 Rust, DMG rebuilt.
2026-07-15 17:40 · feat(goal): Codex-style /goal shipped — vendored @prevalentware/opencode-goal-plugin 0.1.24 as ONE esbuild-bundled file (fetch-goal-plugin.sh; opencode 1.17 ignores npm plugin specs) deployed into the app profile + injected into the config plugin array (ensure_goal_plugin, idempotent); new goal.rs reads/writes the plugin's goals.json directly so the header GoalPill (objective + live status + auto-turn count + pause/resume/clear) never costs a model turn; verified on 1.17.13: spaced-path load, external pause stops the loop race-free, resume needs a one-turn nudge (GOAL_RESUME_NUDGE wired); 517/517 FE + 95/95 Rust tests, DMG rebuilt with the plugin resource inside.
2026-07-15 15:15 · research(goal): verified @prevalentware/opencode-goal-plugin 0.1.24 (MIT, Codex goal-mode port) against our bundled opencode 1.17.13 in serve mode — full loop works: /goal registers via config hook (composer will list it automatically), evidence-gated completion with history/checkpoints, and session.idle auto-continuation (autoTurns 0→2 on a forced multi-turn goal). Caveat: 1.17.13 does NOT auto-install npm plugin specs (silent no-op) — must vendor + load via file:// path. Ready to integrate.
2026-07-14 01:20 · roadmap: triaged all 5 open issues (8 asks) into GitHub milestones — v0.2.1 patch (fixes on master), v0.3.0 Research UX (#20 #21 #22: LaTeX, image upload, plan-first, adaptive approvals, notifications), v0.4.0 Reach (#3 LAN web UI, #20 messaging, #14 ACP) — and rewrote the stale PRD §9 roadmap to match reality.
2026-07-14 00:55 · fix(ui): code was unreadable in dark — CodeViewer hardcoded highlight.js's light github.css; token colors now live in index.css as per-theme --hl-* variables (.hljs-* rules mirroring github/github-dark), so all three themes highlight correctly; 511/511 FE tests, typecheck, lint pass, DMG rebuilt.
2026-07-14 · fix(desktop): traffic lights drift on some Macs — tao re-applies the configured inset only from drawRect, which the transparent+vibrancy window can stop firing; new src-tauri/src/macos.rs re-pins the lights on Focused/Resized/ThemeChanged. fix(session): a hard reload on /live/:id showed a permanent skeleton — openSession ran before bootstrap had a client and bailed with no retry; the page effect now re-fires on connected and on the session's directory arriving. 511/511 FE tests, cargo check, typecheck, lint pass, DMG rebuilt.
2026-07-13 23:50 · release: v0.2.0 published (6 installers via CI) and the first Zenodo DOI minted — concept 10.5281/zenodo.21351225 (badge in 7 READMEs), version 10.5281/zenodo.21351226 (BibTeX + CITATION.cff).
2026-07-13 23:15 · release: v0.2.0 tagged (new UI: 3 themes, vibrancy sidebar, Codex-style settings, in-app zoom; fixes: provider retries/errors #17 #18, git snapshot bloat #19, uv/python provisioning, sidecar lifecycle) — CI builds the draft release; publish mints the first Zenodo DOI, then the DOI badge + CITATION.cff doi still need adding.
2026-07-13 22:55 · fix(ui): settings row dividers glowed white in dark — divide-border-faint is not a valid Tailwind token (color key is faint), so divide-y fell back to Tailwind's default gray-200; both usages now divide-faint; 511/511 FE tests pass, DMG rebuilt.
2026-07-13 23:40 · feat(ui): light is now the default theme; per-theme accents (light #2563eb blue, dark #4d9df6 blue on dark text, warm keeps terracotta); Settings › Appearance gains a Zoom row (−/%/+/reset wired to the in-app ZoomProvider, shortcut hint ⌘/Ctrl +−0); 511/511 FE tests, typecheck, lint pass, DMG rebuilt.
2026-07-13 23:20 · fix(ui): sidebar collapse/expand button now stays aligned with the macOS traffic lights at any Cmd/Ctrl +/- zoom — the native lights don't zoom but the strip's h-12/pl-[78px] did; app now owns zoom (ZoomProvider, zoomHotkeysEnabled: false, persisted ai4s.zoom) and exposes it as --zoom so titlebar strips counter-scale height + inset by 1/zoom (overlayTitlebarStyle); 511/511 FE tests, typecheck, lint, FE build pass.
2026-07-13 23:00 · fix(ui): root-caused the washed-out dark sidebar — setTheme was silently denied (missing core:window:allow-set-theme capability), so the vibrancy material never followed the in-app theme; capability added, tint now uses each theme's own --bg (warm stays warm, dark 80%), and the AppShell titlebar strip renders on every non-live page under the overlay titlebar so the whole content-area top is draggable (was only when the sidebar was collapsed); 511/511 FE tests, typecheck, lint pass, DMG rebuilt.
2026-07-13 22:25 · fix(ui): dark sidebar no longer washes out to gray — vibrancy translucency now theme-scoped (55% light/warm, 92% dark, [data-vibrancy][data-theme="dark"] override); all native <select> chrome replaced with a flat selectCls/.select-chrome (appearance:none + own chevron) across settings and inspector; 511/511 FE tests, typecheck, lint pass, DMG rebuilt.
2026-07-13 21:45 · feat(settings): Codex-style settings — sidebar becomes the settings navigation on /settings/* ("Back to app" on top, 7 section routes general/appearance/models/runtime/connectors/compute/privacy, collapse disabled so the exit can't vanish), pages rebuilt on flat Section/Row/Switch primitives (heading outside one bordered container, no nested cards; language grid → select, update checkboxes → switches); sidebar vibrancy opacity 80%→55% (was imperceptible over the light material); lint + 511/511 FE tests pass, DMG rebuilt.
2026-07-13 21:05 · feat(ui): three themes — new light (Codex-style clean white, neutral grays), warm (the old paper light, still the default; storage key bumped to ai4s.theme.v2 with legacy light→warm migration), dark; settings picker + palette cycle + 7 locales updated, and on macOS the window gains a sidebar vibrancy material (tauri.macos.conf.json, macos-private-api) with the sidebar at 80% opacity so the desktop tints through, native appearance synced to the in-app theme; 511/511 FE tests, typecheck, lint pass, DMG rebuilt.
2026-07-13 20:20 · fix(session): a failing provider no longer looks like an infinite "Working…" — repro'd with an apevon relay whose key group has no channel for the model: OpenCode classifies the 5xx as retryable and retries with UNCAPPED exponential backoff, emitting only session.status {type:"retry"} events the SDK dropped, and a turn whose live session.error is missed reloads with info.error stripped by getMessages (empty reply + "done", nothing else). Now the SDK maps session.status→session.retry, the store keeps per-session retryNotices (cleared by any other sign of life), LiveSessionPage shows "Provider error — retrying (attempt N): " in the working row, getMessages carries info.error, and historyToThread renders it as a red status line (abort errors stay quiet). Mock server grew a flaky-turn mode; 511/511 FE tests, typecheck pass, DMG rebuilt.
2026-07-13 19:45 · fix(settings): resolve GitHub issues #17 & #18 — (#18) the send path never names a model, so OpenCode's global default (opencode.jsonc model) is the only source of truth, and no provider mutation ever reconciled it: editing/removing a custom provider left a dangling provider/model and every send failed with ProviderModelNotFoundError; refreshAll now re-points a dangling default at the closest surviving model (new pure fallbackDefaultModel in modelCatalog, toast on reset) and session-scoped "model not found" errors now say where the fix lives. (#17) OAuth "browser shows success but the app never updates" — refreshAll was triggered ONLY by the hanging /oauth/callback long-poll, so a lost loopback redirect (port collision, proxy) froze the UI while the token was already in the sidecar's auth.json; the wait now races the callback against a 2s credential-store watcher (new Rust provider_auth_exists reading the sidecar's auth.json) and re-checks the store before declaring failure/timeout, refreshing the provider cache when the store wins. 508/508 FE tests, 91/91 Rust tests, typecheck pass, DMG rebuilt.
2026-07-13 06:35 · fix(git): stop snapshot auto-commit from bloating .git — commit() ran git add -A with no .gitignore and no size guard, so run-produced media/datasets/weights were committed forever (worse under per-run commits, cost ≈ runs×size). Added three defenses in git_snapshot.rs: (1) a comprehensive default .gitignore at repo init (secrets/.env, OS junk, Python/Conda/R/Node envs & caches, and bulk binary media jpg/png/wav/mp4/… — keeps .svg/.pdf; never clobbers a user's file); (2) unstage_oversized drops any single staged file ≥20 MB; (3) unstage_bulk_dirs drops any directory whose freshly-staged files sum ≥50 MB (catches thousands-of-small-files datasets that per-file misses), grouped by immediate parent so sibling source trees survive. All unstage-only (files kept on disk, logged). Thresholds from worst-case-bloat math. 90/90 Rust lib tests pass (5 git_snapshot); clippy clean for the module.
2026-07-13 05:23 · fix(mcp): harden first-run Python provisioning against the Windows "uv venv failed: Using CPython …" report — uv venv reused a broken/locked system Python; it now tries reuse first and falls back to a downloaded managed Python (new uv::create_venv, science-MCP + jupyter), and the bundled uv finally inherits the configured proxy plus new optional PyPI / Python-download mirror settings (uv_network_env; Settings › Agent runtime) since a GUI-launched uv otherwise ignored the proxy and hung on restricted networks; 87/87 Rust + SettingsPage FE tests, typecheck, lint pass (Windows-only repro — DMG verify-only on macOS).
2026-07-12 23:45 · fix(runtime): model switches no longer flash the page — OpenCode's instance rebuild closes /event ~1s after the PATCH (outside the switching mask) and the SDK self-recovers in ~250ms, but the store mirrored the transient ready→connecting flip straight into the UI; a ready→connecting flip is now held for a 2s grace window and surfaced only if the stream stays down (errors still show immediately); 504/504 tests, typecheck pass, DMG rebuilt.
2026-07-12 23:30 · fix(settings): the model picker no longer bounces back to the previous model after a switch — getDefaultModel read the instance-scoped /config, which lags the /global/config PATCH by ~1s while OpenCode rebuilds its instance, so the reconnect's loadCatalog could clobber the new selection with a stale read; now the read targets /global/config and loadCatalog leaves defaultModel alone mid-switch; 501/501 tests, typecheck pass, DMG rebuilt.
2026-07-12 08:01 · fix(settings): PR #13 review fixes on feat/model-browser-fixes — the store now owns model-switch failure state (modelSwitchError; fixes the null===null first-boot misrender, the retry that collapsed the browser, and the stale-catalog-after-URL-change write hazard), catalog gained a loading state scoped to listProviders, the providers card stays collapsible when disconnected, model rows keep focus via aria-disabled, the dead model.notSet key is live again, and the two force-added docs/superpowers/ files are removed; 500/500 tests, typecheck, lint, build pass.
2026-07-12 15:10 · fix(desktop): #12 — the app had no zoom mechanism at all, so WSLg users hit by its HiDPI scale-factor bug (fonts shrink on maximize; upstream wslg#23/#388/#1335) had no way to recover; enabled zoomHotkeysEnabled + the core:webview:allow-set-webview-zoom capability so Ctrl/Cmd +/- zoom now works on all three platforms; DMG rebuilt.
2026-07-12 11:25 · feat(settings): replaced the large native model dropdown with a searchable two-column browser (all/favorites/recent/provider filters), local favorite/recent persistence, immediate masked switching, and a separate collapsed provider-management card; full frontend tests, typecheck, lint, Windows Tauri build, and visual QA pass.
2026-07-12 01:32 · fix(runtime): serialized the complete OpenCode sidecar lifecycle behind one lock, cleared stale URLs on failed restarts, and deduplicated React StrictMode bootstrap calls so concurrent starts cannot double-spawn, overwrite the owned child, or launch dueling reconnect loops; Rust 87/87 + frontend 448/448 tests and the production build pass.
2026-07-10 21:55 · fix(mcp): #10 root cause — enabling a second Python MCP re-ran uv venv on the shared env, and uv deletes + rewrites the interpreter even with --allow-existing (verified: inode changes per run); Windows cannot replace the python.exe the first connector's running MCP server holds, so the second enable always died with "uv venv failed". Venv is now created only when the interpreter is missing (science-MCP + jupyter setup); uv pip install into the existing env verified end-to-end with the bundled uv.
2026-07-10 10:45 · feat(projects): project concept shipped — a project is a named shared-workspace folder under the base dir, marked only by .openscience/project.json (no registry/DB); sessions group under it by their directory. Sidebar gains a Projects section (collapsible groups, inline create + double-click rename, per-project new session); loose dated-folder sessions unchanged; same-project concurrency allowed and git snapshots now name the session that made them. All tests pass (447 FE + 87 Rust); DMG rebuilt.
2026-07-10 07:35 · docs(citation): repo is now academically citable — CITATION.cff + a citation section in all 7 READMEs (team authorship "The Open Science Desktop Contributors"); Zenodo↔GitHub archiving enabled, first DOI mints on the next release (then: add the badge + doi field, and sync the cff version each release). Sibling repo ai4s-skills shipped v0.1.0 the same way and already has DOI 10.5281/zenodo.21297455.
2026-07-10 04:04 · release: v0.1.9 published (network-proxy setting, connection self-heal, #6 #7 #8 #9 fixes) — all 8 platform installers built by CI.
2026-07-10 03:35 · fix(runtime): #9 root cause found by live probe — the browser delivers the xAI OAuth code fine; the sidecar's token exchange to auth.x.ai hangs because GUI-launched children inherit no proxy config. Added a network-proxy setting (follow system via scutil / custom URL / direct) in Settings → Agent runtime, injected as HTTP(S)_PROXY env at sidecar spawn with NO_PROXY for loopback; system+none apply on select via the masked-restart flow.
2026-07-10 01:40 · fix(settings): OAuth browser-login hardening (#9) — retry the callback wait on webview network drops (WKWebView kills idle fetches ~60s, far short of xai's 5-min window; opencode's pending closure is re-invocable, verified in v1.17.13 source), never re-authorize while the same login is waiting (a second authorize makes the xai plugin close the loopback server the new attempt needs), and fix a latent oauth method-index mismatch after filtering. Provider-side token-exchange rejections (no SuperGrok, blocked auth.x.ai) remain possible and are swallowed upstream — awaiting reporter feedback.
2026-07-10 00:55 · fix(sdk): self-heal the event stream (#8) — the global-config PATCH kills /event ~2s AFTER acknowledging, past af58765's masked reconnect, and EventSource never retries a terminally closed stream; the client now reopens it itself with backoff (verified in debug.log: drop → ready in ~300ms, no manual Connect). "Old model" self-reports are a prompt-level illusion — the app never pins a model per session.
2026-07-09 23:06 · fix: force UTF-8 on the Python kernel stdio (#6, garbled Chinese output on Windows); surface the active session folder new notebooks are created in on the Notebooks page (#7).
2026-07-09 13:05 · fix(models): switching the default model now reconnects transparently (masked by switching, like setApprovalMode) instead of closing the event stream and stranding the app disconnected until a manual Connect.
2026-07-09 11:52 · fix(sessions): stopped the open-session effect firing twice (currentId dep) and made openSession bail before a duplicate reconnect, fixing connection-pool exhaustion that left later sessions stuck on the loading spinner; also unstuck the /new·/clear guard when clearing from a draft.
2026-07-09 11:50 · fix(git-snapshot): only auto-commit into app-created workspace repos (marker-gated) so a user's own repo is never touched, and serialized snapshot commits behind a process lock to avoid index.lock races.
2026-07-09 11:25 · fix(sessions): added timeouts for session list/history/recovery requests and made failed history loads render an error row instead of an endless skeleton.
2026-07-09 11:18 · fix(sessions): made session idle folding idempotent so duplicate OpenCode idle events render only one done line.
2026-07-09 11:15 · fix(sessions): reconnected the current-folder runtime before the first post-clear turn and added timeouts for hanging session creation.
2026-07-09 11:11 · fix(sessions): preserved the local /new and /clear divider state when clearing from an existing session route.
2026-07-09 11:03 · fix(runtime): added an OpenCode SSE handshake timeout so first-launch auto-connect can keep retrying instead of hanging forever.
2026-07-09 10:02 · docs(readme): added the ResearchClawBench #1 recognition line across all README languages.
2026-07-09 09:39 · feat(sessions): added local /new and /clear commands that clear chat context while keeping the same workspace folder, and added best-effort local git snapshots for new workspaces and workspace file changes.
2026-07-09 08:53 · fix(updates): moved the update card to the bottom of Settings, made failed checks display as failed instead of "up to date", and switched desktop checks to the GitHub Releases Atom feed to avoid anonymous API rate limits.
2026-07-09 08:14 · feat(updates): added 24-hour-throttled GitHub Release update checks with manual checking, dismissible Settings badge, improved language switching UI, tests, and verified web/Tauri builds.
2026-07-09 06:00 · release(v0.1.8): bumped version to 0.1.8 and tagged; CI builds macOS/Windows/Linux installers into a draft GitHub Release. Covers the full i18n rollout (7 UI languages) and Open Science Desktop rebrand since v0.1.7.
2026-07-09 05:40 · docs(brand): updated the Open Science Desktop tagline and GitHub description/topics to include Linux alongside macOS and Windows.
2026-07-09 04:02 · docs(brand): repositioned the project as Open Science Desktop in README/agent metadata, added a neutral OpenScience comparison page, and updated the GitHub repo description/topics.
2026-07-09 03:25 · chore(ignore): removed tracked docs/superpowers planning artifacts and ignored docs/superpowers/ to keep local superpowers files out of git.
2026-07-09 02:36 · docs(readme): folded language support into the capability tables, restored emoji in README tables of contents, and added the Open Science Discord invite.
2026-07-09 01:14 · docs(readme): refreshed README for v0.1.7 current capabilities and added README translations for all seven shipped UI languages.
2026-07-08 20:47 · fix(remote-compute): remote/modal run outputs are now immutable per run. Root cause: skills required recording but still fetched reruns into stable paths like results/humanoid-walk/humanoid_walk.mp4, so .openscience/remote-runs.jsonl kept distinct hashes while old bytes were overwritten. Updated remote-compute/modal-run skills to use fresh RESULT directories, and both record_run.py helpers now reject any output path already recorded by an earlier remote run. Added a dev regression test covering both helpers; red → green verified.
2026-07-08 20:28 · fix(preview): right-pane GIF/image previews no longer generate dead URLs for bare artifact names. Root cause: preview_url returned a URL without validating relative paths, so humanoid_walk.gif could become a 404 <img> URL (question mark) while the Files browser worked via an already-resolved path; relativize now validates literal paths and falls back to the existing bounded basename resolver before building the URL. Verified with a failing-then-passing regression test and full Rust lib tests (78 passed).
2026-07-08 09:45 · fix(clipboard): copy failed in the desktop app ("could not copy to the clipboard"). WKWebView's navigator.clipboard.writeText rejects even inside a user gesture. Added tauri-plugin-clipboard-manager (+ capability clipboard-manager:allow-write-text) and a shared copyText helper that uses the native clipboard in-app, navigator as browser fallback; routed the file context menu's Copy path/relative-path and Runs' Copy command through it. tsc + clippy + eslint clean.
2026-07-08 09:30 · feat(files): right-click context menu in the file explorer — Reveal in Finder/Explorer/File-manager (label adapts per OS; via opener's cross-platform reveal, macOS open -R / Windows explorer /select / Linux portal+DBus with folder fallback), Copy path (absolute), Copy relative path, Open in default app. New Rust reveal_path + absolute_path commands (sandboxed under the chosen root); shared FileContextMenu (Radix context-menu) wraps rows in both the global Files page and the session files pane; clipboard via navigator + toast feedback. tsc + clippy + eslint clean; 372 frontend tests green.
2026-07-08 08:55 · fix(remote-compute): make run recording mandatory in the skill. A casual "re-run run.sh" showed the run in NEITHER Runs view — because the agent (Kimi) did the mechanical relaunch+scp but skipped record_run.py entirely, and passive capture deliberately ignores remote (ssh) commands. Hardened both SKILLs: §4 is now "REQUIRED, every time" and the tracking step spells out that a finished run MUST be fetched AND recorded — including quick re-runs — or it's invisible. (Prompt-level; the structural fix — folding fetch+record so recording can't be skipped — is proposed pending user confirmation.) Scratchpad script back-records the orphaned run into its session.
2026-07-08 08:20 · fix(remote-compute): attach remote runs to their session. Symptom: the humanoid-walk SSH run showed in the GLOBAL Runs view (with mp4/env/code — provenance intact) but NOT in the session's Runs pane, which filters by sessionId — and the skill's record_run.py call carried none (OpenCode exposes no session-id env var to bash; the id lives only frontend-side). Fix: new mark_session command writes the active session's id to <workspace>/.openscience/session.txt; openSession calls it (it already has id+directory); the remote-compute/modal-run SKILLs now pass --session-id "$(cat .openscience/session.txt)" (empty-safe — record_run.py drops a blank id). Future remote runs attach; a scratchpad script back-attaches the existing run (stamp JSONL + drop the runs.db cache to force re-ingest). tsc + clippy + eslint clean; 372 frontend + rust lib green.
2026-07-08 07:45 · feat(preview): inline video (mp4/webm/mov/m4v) preview. A robotics-sim session (ses_0be546ef) produced humanoid_walk.mp4 — recorded correctly in the run's outputs (the SSH-provenance fix worked) but the app treated it as an opaque binary ("no preview"), so it felt lost. Added a video PreviewKind → native
2026-07-08 05:15 · fix(remote-compute): complete provenance for SSH runs. Root cause found by auditing a real session (ses_0bec2fed0ffe): record_run.py was called with only the wrapper (--code run.sh, not the real humanoid_sim.py) and one output (result.json, not trajectory.npz), no environment, and a GPU hardware string for a CPU-only job — because the SKILL template modelled exactly that. Fixed: record_run.py gains --env-file (parses a run-time manifest → structured env with a content-addressed pip-freeze lockfile under .openscience/env/, matching local runs) and warns on missing/empty code/outputs; remote-compute SKILL now has run.sh emit env.txt at run time (ambient SSH env is otherwise lost), fetch every output, and record every script + output + env + honest hardware; modal-run SKILL records every output (env stays anchored by the versioned modal.Image, no --env-file). runtime.rs injects OPENSCIENCE_APP_VERSION so the off-app helper can stamp env.app. 9 rust runs-tests green (added env round-trip); clippy clean on changed files.
2026-07-08 03:34 · Release v0.1.7 — remote compute over SSH shipped, plus cross-platform hardening (single-line probe, atomic persist), base-workspace global machine config auto-materialized into each session, ssh run-surface in Runs/Reproduce, and stale-skill prune (hpc-slurm → remote-compute). Tagged v0.1.7 → CI builds macOS/Windows/Linux installers + draft GitHub Release.
2026-07-08 01:35 · feat(remote-compute): generalized Cluster (HPC) → Remote compute — connect any SSH machine (CPU or GPU, Slurm optional), one-round-trip capability+usage probe, multi-machine compute.json (migrates legacy hpc.json), RemoteComputeCard with capability chips + expandable snapshot/queue, and a unified remote-compute agent skill that runs jobs directly (setsid+PID+exit_code) or via Slurm. Built via subagent-driven TDD (9 tasks); 72 rust + 372 frontend green.
2026-07-08 01:27 · Design: documented the App Operator protocol as a session-scoped control lease with a visible controller session, generic action registry, return-to-owner flow, busy/interruption rules, and safety boundaries in docs/APP_OPERATOR.md.
2026-07-07 23:15 · Fix (Cluster): connecting a reachable host with no Slurm ("home-3090", a plain workstation — verified over SSH it has no sbatch/squeue/scancel anywhere, no slurm pkg/service) saved the host then unconditionally read the queue → squeue not found → "Could not read the queue" error. ClusterCard now reads the queue only when Slurm is detected; a reachable-but-no-Slurm host shows an explanatory note instead of a failing queue table (doesn't lock out module-based clusters or the agent's submit-time module load). Also fixed ClusterCard's own btnAccent opacity flicker (same WKWebView compositing bug as SettingsPage). Updated the empty-queue test to assert the queue read is skipped. 373 frontend tests green.
2026-07-07 23:00 · Fix (Bug 2 root cause): Settings controls flickering in packaged WKWebView was NOT the store subscription (store is quiet at rest) — it was opacity. btnAccent/btnGhost used transition-opacity hover:opacity-90 disabled:opacity-50; CSS opacity promotes a GPU compositing layer, so hovering one button mis-repainted neighbouring disabled (opacity-50) buttons. Confirmed by user repro: hovering one Enable flickered another. Replaced opacity states with alpha bg/text COLOR (plain paint, no layer), and swapped the two -translate-y-1/2 overlay-icon centerings (transform → layer) for margin centering. Kept the per-field selector change from before. DMG rebuild for verification.
2026-07-07 21:05 · Fix: Jupyter/connector provisioning no longer breaks when you leave Settings — the setup state + progress listener lived on the SettingsPage component, so navigating to a chat unmounted them (download looked frozen; a re-click collided on the same env dir). Moved the whole flow into a new app-lifetime useSetupStore with a single app-level progress listener and a double-start guard (the uv download was never actually killed — only the UI lost it). Also fixed Settings controls flickering in the packaged WKWebView: SettingsPage subscribed to the WHOLE runtime store (useRuntimeStore() with no selector) and re-rendered on every unrelated mutation — switched to per-field selectors. 373 frontend tests green (+3 setup-store). DMG rebuild pending user verification of the WKWebView flicker.
2026-07-07 20:10 · Follow-up (v0.1.7): the "Open JupyterLab" button now also lives in the notebook editor header (conversation inspector + full page), not just the Notebooks list, and deep-links to the open file (/lab/tree/<workspace-relative path>?token=…) instead of the lab home — verified the URL returns 200 against a live lab rooted where the notebook lives. Base/cross-session notebooks (outside the lab root) still open the lab home. 370 frontend + 66 rust tests green.
2026-07-07 19:20 · Follow-up (v0.1.7): the app-managed Jupyter env is now the DEFAULT local kernel when present (order: manual override → jupyter-env → system auto-detect), so the Run button and the agent's MCP share one Python; added numpy/pandas/matplotlib to that env (~500 MB) so the unified kernel is usable out of the box; split python discovery + enriched PATH per-OS (macOS Homebrew/framework vs Linux /opt/conda·Linuxbrew vs Windows); added a one-click "Open JupyterLab" button (Notebooks page) that starts the headless server and opens it in the browser with token. 370 frontend + 66 rust tests green.
2026-07-07 18:55 · Fix (v0.1.7): notebook "Run" said "no Python found" even after the ~300 MB Jupyter setup, because the local kernel and the Jupyter MCP env were two disconnected Python worlds and Windows discovery was weak. kernel::python_bin now resolves in order (manual override in runtime/python-path.txt → system discovery → the uv jupyter-env fallback), broadened Windows candidates (ProgramData/C:\ Anaconda + versioned python.org dirs) with a Windows enriched_path (conda Library\bin on PATH so numpy loads); new python_interpreter/set_python_path commands drive a Settings "Local Python kernel" card + a notebook-header indicator. setup_jupyter/setup_science_mcp now stream live setup-progress events via uv::run_uv and abort with a readable error after 10 min of silence instead of hanging forever. 370 frontend + 66 rust tests green; DMG rebuilt.
2026-07-07 18:40 · Fix: clicking a file chip in the chat showed a broken image in Artifacts while the same file opened fine from the Files tree — locate_under returned an absolute agent-prose path (e.g. /Users/…/2026-07-07-0902/test_image.png) with only the leading slash stripped instead of root-relative, so the preview server resolved <workspace>/Users/… and 404'd; it now strip_prefixes against the canonicalized root. 370 frontend + 63 rust tests green.
2026-07-07 09:25 · v0.1.6 — Runs redesign + SQLite global index + global/session dual-view. Reworked the Runs experience on aesthetics, usability, AND scale. AESTHETICS: replaced the boxed/striped cards + green left-bar with a calm borderless "log ledger" — status is a small dot, sticky day-group labels (Today/Yesterday/…), aligned tabular meta, relative timestamps. USABILITY: search (command + output path), clickable facet chips (OK/Failed + remote surfaces, with counts) that combine (ANDed in SQL), a time filter (Anytime/24h/7d/30d), clickable outputs (open in OS), copy-command; removed the duplicate count display. SCALE (best practice): the read path is now an event-log + materialized-read-model — runs.jsonl stays the durable append log, and a GLOBAL SQLite index (<base>/.openscience/runs.db, new rusqlite bundled dep) is derived lazily by byte-watermark across ALL session folders; queries are indexed, keyset-paginated (infinite scroll, 50/page), faceted, and searched server-side — fast + low-memory at 100k+. DUAL-VIEW (matches Files/Notebooks): global Runs (sidebar, all sessions) + per-session Runs (session-header toggle, shown only when the session has runs, filtered by session_id) share one RunsView; the pane is mutually exclusive with the artifact/Files panes (new PaneState.showRuns). 370 frontend + 63 rust tests green, tsc + clippy + eslint clean.
2026-07-07 06:10 · v0.1.5 — crash fix + HPC/Modal remote back-fill. CRASH (shipped in v0.1.4, hit on first real test): the Runs view threw "undefined is not an object (d.code.length)" for any run with no captured code or no outputs — the Rust RunRecord serialized code/outputs with skip_serializing_if = Vec::is_empty, omitting empty arrays, so the frontend's r.code.length/r.outputs.length hit undefined (most runs produce no files). Fixed: code/outputs now always serialized (even empty); TS type made optional and every access guarded (tsc-enforced); survives the empty-array records v0.1.4 already wrote. REMOTE BACK-FILL: remote runs (HPC/Modal) now recorded by the driving skills, not passive capture (which no longer stamps the laptop's env/hardware on off-box runs) — a bundled record_run.py helper appends accurate records (host, jobId, remoteHardware, status, wall, fetched outputs) to .openscience/remote-runs.jsonl, which read_runs merges; both SKILL.md files gained a "Record the run" step (gather sacct NodeList/state for HPC, gpu= for Modal). Reproducibility anchor stays the spec (sbatch modules / Modal Image pins, already provenance-versioned), not enumeration. NOTE: remote path verified at unit+contract level only (no cluster/Modal to E2E test). 367 frontend + 58 rust tests green, tsc + clippy + eslint clean.
2026-07-07 05:30 · Reproducibility — high-effort review pass + hardening (v0.1.4). A workflow-backed adversarial review (16 agents) of the runs/provenance feature surfaced 8 real defects, 6 fixed: (1) parse_code_files misclassified a data file named as an argument (--out results.csv) as "code" and dropped it from outputs — now only the first source-extension file is the entry script; (2) env-var-prefixed commands (CUDA_VISIBLE_DEVICES=0 python train.py) were never recorded — command classification now strips leading VAR=val/cd prefixes and matches per &&/;/| segment; (3) HPC markers matched anywhere, so git commit -m "…sbatch…" was a false run — markers now anchor at a segment head (ssh-unwrapped), never inside quoted args; (4) failed runs stamped their partial outputs as "produced by run" — provenance linking now happens only for ok runs; (5) the mtime attribution window truncated the start to whole seconds + 2s grace — now millisecond precision; (6) record_run appended to provenance.jsonl under the wrong mutex (race w/ record_provenance) and re-read the whole store per output — replaced with a single batched link (link_run_outputs) under ProvenanceState. Deliberately NOT changed: the runtime.ts interrupt-guard .delete→.has (intentional/documented, pre-existing). A second manual pass over the fix code itself found 2 more latent bugs: (7) ./run.sh / python ./train.py captured no code entry because root.join("./x") leaves a CurDir component that rel_key rejects — now strips the leading ./; (8) record_run held ProvenanceState across capture_env's one-time pip-freeze/nvidia-smi shell-out (blocking concurrent writes) — env is now captured before the locks. 365 frontend + 57 rust tests green, tsc + clippy + eslint clean.
2026-07-07 04:05 · Reproducibility P2 — the three deferred pieces. ① edit diff lineage: provenance now keeps an edit's unified diff (from the event) when the full content wasn't captured, so the History shows what changed instead of "content not captured"; extracted a shared DiffView (ProvenancePanel + ToolGroup both use it). ② standalone Runs view: new /runs route + sidebar entry (RunsPage) listing every recorded run as an expandable recipe card — command, status, duration, env + hardware, code/outputs, Reproduce + Open conversation. ③ surface capture: surfaceForCommand tags each run local/hpc/modal/jupyter; HPC (sbatch/srun, even over ssh) / Modal (modal run) / notebook-batch (papermill, nbconvert --execute) submissions are now recognized as runs even when the marker isn't the command head, recorded honestly with a surface badge and a "outputs live off-box" note (remote outputs not captured); interactive local-kernel and single Jupyter MCP cells are deliberately NOT recorded (exploration, not reproducible batch runs). 361 frontend + 55 rust tests green, tsc + eslint clean.
2026-07-07 03:45 · Reproducibility, reframed from files to runs (P1). Root-caused the user's confusion: provenance only fired on write tools, so authored source files (train.py/model.py) got versions + a meaningless "Reproduce" (re-author the code?) while the results that actually need reproducing — produced by running code — got no record at all; the "content not captured (produced by running code)" message was doubly misleading (a content-less version was an edit, whose text lives in newString ∉ CONTENT_KEYS). Reframed: a run (an execution) is the reproducibility unit. New .openscience/runs.jsonl (Rust runs.rs) captures every experiment execution passively from the bash tool.updated events the app already receives — command, code version (entry scripts hashed), env + NEW hardware capture (nvidia-smi/sysctl/proc, cached per app-run), captured log, and outputs (files whose mtime falls in the run window, bounded scan). No new agent tool, no behavior change — rejected the initial "run experiments via a special tool" design as fragile. Produced files link back via a new runId on their provenance version; Reproduce on a run drafts a real recipe (re-run the command in the recorded env, compare outputs — not re-author source). ProvenancePanel now shows "Produced by run …" + recipe Reproduce, and an honest fallback message. 353 frontend + 55 rust tests green, tsc + eslint clean. DMG rebuild pending.
2026-07-07 02:27 · Fixed blank PPTX previews. A generated deck (valid single-master, opens fine in WPS/PowerPoint) declared phantom [Content_Types].xml Override entries for slideMaster2..10 — parts that were never written. pptx-preview tried to load the missing parts and silently rendered ZERO slides (no exception, so OfficePreview's try/catch never fired — the user saw a blank pane). Root-caused by reproducing the real file through the preview path (a blank vs full render hinged entirely on those overrides; also learned jsdom's cross-realm instanceof ArrayBuffer fails, so JSZip/pptx-preview need a Uint8Array in tests). Fix: normalizePptxForPreview now also strips Content_Types overrides whose part is absent (new pure dropMissingContentTypeOverrides), in-memory only, before feeding pptx-preview — same "never break the preview" philosophy as the existing defRPr merge. 344 tests green (+3), tsc clean; DMG rebuilt.
2026-07-07 02:07 · Fixed the macOS fullscreen titlebar gap. In native fullscreen the traffic lights slide away, but overlayTitlebar = isTauri && isMac (computed independently in AppShell, LiveSessionPage, Sidebar, RightPane) stayed true, so the ~78px traffic-light inset became an empty gap — the collapse/expand buttons floated oddly indented. Added isFullscreen to the UI store, synced by one watchFullscreen listener in AppShell (Tauri getCurrentWindow().isFullscreen() + onResized), a pure trafficLightsPresent(tauri, mac, fullscreen) helper, and a shared useOverlayTitlebar() hook now used by all four components. Verified core:window:default/core:event:default already grant allow-is-fullscreen + allow-listen, so no ACL denial (unlike the earlier drag-region miss). 341 tests green (+3), tsc clean; DMG rebuilt.
2026-07-07 01:39 · Session header cleaned up + stop-button noise fixed. Header: title moved to the left as the identity anchor; the Files toggle moved to the right and restyled as a quiet ghost control (no border/fill until hover/active), and notebook chips unified to the same ghost style so the right cluster reads as one language. Bug: pressing stop mid-turn printed "Aborted / done / done / Interrupted" — two root causes: the interruptedSessions guard was armed AFTER the abort POST's await (so the server's trailing SSE burst raced in first), and it was consumed by the FIRST session.idle (an abort emits two). Fixed by arming the guard before the await and holding it across every trailing idle (.has, cleared on the next turn); added a regression test that fires the burst during the await. 338 tests green, tsc clean; DMG not yet rebuilt.
2026-07-07 00:23 · New sessions now seed an agent harness instead of an empty folder: the evolve-agent scaffold (AGENTS.md rules, KNOWLEDGE.md, knowledge/, notes/) is maintained in-repo at runtime/harness/, bundled as a Tauri resource, and copied into each new dated folder by harness::seed_harness (non-clobbering, only on new_dated_workspace — never on switching to an existing session); per-session state files reset to blank templates, CLAUDE.md symlink dropped for cross-platform safety; DMG rebuilt and verified to contain Contents/Resources/harness.
2026-07-07 00:10 · Failed tool steps no longer break out as prominent cards (they read like generated artifacts and drown the thread): failures/warnings stay quiet rows inside the group — red ✗, error output expandable — with a red "· N failed" count on the collapsed summary; only waiting-approval still renders standalone; 337 tests green, DMG rebuilt.
2026-07-06 23:55 · Session-open jank fixed: collapsed tool details now lazy-mount (closed content stays out of the DOM; opening mounts collapsed and expands next frame, closing unmounts after the 300ms transition) — a history like the training session (16×50KB webfetch outputs, 20 file contents, 12 line-per-div diffs) no longer mounts ~1MB of hidden text on click; 335 tests green, DMG rebuilt.
2026-07-06 23:32 · Codex-style tool-call UI: consecutive quiet steps fold into a one-line summary group with per-step expandable detail (shell panel, colored diff, inline file content); bash titles are de-noised commands (cd-prefix stripped, verb + subject, full command in the detail); running commands show a live stdout tail + elapsed timer — the server already streamed state.metadata.output, the SDK just never read it; folds coalesce at 250ms and \r progress bars collapse in place — 335 tests green, DMG rebuilt.
2026-07-07 05:35 · v0.1.2 published — collapsible sidebar + single-row titlebar + resizable/maximizable right pane + 48px headers, plus the provider-credential and macOS-13 fixes; CI matrix green, 6 installers attached (https://github.com/ai4s-research/open-science/releases/tag/v0.1.2).
2026-07-06 21:45 · Header polish: all header rows (session titlebar, sidebar strip, every pane header) unified at 48px (1.5× the old 32px); macOS traffic lights re-centered for the taller row via trafficLightPosition {x:13, y:22} (measured on screen: lights and icons both center at 23.75px); header icons normalized to 14px / 1.5 stroke / solid text color so they match the 12px lights' height and no longer look washed out — 319 tests green, DMG rebuilt.
2026-07-06 21:20 · Maximized pane header is now a single row with the traffic lights: PaneTitlebarInset (a 62px drag spacer rendered at the start of every pane header, macOS+maximized only) replaced the extra titlebar strip; cross-platform audit of the recent titlebar/pane work found no Windows/Linux issues — every drag region and pl-[78px] inset is gated on isTauri+Mac UA, titleBarStyle/hiddenTitle are macOS-only config keys, and allow-start-dragging is valid on all desktop platforms — 319 tests green, DMG rebuilt.
2026-07-06 21:05 · Right pane (artifacts/Files) is now resizable like the sidebar: left-edge divider drags within 360–960px (persisted, capped at 70% of the window), dragging far right snaps it closed; new maximize/restore button in every pane header covers the whole window with just the artifact; session header separators softened — draft pages have none, open sessions (and examples) use a new faint border token — 319 tests green, DMG rebuilt.
2026-07-06 20:37 · Window-drag root-caused: no capabilities file existed, and core:window:default does not include start-dragging, so every data-tauri-drag-region invoke was silently ACL-denied since day one — fixed with capabilities/default.json (core:default + allow-start-dragging, double-click-zoom now works too); drafts also lost the header title/folder chip (workspace picker now sits in the composer row right of attach) — 319 tests green, DMG rebuilt.
2026-07-06 20:20 · Session header is now a Codex-style single-row titlebar: collapsed = traffic lights → expand button → folder toggle → title in one 32px draggable row (drafts hide the Files toggle until the session folder exists), expanded = collapse button lives in the sidebar's top strip right of the lights so the toggle never moves; non-live routes keep a fallback strip — 319 tests green, DMG rebuilt.
2026-07-06 20:12 · Collapsed-sidebar titlebar overlap fixed: with the sidebar closed the page header slid under the macOS traffic lights (and the floating expand button sat on top of it) — the main pane now reserves the same h-8 drag strip the sidebar uses, with the expand button in-flow right of the lights, restoring window dragging when collapsed — 319 tests green, DMG rebuilt.
2026-07-06 20:05 · First-boot page flicker root-caused and fixed: every reconnect attempt tore down the old client, which emitted "offline" into the store, so while macOS TCC held the sidecar the page flipped between the offline card and the connecting screen ~1×/s — reconnects now unhook the status listener before close (explicit Disconnect unchanged), regression test added, 319 tests green, DMG rebuilt.
2026-07-06 19:55 · Sidebar is now collapsible (Codex-style): toggle button in the header + ⌘/Ctrl+B, floating expand button next to the traffic lights when closed, divider drags between 184–340px and snaps shut below 140px, width/state persisted — 318 tests green, DMG rebuilt.
2026-07-06 18:30 · Code review of the connector fixes surfaced 4 confirmed bugs, all fixed: instance dispose now also hits the ?directory= workspace instance (chats see new credentials), the OAuth wait captures its generation at flow start and is abortable (cancel/save/retry no longer leak or resurrect waits), a post-cancel completed login still refreshes the provider list, and every SDK error now carries the server diagnostic — 318 tests green, DMG rebuilt.
2026-07-06 17:45 · Model-connector bugs fixed against a live opencode 1.17.13 sidecar (verified E2E): the SDK disposes the server's cached instance after every credential change so new providers appear immediately, "auto" OAuth logins wait for the browser in the background (cancellable, no page-wide busy lock), and SDK errors now carry the server's diagnostic message.
2026-07-06 16:40 · macOS 12 incompatibility root-caused: the Bun-built opencode sidecar declares LC_BUILD_VERSION minos 13.0 (verified via otool on the v0.1.1 dmg), so on Monterey the app shell (minos 11.0) opens but the sidecar is killed on exec → endless "connecting" → "could not open OpenCode event stream". Fix: bundle.macOS.minimumSystemVersion=13.0 (next release refuses cleanly on old macOS) + macOS 13+ requirement documented in both READMEs and the v0.1.0/v0.1.1 release notes.
2026-07-06 16:05 · v0.1.1 PUBLISHED — Windows performance release live at https://github.com/ai4s-research/open-science/releases/tag/v0.1.1 (6 installers, all 4 CI targets green, ~13 min tag-to-release); updater tar.gz assets removed as in v0.1.0. Ships the UI-freeze / console-window / fake-python fixes below. Field verification on a real Windows machine still pending — the fix addressed root causes confirmed in code, not yet re-tested on the reporting machine.
2026-07-06 14:58 · Windows perf/UX bugs root-caused and fixed: (1) heavy Tauri commands were synchronous and ran on the WebView2 UI thread — kernel_execute blocked the whole app for the duration of every notebook cell (now async + spawn_blocking), and detect_tools/record_provenance/start_runtime/start_jupyter/file-preview commands froze startup and browsing (now #[tauri::command(async)], 17 commands total); (2) every direct std::process::Command spawn popped a black console window on Windows — new quiet_command helper sets CREATE_NO_WINDOW at all 8 spawn sites; (3) interpreter_ok/tool probe accepted the Windows Store fake python.exe alias (runs but exits non-zero), causing endless kernel respawn + window spam — both now require --version to succeed, with regression tests. 51 Rust + 314 frontend tests green, zero warnings; Windows-specific code cross-checked against x86_64-pc-windows-msvc. Needs a CI Windows build to confirm on a real Windows machine.
2026-07-06 14:57 · Product promo video shipped: videos/open-science-claude-science-alt/ contains a 28s portrait HyperFrames promo positioning Open Science as "Claude Science, but open", reusing real project showcase assets; npm run check is green (lint 0/0, validate no console errors and WCAG text pass, inspect 0 layout issues), and the rendered MP4 is renders/open-science-claude-science-alt.mp4 (28.000s, 18 MB).
2026-07-06 14:20 · v0.1.0 PUBLISHED — first public release live at https://github.com/ai4s-research/open-science/releases/tag/v0.1.0 with 6 installers (mac arm64/x64 dmg, Windows exe/msi, Linux deb/rpm), release notes incl. unsigned-build bypass instructions; updater tar.gz assets removed (no updater configured).
2026-07-06 13:45 · Release flow VERIFIED end-to-end: tag v0.1.0 → CI builds all 4 targets green (mac arm64 4m, mac x64 3m, Windows 5m, Linux 13m) → draft GitHub Release auto-created with 8 assets (dmg×2, exe, msi, deb, rpm, app.tar.gz×2); publishing = press Publish on the draft. Five CI iterations fixed real bugs: pnpm version pin conflict with packageManager; GITHUB_TOKEN needed permissions: contents.write for release creation; redundant .app upload doubled mac artifacts; Linux AppImage permanently dropped (linuxdeploy runs ldd on the Bun-built opencode sidecar, ldd exits 1, hard abort — tauri#8929) so Linux ships deb+rpm only. Remaining known gaps: no code signing (Gatekeeper/SmartScreen warnings on browser downloads) and Windows/Linux UI never eyeballed (titleBarStyle Overlay is mac-only). CI-built arm64 dmg handed over for install testing.
2026-07-06 10:30 · Release pipeline now covers Linux: fetch-opencode.sh/fetch-uv.sh accept *-unknown-linux-gnu triples (opencode ships linux tar.gz, uv ships gnu tarballs — both asset names verified against the pinned releases), CI matrix gains ubuntu-22.04 (webkit2gtk-4.1 deps) producing .deb/.rpm/.AppImage, and a v* tag push now auto-creates a draft GitHub Release with all installers attached. Blocker: zero CI runs so far — macOS arm64 is the only locally-verified platform; Windows/Linux/x64-mac remain unverified until the first tag push.
2026-07-06 03:45 · P0-5 chemistry gate upgraded to a real RDKit round-trip (the named "library round-trip vs static patterns" gap). New _rdkit_verdict(smi) → "valid"/"invalid"/None: when RDKit is installed, Chem.MolFromSmiles (which sanitizes) is the AUTHORITATIVE judge — it catches far more than the static five-bond carbon (bad ring closures, impossible aromaticity, over-valent N/O/S) AND clears molecules the heuristic would wrongly flag; when RDKit is absent (verdict None) it degrades to the existing stdlib bond-counter, so the gate still works offline with zero deps. _molecule_finding routes every SMILES literal through this precedence, replacing the direct static calls in check_chemistry (python + R paths). TDD: the RDKit integration logic is driven deterministically by patching _rdkit_verdict (invalid→flag / valid→suppress-static-FP / None→static-decides), so the unit suite needs no RDKit — 37 domain-check tests green. VERIFIED end-to-end with REAL RDKit (2026.3.3 in a throwaway uv env): it flags 5-bond nitrogen and an unclosed ring c1ccc the static path misses, and clears caffeine + aspirin. DMG rebuilding to bundle the updated skill. Gap left in P0-5: POSCAR→pymatgen validity round-trip.
2026-07-06 03:20 · P0-6 CLOSED (✅ Done) — the large-file probe is now a one-click UI action, not just an agent skill. The "too large to preview" card gained Inspect without loading: a new Rust probe_large_file command (sandbox-resolves the path via scope_root/resolve_under, finds Python via kernel::python_bin, locates the bundled skills-core/large-file/large_file_probe.py resource with a dev fallback, runs it under the enriched PATH, returns the compact JSON pointer) → the pane renders a readable fact sheet (LargeFilePointerPanel: format, size+gzipped, rows/reads/sequences/variants, read-length min/max/mean, schema chips, HDF5 datasets, sample ids, "not loaded" note) or the probe's error. So a USER — not only the agent — can introspect a file bigger than the context window in one click. TDD: Rust pure first_existing helper (+1 test, 49 total); frontend probeLargeFile bridge + PreviewError inspect flow (+2 component tests: renders the pointer, surfaces a probe error) + updated the existing too-large test — 314 JS tests, tsc, eslint green. VERIFIED end-to-end: the probe bundled in the fresh DMG, run against a real 25 MB CSV (over the preview cap), returns schema + 900k row count + head/tail sample — the exact chain the command executes. This meets P0-6's acceptance (analysis over an over-window file completes by introspection/sampling, no whole-file load); status → ✅ Done in the summary table. DMG rebuilt (02:48).
2026-07-06 02:55 · P0-5 social-science domain gate shipped (5th discipline; the last unbuilt domain-check field). Two deterministic rules in the bundled domain-check skill, distinct from the P1-6 stats-integrity skill (which checks reporting/reproducibility) — this catches CODE that computes the wrong statistic: social · multiple-comparisons (a significance test — ttest/pearsonr/f_oneway/chi2/… — run inside a loop or ≥3 times with NO multipletests/FDR/Bonferroni correction anywhere; the named silent-p-hacking failure that inflates the family-wise false-positive rate) and social · categorical (a numeric reduction .mean()/.median()/.std() taken directly on a nominal code — gender/race/region/condition/… — treating an unordered label as interval). Precision-first, matching the other four gates: a SINGLE test isn't flagged (needs no correction), a correction anywhere silences it, and a groupby('gender') KEY is correct usage and not flagged (verified adversarially — groupby+col.mean, bare groupby.mean, single corr, 2-tests-no-loop all stay silent). Reuses the same review contract + per-discipline tag → zero UI change. TDD: +7 validator tests (loop/≥3/correction-ok/single-ok; categorical mean, groupby-ok, continuous-ok); 34 domain-check tests total, all green; end-to-end run() emits both social tags on a realistic file. DMG rebuilding. P0-5 now covers all five targeted disciplines; only library round-tripping (SMILES→RDKit, POSCAR→pymatgen) remains.
2026-07-06 02:30 · P0-6 genomics + remaining physical-science large-file formats shipped (the named gap; a 90 GB FASTQ is exactly the requirement's motivating case). Extended the bundled large-file probe: FASTQ/FASTA/VCF are STDLIB and gzip-aware (a new is_gzip/stream_lines/count_lines_any layer + double-extension detection that sees through .gz, so reads.fastq.gz → FASTQ not a gzip blob) — FASTQ returns read count + read-length min/max/mean over a bounded 100k-read scan + sample ids (never full sequences), VCF returns variant count + sample names from #CHROM + contigs, FASTA returns sequence count + residues. Binary BAM/CRAM (pysam header only), GRIB (cfgrib/pygrib), ROOT (uproot, metadata) introspect via their library or degrade to an install-hint pointer, never a raw dump. VERIFIED on this host: a 500k-read .fastq.gz → 462 B pointer streamed in CONSTANT memory (whole file never loaded), a 3,000-variant VCF → samples + contigs parsed. TDD: +7 probe tests (FASTQ reads/lengths, FASTQ gzip, FASTA, VCF samples, BAM/GRIB/ROOT install-hints); 18 probe tests total, all green. Only P0-6 remainder: wiring the probe as an automatic UI pre-read step. DMG rebuilding.
2026-07-06 02:05 · P1-2 physics + earth/climate connectors shipped — the two previously-EMPTY disciplines now have a domain database, so all five targeted disciplines meet the acceptance ("≥1 non-bio DB per discipline"). Added three vetted open-source MCP servers to the curated catalog (same one-click-into-isolated-uv mechanism): spaceweather-mcp (physics — NOAA SWPC/NASA DONKI/USGS: solar wind, flares, Kp/Dst, radiation storms, aurora; 15 tools; no key), mcp-weather-server (earth — Open-Meteo weather/climate/air quality; 8 tools; no key), usgs-mcp (earth — USGS streamflow/flood/sites; 10 tools; no key). METHOD (the lesson from the earlier false-friend episode): each candidate was proven with a REAL MCP initialize + tools/list stdio handshake inside the bundled-uv env BEFORE adding — wrote a mcp_handshake.py verifier and confirmed all three init and enumerate their tools via the EXACT launch shapes connectorConfig emits (console-script and -m module), in a fresh shared env. This also DISPROVED the earlier "usgs/open-meteo don't start" doubt (that was an inadequate check — a SOCKS-proxy env var on the test box, not the servers) and re-confirmed astro-mcp is Airflow, not astronomy. TDD: +4 connector tests (physics + earth disciplines present, three launch-command shapes); 312 JS tests, tsc, eslint green. Docs: CONNECT_YOUR_TOOLS.md lists all seven connectors with disciplines. DMG rebuilding. Gap: astronomy catalogs (ADS/SIMBAD/Gaia) have no pip-installable stdio MCP — GitHub-only, would need vendoring.
2026-07-06 01:40 · P0-5 chemistry domain gate shipped (4th discipline; the requirement's named next frontier — the C&EN caffeine "five-bond carbon" class of "runs but wrong"). New chem · valence rule in the bundled domain-check skill: a stdlib SMILES bond-counter walks a string literal (assigned to a smiles/smi/mol variable, or passed to MolFromSmiles/MolFromSmarts) — tracking chain/branch/ring bonds and bond orders (=/#/aromatic) — and flags any organic-subset atom whose EXPLICIT bonds exceed its valence: carbon >4 (the five-bond carbon) or a halogen >1. Precision-first: it BAILS (no finding) on bracket atoms [..] (which carry their own valence/charge/H), unbalanced branches, open rings, or any unmodeled token, so it never guesses — and only flags elements whose valence is unambiguous. Reuses the same review contract + per-discipline tag, so zero UI change. TDD: +8 tests (5-bond carbon via literal and via MolFromSmiles arg, over-bonded F, valid caffeine + acetic acid stay silent, non-chem string ignored, bracket-atom bail, R regex fallback); 27 domain-check tests total, all green; verified end-to-end that run() emits the review block naming "C has 5 bonds (max 4)". DMG rebuilt to bundle the updated skill. Gaps: full RDKit round-trip sanitization, a social-science correctness gate.
2026-07-06 01:25 · P0-7 moderate: tool detection now searches the enriched PATH. Settings' tool list probed with the app's own environment — a Finder/Dock-launched app gets a minimal PATH, so the user's anaconda/homebrew Python, R, uv, and Jupyter showed as "missing" while the agent and kernel (which already use enriched_path()) could run them fine. probe now searches the SAME enriched PATH (pure probe_with_path + fake-tool-on-temp-PATH test). 48 Rust (+1) + 308 JS tests, tsc, eslint green. DMG rebuilt.
2026-07-06 01:15 · P0-7 moderate: sidecar restart consolidated under a lifecycle lock. The kill→spawn sequence was copy-pasted in four config-changing commands (import login / remove config entry / approval mode / configure provider) with no serialization — two concurrent restarts could double-spawn and orphan a child. Now one restart_sidecar() holds the child mutex across the whole kill→spawn (the mutex IS the lifecycle lock), and all four commands route through it (−30 lines). 47 Rust + 308 JS tests, tsc, eslint green. DMG rebuilt.
2026-07-06 01:05 · P0-7 plaintext-keys interim minimums met — the last critical safety item is now addressed (keychain-at-rest stays deferred to signed releases per P2-3's deliberate trade-off). The gap: provider/connector keys + the Jupyter token live in opencode.jsonc at mode 644 (world-readable), and the sidecar rewrites that file at runtime with its own umask, so write-site chmod alone wouldn't hold. FIX: tighten_private (unix: dirs 700, files 600; Windows %APPDATA% is per-user ACL'd) applied to the whole app-private runtime root + config file on EVERY sidecar start (repairs existing installs) and after each Rust-side config write — the 700 directory is the load-bearing guarantee (unreachable to other users whatever the sidecar sets on files inside). VERIFIED empirically: an authenticated PATCH /global/config against the real bundled binary preserves a 600 config (in-place rewrite), so both layers hold; +1 Rust test (47). With this, every P0-7 CRITICAL checkbox is done or at its documented interim bar: approval modes ✓, sidecar auth + CORS ✓, workspace-bash binding ✓, keys ✓(interim), Windows injection ✓, kernel deadlock ✓. Remaining: moderate robustness (sidecar restart dedup/liveness, async-worker blocking, detect_tools PATH) + structure cleanup. DMG rebuilt.
2026-07-06 00:55 · P0-7 Windows command injection fixed (the last critical Rust defect — both are now closed). ROOT CAUSE: open_url/os_open used cmd /C start "" <arg> on Windows; cmd re-parses &/^/| after Rust's quoting, so an agent-emitted link like https://x.com/?a=1&calc would EXECUTE calc — and every legit URL containing & broke. FIX: both functions now call the opener crate — verified in its vendored source to use ShellExecuteW on Windows (no shell re-parsing), open on macOS, xdg-open on Linux; it also wait()s the helper (the old spawn-and-forget leaked one zombie per open, even on macOS). The http(s)-only scheme gate stays and gained a regression test (javascript:/file:/bare-command rejected). Verified: opener cross-compiles clean for x86_64-pc-windows-msvc (full Windows app build stays host-bound in CI — llvm-rc + real sidecar binaries, P1-4). 46 Rust tests (+1), 308 JS, tsc, eslint green. DMG rebuilt. P0-7 critical remainder: plaintext keys at rest (deliberate P2-3 trade-off, revisit for signed releases).
2026-07-06 00:45 · P0-7 kernel deadlock fixed + notebook Stop button (the 2nd critical Rust defect; acceptance: a while True: pass cell can be reset without restarting the app). ROOT CAUSE: kernel_execute held the GLOBAL kernel-map mutex across an unbounded blocking read_line, so a hung cell wedged every kernel command including kernel_reset — only an app restart recovered. FIX (per-kernel locks): the map holds Arc<Kernel> and is locked only for lookup/insert/remove; each kernel has an io lock (one cell at a time) and an INDEPENDENT child lock (kill/reap) — reset kills without waiting on io, the blocked read sees EOF and errors out, and the error path removes only its own Arc (ptr_eq) so a respawned kernel is never clobbered. Killed kernels are now wait()ed (zombie fix, same class). kernel_reset gained notebook-precise targeting (kills exactly that notebook's kernel). UI: a running cell now shows an ALWAYS-VISIBLE Stop (was: no way to interrupt at all + Run row hidden unless hovered); Stop resets this notebook's kernel and the cell reports "Interrupted — the kernel was restarted; variables were reset" instead of a raw error. VERIFIED with a REAL hung Python kernel in a Rust test: reset returns promptly (<5s guard, actual ms), the hung exec errors, the map empties for a clean respawn. 308 JS tests (+2: Stop resets + labels interruption, crash still reports error), 45 Rust tests (+1), tsc, eslint green. DMG rebuilt. Still open in P0-7: plaintext keys at rest, Windows cmd injection.
2026-07-06 00:31 · P0-7 sidecar auth + preview-server token shipped (the audit's remaining CRITICAL network exposure; REQUIREMENTS said "fix permission block + CORS before next release" — both now closed). MECHANISM (verified in the OpenCode 1.17.13 source): --cors "*" was an exact-match literal (never a wildcard) — the real hole was the built-in CORS allowlist trusting ALL http://localhost:*/127.0.0.1:* origins, so any local webpage could drive agent turns and read API keys via the unauthenticated /global/config. FIX: (1) sidecar — OpenCode's built-in Basic auth enabled via a per-run CSPRNG password (OPENCODE_SERVER_PASSWORD, in-memory OnceLock, never on disk; new runtime_password command hands it to the webview over app-only IPC); the SDK sends Authorization on every fetch and KEEPS the reliable-in-WKWebView EventSource SSE path via the server's ?auth_token= query (same Basic payload); --cors "*" removed. (2) preview server — a per-run CSPRNG token as the URL's FIRST PATH SEGMENT (/<token>/w/…), so relative subresources in previewed HTML inherit it and the sandboxed iframe (opaque origin, no cookies) still works; tokenless/wrong-token → 403 before any filesystem work; dropped Access-Control-Allow-Origin: * (previews are iframe/img, never cross-origin fetch). (3) same-class fix: Windows Jupyter token was guessable pid+nanos — now the shared random_hex CSPRNG (getrandom). VERIFIED against the real bundled binary: no/wrong password → 401 on /global/config, /session, /event; correct → 200; ?auth_token= streams SSE. TDD throughout: 306 JS tests (+3: Basic header on API calls, auth_token on the ES URL, store passes the password), 44 Rust tests (+2: token gating + no-CORS-header, random_hex shape), tsc, eslint green. DMG rebuilt. Still open in P0-7: plaintext keys at rest, Windows cmd injection, kernel deadlock.
2026-07-06 00:20 · Approval switch polished after user testing: (1) UI restyled to the Codex layout — the composer gains a bottom action row with a "✋ Approve for me ⌄" pill bottom-left and a titled upward menu (visually verified via browser screenshots); (2) FIX: menu didn't dismiss on outside click — ROOT CAUSE: WKWebView never focuses a clicked button, so the onBlur close never fired; replaced with a document-level mousedown listener (+1 test); (3) FIX: switching modes flashed the whole page — the sidecar restart flipped status to "connecting" and the page rendered as disconnected; reused the switching flag (same mechanism as workspace moves) so a deliberate restart renders as connected (+1 test). 303 JS tests, tsc, eslint green. DMG rebuilt (20:03).
2026-07-05 23:59 · P0-7 approval modes shipped (the audit's #1 safety-defaults gap). Codex-style two-mode switch in the composer: Approve for me (DEFAULT — deletion/install/remote/privilege commands + webfetch prompt first) and Full access (OpenCode builtin defaults, explicit opt-out that survives restarts). MECHANISM (verified from OpenCode 1.17.13 source + bundled binary): permission rules evaluate last-match-wins with user config appended after the builtin "*":"allow", so the approve preset is pure ask rules — per dangerous token both "T *" (prefix; also matches bare T) and "* T *" (catches compound commands like cd x && rm -rf y without false-positives on words merely containing the token). Full access = "permission": {} (zero rules; the key's presence means "user chose", so startup seeding never overrides it). Rust: pure set_permission_mode/permission_mode_of/seed_default_permission (+6 tests) + get_approval_mode/set_approval_mode commands + first-run seeding in spawn_sidecar (compliance floor even if the UI never runs); switching restarts the sidecar via the configure_opencode flow. Frontend: store loads the mode on connect, setApprovalMode persists + reconnects (+2 store tests); composer dropdown with active-mode check (+3 component tests). VERIFIED on the real bundled binary: approve config accepted — all 86 bash ask rules land in the build agent's resolved ruleset AFTER the builtin allow; {} accepted (24 builtin rules, zero asks); a 22-case evaluation with OpenCode's verbatim wildcard/findLast algorithm classified every case as designed (rm / pip install / ssh / curl / git push / cd-&&-rm → ask; ls / python / git status / "echo confirm" → allow). 292 JS tests, 42 Rust tests, tsc, eslint green. DMG rebuilt. Still open in P0-7: --cors * unauthenticated sidecar, plaintext keys, Windows cmd injection, kernel deadlock.
2026-07-05 19:30 · fix(pptx-preview) + feat(office skills). (1) Decks styled via paragraph-level <a:pPr><a:defRPr> (valid OOXML; WPS/PowerPoint resolve it) rendered as 18px black text in pptx-preview (run-level rPr only) — invisible on dark slides. New pure lib/pptx.ts: applyParagraphDefaults merges each paragraph's defRPr into runs lacking rPr (run's own values win; covers <a:r>+<a:fld>), normalizePptxForPreview rewrites the slide XMLs in-memory before rendering. VERIFIED end-to-end on the failing deck (title: 18px black → >30px white); 4 regression tests. (2) Anthropic document skills (docx/pdf/pptx/xlsx, Apache-2.0) now bundled: fetch-skills.sh pins anthropics/skills@9d2f1ae into external/anthropic-skills/, shipped as skills-office/ resource, deployed by deploy_bundled_skills on every sidecar start. VERIFIED inside the built DMG. 287 JS (+5) / 36 Rust tests, tsc, eslint green. DMG rebuilt.
2026-07-05 19:00 · fix(session): eternal "Working…" spinner + no interrupt. ROOT CAUSE (live-diagnosed on ses_0cb07e522f…): the turn had finished server-side (last assistant message time.completed set, no pending asks) but session.idle was lost in a deliberate SSE reconnect window 1.4 s earlier — runningSessions is event-driven with no recovery, and the /event stream is directory-scoped, so every cross-folder session open/workspace switch is a loss window. FIX: (1) reconcileRunning() checks each locked session against server truth (new turnIsOver: last message is a completed assistant message) on every reconnect + openSession + a 15 s poll while working, reloading the missed history tail and unlocking; (2) interrupt — SDK abortSession (POST /session/:id/abort), Stop button next to Working… + Esc (modals/palette keep priority), "Interrupted" line in the thread, abort's own error/idle events suppressed once; (3) visibility — the Working… row now names the currently running tool call. VERIFIED: turnIsOver validated against the real stuck session's live payload (→ unlocks); 282 JS tests (+8: reconcile stale/genuine/on-connect, interrupt lifecycle ×4, SDK completed-mapping+abort), tsc green. DMG rebuilt.
2026-07-05 23:30 · Architecture audit (4 parallel tracks: layering, Rust backend, frontend, safety defaults). Verdict: skeleton is high quality — SDK boundary real (zero raw HTTP in app), clean package deps, zero any/ts-ignore strict TS, 274 behavioral tests, uniform Rust path sandboxing. BUT the AGENTS.md non-negotiable safety defaults are NOT met: no permission config is ever written and the bundled OpenCode 1.17.13 defaults to {"*":"allow"} (bash/edit/installs run unprompted — contradicts P2-3's "approval mode ✅"; only the permission UI ships), sidecar runs --cors * unauthenticated (any local webpage can drive turns + read keys via /global/config), API keys plaintext in opencode.json. Two critical Rust defects: Windows cmd /C start command injection in open_url/os_open (agent link with & executes commands; also breaks legit URLs), and kernel_execute holding the global kernel mutex across an unbounded blocking read (a while True: pass cell wedges even kernel_reset). Full severity-ranked backlog recorded as REQUIREMENTS.md P0-7. Blocker: fix permission block + CORS before next release.
2026-07-05 22:40 · fix(preview): text files with unlisted extensions (.bib, .rst, .c, …) showed "Preview is available in the desktop app." INSIDE the desktop app. ROOT CAUSE: Rust mime_for defaulted unknown extensions to binary→base64, and the frontend text path only accepts utf8 — text stayed null and fell through to the misleading note. FIX: read_artifact now sniffs unknown extensions (valid UTF-8 without NUL bytes → utf8 text/plain, else base64); frontend reports genuinely binary files explicitly ("binary and has no preview — open it externally") instead of the desktop-app note. VERIFIED: Rust sniff test (bib text / NUL / invalid UTF-8 / known-binary never sniffed) + component test (base64 behind a text preview shows the binary message). 274 JS tests (+1), 10 artifact Rust tests (+1) green. DMG rebuilt.
2026-07-05 22:15 · P1-5 native table→chart surface shipped (closes P1-5's "native categorical chart surface when a real dataset needs one" gap). Any CSV/TSV (or xlsx-parsed) table preview now has a Table ↔ Chart toggle. New pure lib/tableChart.ts: analyzeColumns detects numeric columns (parses %, thousands separators, tolerates a minority of NA/labels via a 60% finite threshold), defaultChartSpec picks a sensible default (first categorical column as X → grouped bar; numeric X → line; numeric columns as Y series, capped at 8), canChart gates the toggle. New TableChart.tsx: renders line/bar/scatter with the app's shared --series-1..8 palette (the SAME hues the agent's matplotlib figures use, theme-aware for light+dark), with a chart-type segmented control, an X-column select (incl. "row #"), per-series show/hide chips, y-gridlines, thinned x-labels, and a legend — so a generated figure and this native chart tile read as one design system (P1-5 acceptance a). Wired via a TableView wrapper in FilePreviewInspector (Chart tab appears only when the data has a numeric column). VERIFIED: numeric detection + default-spec (categorical→bar, numeric→line, 8-series cap, null when nothing numeric) — 6 logic tests; component renders type controls + series toggles + bar rects, switches to line polylines, and shows the no-numeric message — 3 component tests. 270 JS tests (+15 across tableChart+modal), tsc, eslint green. DMG rebuilt.
2026-07-05 21:45 · P2-2 Modal runner shipped (the named P2-2 gap; SSH+Slurm was already done). Same shape as the HPC integration — the app drives the user's OWN credentials, never handles tokens. New Rust modal.rs: modal_status runs modal --version under the enriched PATH (so a conda/user-installed modal is found) and reports {installed, version, authenticated, hint}; authentication is a pure, unit-tested is_authenticated(home, token_env) (a non-empty MODAL_TOKEN_ID or a ~/.modal.toml file). New Settings "Cloud compute (Modal)" card (ModalCard.tsx): a status dot (ready/installed-not-authed/not-installed) + the fix hint + a Re-check button. New bundled modal-run skill: verify readiness, write a Modal function INTO the workspace (pinned image packages + fixed seed for reproducibility), modal run it with the user's token, capture results as artifacts; explicit rules — user's account only, never store tokens, cost-aware (bounded timeout, no GPU unless needed). VERIFIED: is_authenticated via token-env and via a real ~/.modal.toml fixture (empty home never authed) — 2 Rust tests; detection correctly reports "not installed" on this host (no modal); ModalCard mounts — 1 component test. 264 JS tests (+1), 35 Rust tests (+2), tsc, eslint green. DMG rebuilt. Gap: multi-environment management; a live Modal run needs the user's own account.
2026-07-05 21:10 · P1-3 materials binary phase-diagram viewer shipped — completes the materials electronic/thermodynamic trio (DOS + band structure + phase diagram). New lib/phase.ts parses a .phase JSON (2 elements + entries with atomic composition + formation energy/atom), computes each phase's composition x = n_B/(n_A+n_B), builds the LOWER CONVEX HULL (Andrew's monotone chain) and marks each phase stable/metastable by its energy-above-hull (defined via eAboveHull ≤ tol, NOT hull-vertex membership — a phase exactly on a tie-line is collinear and dropped as a redundant vertex, but is still marginally stable; caught this in testing). PhaseView.tsx: SVG scatter of formation energy vs composition with the convex-hull tie-lines, stable phases filled + labeled, metastable phases open (series-6 ring) with their eAboveHull on hover, endpoints labeled with the element symbols, app palette. Registry: previewKind "phase" (.phase), needsText, Rust mime_for serves .phase as JSON text, FilesPage image icon. VERIFIED against a Li–O fixture: composition fractions, stable (Li2O/LiO2/endpoints) vs metastable (LiO at +1.0 eV above the Li2O–O2 tie-line), hull sorted by x — 5 parser tests; component renders system/counts/hull path + 4 circles + stable labels + error — 2 component tests. 263 JS tests (+7), 33 Rust tests, tsc, eslint green. DMG rebuilt. The materials row of P1-3 (band/DOS + phase diagrams) is now ✅. Gap: ternary phase diagrams; FITS corner plots; anomaly-map coastlines.
2026-07-05 20:40 · P2-4 file-open reliability: fixed an ineffective large-file preview guard (real OOM/freeze risk). ROOT CAUSE: read_artifact (the choke point for every preview — Files explorer + artifact clicks) did std::fs::read(full) — loading the ENTIRE file into memory — and only THEN checked the 25 MB cap, so opening a multi-GB file (exactly what P0-6 is about) could exhaust memory and freeze the app before the cap ever fired. FIX: stat first via metadata().len(), reject oversized files BEFORE reading a single byte, then read only within the cap; extracted PREVIEW_CAP_BYTES + a pure exceeds_preview_cap() helper. Frontend: a new PreviewError component special-cases the "too large" error into a helpful card — explains the cap, offers "Open externally", and points to the large-file probe (schema/sample instead of loading the whole file) — other errors still render as a plain line. Considered P1-2 physics/earth connectors this iteration but REJECTED them on the "no bugs / vetted" bar: astro-mcp is actually Astronomer/Airflow (not astronomy — a name mismatch I caught before adding), and usgs-mcp/open-meteo-mcp couldn't be confidently verified to start as stdio servers (both exited with no error even with stdin held open) — I won't ship a connector I can't verify launches. VERIFIED: exceeds_preview_cap boundary (0/at-cap allowed, +1/2 GB rejected) — Rust test; the too-large card renders + Open-externally fires, plain errors don't — 2 component tests. 256 JS tests (+2), 33 Rust tests (+1), tsc, eslint green. DMG rebuilt.
2026-07-05 20:05 · P1-3 materials band-structure viewer shipped — completes the standard electronic-structure pair (band structure + DOS) for materials. New lib/bands.ts parses VASP EIGENVAL (5 header lines + NELECT NKPTS NBANDS + per-k-point blocks of bandIndex energy [energyDown] [occ…]), structurally reading NKPTS blocks × NBANDS bands, detecting spin from the two-energy-column form; BandView.tsx plots every band's energy across the k-point index as thin polylines (spin-up series-1, spin-down series-6 with a legend), energy gridlines, and a k/energy hover readout, in the app palette. Registry: previewKind "bands" (extensionless EIGENVAL via previewKindForName, or .eigenval), needsBytes (TextDecoder — no Rust change), FilesPage flask icon. VERIFIED: parser reads non-spin (3 k-points × 3 bands, correct per-band energy series + eMin/eMax) and spin-polarized (two energy columns → up/down) EIGENVAL — 3 parser tests; component renders one polyline per band + axis labels + error — 2 component tests; previewKindForName EIGENVAL — registry test. 254 JS tests (+5), 32 Rust tests, tsc, eslint green. DMG rebuilt. Gap: phase diagrams (materials); FITS corner plots; anomaly-map coastlines.
2026-07-05 19:35 · P0-4 reviewer PDF-manuscript hardening shipped (the P0-4 gap was "hardening across document formats (PDF)"). New bundled runtime/skills/core/traceability-review/pdf_extract.py: a deterministic, multi-backend PDF extractor (tries PyMuPDF fitz → pypdf → PyPDF2 → pdfminer.six, degrades to a clear "no backend" message — never a traceback) that returns {backend, pages, chars, citations:{dois,arxiv,pmids}, claims:[{kind,text,context}], text}. Citation identifiers (DOI 10.x/…, arXiv new+old, PMID) and quantitative claims (p-values, percentages, N=, CIs) are pulled by regex with surrounding context, so the reviewer audits REAL identifiers from the manuscript rather than ones recalled from memory (the same tool-over-recall principle as P0-5). Updated the traceability-review SKILL.md: for a PDF, run pdf_extract.py FIRST and feed citations→Check 1, claims→Check 2, text→Check 3; on {error} say so and never fabricate. VERIFIED end to end: generated a real PDF (PyMuPDF) and the extractor pulled the text, all three identifier types (10.1234/example.2026, arXiv 2401.00001, PMID 31234567), and p-value/percent/sample-size claims — 7 Python tests (incl. backend-independent regex tests + clean error on a missing file). Found + fixed a test-fixture bug (PyMuPDF insert_text clips long lines at the page edge → identifiers past the margin were lost; fixture now uses short lines — the extractor itself was correct). DMG rebuilt to bundle the extractor. Gap: robustness for models weaker at tool use.
2026-07-05 19:05 · P1-3 native climate-anomaly map shipped — the LAST P1-3 discipline viewer, so ALL FOUR targeted disciplines (materials, physics/astro, earth, social) now have a native renderer, meeting P1-3's "≥1 domain renderer per discipline" acceptance. New lib/anomaly.ts parses a gridded .anom field in two portable text shapes (long CSV lat,lon,value with header sniffing, or a labeled grid with lons in the header row + lats in the first column) into an ascending lat/lon grid + a symmetric zero-centered range; divergingColor gives a blue↔white↔red scale. AnomalyMapView.tsx: a canvas renders the field on an equirectangular (plate carrée) projection (north up, missing cells gray), with an SVG overlay drawing a graticule + °N/°S/°E/°W labels + hover crosshair, a diverging colorbar (±absMax), and a lat/lon/value readout — the correct transform + diverging colormap the discipline expects. Registry: previewKind "anomaly" (ext .anom), needsText, Rust mime_for serves .anom as text/csv, FilesPage image icon. VERIFIED: parser reads long-CSV + labeled-grid + diverging color endpoints — 5 tests; component renders grid dims + canvas + graticule labels + error — 2 tests. 249 JS tests (+7), 32 Rust tests, tsc, eslint green. DMG rebuilt. Gap: materials band structure + phase diagrams; coastline basemap for the anomaly map; FITS corner plots.
2026-07-05 18:35 · P1-3 native qualitative-coding traceback viewer shipped (social-science renderer row ⬜→🟡; pairs with P1-6). New lib/qcode.ts parses a portable .qcode JSON (sources + codebook + span annotations), validates each annotation (flags unknown source/code and out-of-range spans instead of throwing), and — crucially — derives every quote as source.slice(start,end), plus a boundary-segmentation pass that renders overlapping codes without loss. QCodeView.tsx: a codebook sidebar (per-code color from the app palette + counts), the source text with highlighted coded spans, two-way linking (click a code to isolate its spans; hover a span to see its codes), a skipped-annotation warning banner, and a "quotes are exact source spans" assurance — the decisive social-science integrity property is guaranteed by construction (a highlight is literally a slice of the source, so a code can never point at an invented quote). Registry: previewKind "qcode" (ext .qcode), needsText, Rust mime_for serves .qcode as JSON text, FilesPage highlighter icon. VERIFIED: parser reads sources/codes/annotations + exact quotes + overlap segmentation + warnings — 6 tests; component renders codebook + spans sliced from source + error — 2 tests. 242 JS tests (+16 across dos+qcode), 32 Rust tests, tsc, eslint green. DMG rebuilt. Gap: materials band structure + phase diagrams, earth climate anomaly maps.
2026-07-05 18:05 · P1-3 native materials DOS viewer shipped (second P1-3 domain viewer this session; materials band/DOS row ⬜→🟡). New lib/dos.ts parses VASP DOSCAR total density-of-states (5 header lines + Emax Emin NEDOS Efermi control line + NEDOS rows; 3 cols = non-spin, 5 cols = spin-polarized) + DosView.tsx: an SVG chart in the app palette — non-spin as one filled curve, spin-polarized with spin-up above the baseline and spin-down mirrored below (the canonical DOS presentation), the Fermi level as a dashed vertical marker, an "E − E_F ↔ absolute E" alignment toggle, and a hover readout. Made the previewer registry filename-aware: new previewKindForName(filename) recognizes extensionless scientific files (VASP DOSCAR) and the .dos extension, delegating to the ext registry otherwise; routed FilePreviewInspector + FilesPage (icon + inspector) through it. DOSCAR loads via needsBytes (TextDecoder-decoded), so no Rust text/binary change was needed. VERIFIED: parser reads non-spin + spin DOSCAR (energies, up/down channels, Efermi) — 3 parser tests; component renders two filled spin areas + Fermi marker + toggle, and a friendly error — 2 component tests; previewKindForName DOSCAR/.dos/fallback — 2 registry tests. 234 JS tests (+8), tsc, eslint green. DMG rebuilt. Gap: band structure + phase diagrams (materials), earth anomaly maps, social qualitative-coding traceback.
2026-07-05 17:35 · P1-3 native FITS astronomy viewer shipped (physics/astro was the lowest-completion discipline — no connector AND no viewer; now it has a renderer). New dependency-free FITS reader lib/fits.ts (parses 80-char header cards in 2880-byte blocks, big-endian data per BITPIX 8/16/32/64/-32/-64 with BZERO/BSCALE + BLANK→NaN, primary HDU; WCS keywords; 1-D spectrum vs 2-D image by NAXIS) + FitsView.tsx: a 2-D image HDU renders to a canvas with a scientific colormap (magma/viridis/gray), linear/log/asinh stretch, a colorbar, vertical flip for FITS bottom-left origin, and a hover readout of pixel value + approximate WCS sky coordinate (linear CDELT, cos-dec corrected); a 1-D HDU renders as a palette-matched SVG line chart with world-axis labels from CTYPE/CRVAL/CDELT. Dark astro backdrop, theme-independent like the molecule/mesh viewers. Wired the previewer registry: previewKind "fits" (+FITS_EXTS fits/fit/fts), FilePreviewInspector needsBytes→FitsView, Rust mime_for serves them as binary (base64→ArrayBuffer), extToKind "figure". VERIFIED against REAL astropy files (8×8 float32 image with a TAN WCS; 16-pt int16 spectrum): parser reads dims, decodes big-endian values (ramp 0..63; int16 0,3,6…), extracts WCS, maps the reference pixel to exactly CRVAL, and the 1D linear axis — 7 parser tests; component mounts with the right controls/DOM + friendly error — 3 component tests. 223 JS tests (+10), 32 Rust tests, tsc, eslint green. DMG rebuilt. Gap: materials band/DOS + phase diagrams, earth anomaly maps, social qualitative-coding traceback; FITS corner plots + full (non-linear) WCS.
2026-07-05 16:55 · P1-2 first non-bio domain connectors shipped (was 🟡 with ZERO non-bio DBs). Added two vetted, real open-source MCP servers to the curated catalog (lib/scienceConnectors.ts), same one-click-provision-into-isolated-uv-env mechanism as the literature/bio ones — we integrate, don't reimplement: Materials Project (mcp-materials-project, the requirement's prioritized materials DB; MP_API_KEY) and FRED (fred-mcp, Federal-Reserve economic time series for social science; FRED_API_KEY). Package names + entry points were verified against PyPI + each wheel's entry_points.txt BEFORE adding them (no invented names — the exact risk P0-5 guards against). Two mechanism upgrades: (1) connectorConfig now launches console-script servers (the common MCP shape) by resolving the script next to the managed interpreter — cross-platform (<env>/bin/<s> unix, <env>/Scripts/<s>.exe Windows) — in addition to -m module; (2) a per-connector free-API-key field passes the key via the MCP environment map (never into provenance/logs/exports — safety default) and Enable is gated until a required key is entered. Settings card gained a discipline chip + "Get a free key ↗" link + large-install note. VERIFIED end to end on this host: fred-mcp pip-installs, its console script lands EXACTLY at the path connectorConfig computes (<venv>/bin/fred-mcp), it rejects startup with no key, and with a key supplied via the environment it passes key validation and proceeds to run its transport — proving the launch + env-key chain (a live query needs the user's real key + OpenCode as the stdio client, same bar as the existing connectors). 216 JS tests (+8 connectorConfig: module/console-script/Windows-.exe/env-key/blank-key/breadth), tsc, eslint green. DMG rebuilt. Gap: physics/astro + earth/climate connectors (no sufficiently mature open MCP found yet), more chem/social DBs.
2026-07-05 16:20 · P0-6 large-file "reference, don't load" memory-pointer contract shipped (was 🟡 with the core contract unbuilt). New bundled skill large-file (runtime/skills/core/large-file/large_file_probe.py, stdlib-first, agent-facing tool — emits a compact JSON POINTER, not the review contract, so zero frontend change): given any data file it returns schema / shape / approx row count / head+tail sample / extracted log numbers in BOUNDED memory (streamed line count, tail via a 64 KB seek, per-cell truncation), so the agent references data instead of loading a file bigger than the context window. Coverage: tables (CSV/TSV, delimiter sniff + dtype inference), NDJSON (key union + count), Parquet (pyarrow metadata only), HDF5 (h5py dataset tree, no array read), FITS (astropy memmapped headers), NetCDF, and text/logs with DETERMINISTIC numeric extraction for VASP OUTCAR/OSZICAR (last free energy TOTEN, energy(sigma->0), convergence flag — the numbers, not prose). Binary formats without their lib degrade to a clear install-hint pointer, never a raw dump. SKILL.md tells the agent to probe BEFORE reading and then read only bounded slices (nrows/usecols/sub-array). VERIFIED end to end on real files on this host: 13.5 MB CSV → 788 B pointer (700k rows, head+tail sample), real 1.6 MB Parquet → 447 B (100k rows, schema from metadata), 16 MB HDF5 → 456 B (dataset tree with shapes) — each ~18,000–37,000× smaller than the file, no whole-file load (matches the requirement's >16,000× memory-pointer finding). 11 Python probe tests (incl. an explicit "pointer stays <4 KB and <file/100" assertion). DMG rebuilt. Gaps (🟡): genomics BAM/CRAM/FASTQ (pysam), GRIB, ROOT; auto pre-read wiring in the UI.
2026-07-05 15:45 · P1-6 social-science analysis integrity shipped (the other ⬜, pairs with P0-5). New bundled skill stats-integrity (runtime/skills/core/stats-integrity/stats_integrity_check.py), same deterministic + pluggable shape and same review contract — added integrity to shared ReviewCheck (findings carry a stats · … tag, so no per-check UI change). Three checks map to the three acceptance points: (a) execute-don't-interpret — a stats · interpretation check flags causal/"provocative" language ("causes/leads to/drives/the effect of…") in a report that is reporting an association, and the SKILL.md instructs surface-estimates-don't-volunteer-causation (report coef+SE, name the design before any causal claim); (b) prereg-aware — a stats · prereg check parses regression formulas (y ~ x1 + x2*x3) in the code and flags any predictor/interaction absent from a preregistration.*/analysis_plan.*/prereg.* plan (HARKing guard); (c) reproducibility — a stats · seed check flags randomised analysis (np.random/train_test_split/bootstrap/R sample) with no fixed seed. VERIFIED the .dta→R round-trip acceptance ON THIS HOST: pandas to_stata → R foreign::read.dta → lm reproduces Python OLS to the printed precision (β=0.600870, SE=0.075000 both sides; the SKILL.md documents this recipe with fixed seeds — no new engine, reuses the shipped R kernel). Rules favour precision (association wording and non-stats docs stay silent; no plan → no prereg findings). 13 Python validator tests; CLI catches all three risks on a realistic workspace; 208 JS tests (+1 integrity-tag parse), tsc, eslint green. DMG rebuilt. Gaps (🟡): a first-class in-app preregistration artifact + auto pipeline↔plan diff every run; packaged Stata/SPSS reader UI; wrong-test-selection detection.
2026-07-05 15:10 · P0-5 domain-correctness gates shipped (was the only ⬜ P0, "runs" ≠ "right" — the highest-value new build). New bundled skill domain-check (runtime/skills/core/domain-check/domain_check.py): a DETERMINISTIC, pluggable validator layer — one check_<field> rule set per discipline, stdlib-only, analysing the code the agent actually wrote (AST for Python/notebooks, regex for R) rather than model recall. Three gates ship, each catching its acceptance case: physics · units (dimensional mismatch t_seconds + d_meters; trig on a degree-valued angle), earth · crs (Euclidean distance on lat/lon incl. the classic (lat1-lat2)**2+(lon1-lon2)**2; geopandas geometric op with no CRS set), biology · coords/strand (BED off-by-one — 0-based half-open so length is end-start, no +1, caught via AST so int(end)-int(start)+1 is seen through the cast; strand-unaware sequence extraction). Rules favour precision (unknown units / no discipline signal stay silent; findings dedupe when rules overlap). Reuses P0-4's review contract end to end: extended shared ReviewCheck with domain + optional per-discipline tag (so new fields need NO UI change), parser + ReviewerCard render the tag; the skill tells the agent to run the gate before/after execution and relay the emitted ```review block. VERIFIED: 19 Python validator tests (3 acceptance catches + true-negative precision guards + syntax-error-no-crash + R fallback); CLI emits clean deduped findings on a multi-discipline sample; 207 JS tests (+2: domain-tag parse), tsc, eslint green; skill bundles into Open Science.app/Contents/Resources/skills-core/domain-check with NO test/pycache leakage; runs portably from the deployed $XDG_CONFIG_HOME path; and the bundled OpenCode sidecar DISCOVERS domain-check in its directory-scoped skill list alongside traceability-review. Gaps (kept 🟡): full library round-tripping (SMILES→RDKit, POSCAR→pymatgen), chemistry/materials + social-science gates, deeper per-field rules. DMG rebuilt.
2026-07-05 07:30 · Rewrote docs/REQUIREMENTS.md on a multi-discipline evidence base (v2). Five parallel community-research passes (physics/astro, chemistry/materials, earth/climate, social science, bio+cross-cutting) saved to docs/research/2026-07-05-multidiscipline-needs.md. Key finding: needs are ~80% shared / ~20% discipline-specific; the shared 80% (the moat) is largely shipped. Every item now carries a ✅/🟡/⬜ status marker. Added two NEW requirements from the evidence: P0-5 domain-correctness gates ("runs" ≠ "right" — units/CRS/off-by-one/stoichiometry/stat-selection; the largest gap, highest-value build) and P1-6 social-science analysis integrity (execute-don't-interpret + prereg + Stata/SPSS); split out P0-6 large-file reference-don't-load; expanded P1-2 connectors and P1-3 renderers into per-discipline tables (physics/chem/earth/social all ⬜ planned). Docs only; no code change.
2026-07-05 06:20 · Session header slimmed from 5 elements to 3 so it stays one line (user: slim the header content down or it wraps to two lines). The Files toggle now names the session folder itself (e.g. 2026-07-05-0319, full path on hover), absorbing the redundant read-only folder chip — WorkspaceChip no longer renders for open sessions (draft picker unchanged); ConnBadge shows only the green dot when ready (full text on hover), text only for connecting/error. 206 JS tests (WorkspaceChip open-session test now expects empty render), tsc, eslint green; DMG rebuilt.
2026-07-05 05:10 · Right pane is now per-session with scroll memory (user: switching sessions leaked another session's open PDF into a workspace where the path no longer resolved; wanted each session to restore its own pane and positions). Store: global activeArtifact → panes map keyed by session id (DRAFT_KEY for drafts) holding {artifact, showFiles} — mutually exclusive by construction; draft pane grafts onto the created session id, startDraft/switchWorkspace reset the draft pane, deleteSession forgets its pane. New useScrollMemory hook (module-level Map, zero re-renders): remembers scrollTop per key, restores once content is ready, ignores clamp events while loading; wired into chat (chat:<sid>), NotebookEditor + FilePreviewInspector (file:<path>, history separate), Docx/Xlsx/Pptx inner scrollers (office:<path>), and the demo inspectors. Limitation: PDF/HTML render in native iframes — the file reopens but the viewer's internal position can't be captured. 206 JS tests (+13), tsc, eslint green; DMG rebuilt (user re-tested BEFORE the rebuild and saw the old global-pane behavior — the fix wasn't in the installed app yet).
2026-07-05 04:10 · Files/Notebooks are now GLOBAL pages; the session gets its own quick entry (user: pages looked global but silently showed per-session content; demanded the kernel-cwd gap be solved properly, "需要1个最佳实践"). Design per user: Files browses from the Settings base folder (root crumb names it), Notebooks lists every .ipynb across all session folders newest-first with a folder chip, and the chat header gains a Files button that opens the CURRENT session's folder in the right inspector (SessionFilesPane, like Artifacts). Plumbing: file commands (list_dir/read_artifact/preview_url/write_workspace_file/open_path/list_notebooks) take an explicit root scope "workspace"|"base" (no fallback guessing); preview URLs are scope-prefixed /w/… vs /b/…. Kernel best practice = Jupyter semantics: one kernel PER NOTEBOOK, cwd = the notebook's own folder (kernel map keyed by lang+absolute path, per-kernel R code file) — relative paths in any notebook now resolve correctly from any page, and state no longer bleeds between notebooks. 32 Rust + 194 JS tests, tsc, eslint green; DMG rebuilt.
2026-07-05 03:40 · Files page now names the folder it is browsing (user: "Workspace" root showed 3 files but no way to tell WHICH folder — expected the OpenScience base, was actually the session's dated folder). The breadcrumb root now shows the active workspace folder's basename (e.g. 2026-07-05-0319) with the full path on hover, reusing WorkspaceChip's baseName (now exported). Browser dev keeps the "Workspace" fallback. 192 JS tests, tsc, eslint green; DMG rebuilt.
2026-07-05 03:45 · Follow-up on "still broken" report: the re-root fix WAS working (new jupyter correctly rooted + owns the port; e2e-verified by creating a notebook through the jupyter API — it landed in the active workspace). The remaining symptoms were pre-fix residue: nature_figure.ipynb had been misplaced into 2026-07-04-2204 by the old build (moved back to its session folder 2026-07-05-0319), and a Friday orphan jupyter-lab had survived TWO SIGTERM pkills (wedged in graceful shutdown) — force-killed it and hardened kill_orphan_jupyter to SIGKILL (pkill -9; Windows already used /F). 31 Rust tests green; DMG rebuilt.
2026-07-05 03:25 · Agent-created notebooks now land in the ACTIVE workspace (user: agent made a notebook but the preview said "file not found" and the Notebooks sidebar showed "No notebooks yet" despite many existing). ROOT CAUSE, same class as the 07-04 preview-server bug: jupyter-lab pins --ServerApp.root_dir at spawn time and start_jupyter is idempotent, so after workspace-per-session switches the agent's jupyter MCP kept writing notebooks into the OLD folder (proven live: running lab rooted at 2026-07-04-2204 + a Friday orphan rooted at the base folder, both on port 50868, while the UI scanned 2026-07-05-0319). FIX: set_workspace (the choke point for every switch path) now calls reroot_jupyter — background kill + respawn rooted in the new folder, serialized by a lifecycle lock; port/token are fixed in server meta so the MCP entry stays valid. Orphans self-heal on next start via the existing pkill. Existing stray notebooks remain in ~/Documents/OpenScience (by-design per-folder isolation). 31 Rust tests green; needs app rebuild + restart to take effect.
2026-07-05 04:35 · 3D viewer warns on unrenderable files (user: add a notice so people know the file is the problem). After framing, MeshView renders the model once offscreen over a magenta clear (backdrop off) and counts painted pixels; if ~none (<0.08% of the canvas) it overlays a "File problem — this 3D file has no visible geometry, the export looks incomplete or corrupt" banner. Catches the malformed sample (renders empty) and genuinely empty/corrupt exports; verified NOT to false-positive on a valid cube glTF or the torus-knot STL. 194 JS tests, tsc, eslint green; DMG rebuilt.
2026-07-05 04:20 · 3D viewer fidelity: glTF/GLB visibility + obj z-fighting (user: cube.gltf/glb showed nothing; cube.obj bottom face flickered at some angles in Shaded). ROOT CAUSES: (1) glTF meshes kept their own single-sided material and a default fully-metallic PBR material renders black without an environment map — added a PMREM RoomEnvironment so PBR renders correctly, and forced DoubleSide on all mesh materials so inconsistent winding never culls a face to invisible; (2) the shadow-catching ground sat coplanar with a flat model bottom (a cube) → z-fighting; dropped the ground by radius*0.01. Decisive check: a hand-built VALID cube glTF renders as a clean shaded cube — so the viewer is correct; the user's sample cube.gltf/glb are MALFORMED (only 2 of 6 faces, and a wrong indices bufferView byteLength), produced by the sample generator, which is also why the same-cube STL/OBJ/PLY render fine. Verified valid-gltf + obj by screenshot. 194 JS tests, tsc, eslint green; DMG rebuilt.
2026-07-05 03:15 · 3D mesh viewer for stl/obj/ply/gltf/glb (user: can 3D models / CAD show? then: add three.js, beautiful + workable). New MeshView (three.js + WebGL, self-contained, lazy-loaded loaders) added as its own preview kind: warm studio lighting (hemisphere + key/fill), soft contact shadow, warm gradient backdrop matching the app palette, auto-frames the model, OrbitControls (drag-orbit / scroll-zoom / right-pan), Shaded↔Wireframe toggle, triangle-count badge; theme-independent like the molecule/Office previews. Wiring: previewKind→"mesh" (+MESH_EXTS), FilePreviewInspector needsBytes, Rust mime_for returns these as binary→base64→ArrayBuffer, new "model" ArtifactKind (Box icon) so written models surface as artifacts. Three fidelity fixes found via screenshot: STL face normals are often zero → always computeVertexNormals (else unlit black); wireframe swaps to a dark high-contrast material (light clay drew near-invisible lines on the warm bg); material ref-swap toggles without rebuilding. Added three/@types/three deps. Verified with a torus-knot STL: shaded + wireframe both render cleanly. CAD note: DXF (mid) and STEP via occt-import-js WASM (heavier) possible later; DWG has no open reader. 192 JS tests (1 new: mesh mapping), tsc, eslint green; DMG rebuilt.
2026-07-05 02:35 · Office preview overflow fixes (user: wide xlsx changes background color when scrolled right; docx renders too large to see both page edges). (1) xlsx: the shadow .page was block-level (viewport-wide), so its white background stopped mid-table and cells without a fill turned a different color past that point when scrolled right — set .page { width: max-content; min-width: 100% } so the background covers the table's full width. (2) docx: a page has a fixed physical width (Letter/A4, portrait or landscape) usually wider than the pane, overflowing with both edges cut off — DocxView now scales the rendered page to fit the pane width via zoom (shrinks the layout box, no leftover scroll; never upscales) and re-fits on pane resize via ResizeObserver. Both verified by screenshot in a narrow pane. 191 JS tests, tsc, eslint green; DMG rebuilt.
2026-07-05 02:10 · xlsx preview now shows real formatting — fills, font size/color, bold, alignment, borders, column widths (user: docx/pptx/xlsx looked very different from WPS; xlsx had no background colors etc). ROOT CAUSE for xlsx: the open-source SheetJS build's sheet_to_html emits values ONLY (no styles), so every cell was an unstyled grid. Rewrote lib/xlsx.ts on ExcelJS (already a transitive dep, now explicit) — reads each cell's fill/font/alignment/border and emits inline styles + a <colgroup> of Excel column widths + merge spans; ARGB alpha ignored (files store 00 alpha that still means visible). workbookSheets is now async; XlsxView awaits it; SHEET_CSS reduced to a neutral gridline scaffold cells override. Verified with the user's real spec.xlsx: terracotta header fill + white bold text, and the Color-Palette sheet's hex cells render their actual swatch colors — matches WPS. docx (docx-preview) and pptx (pptx-preview, black bg already fixed) were checked and already apply document styles, so left as-is; remaining gaps there are font substitution + inherent browser-render approximation. 191 JS tests (xlsx suite rewritten, +1 style assertion), tsc, eslint green; DMG rebuilt.
2026-07-05 01:45 · File preview no longer bleeds the previous file's content (user: clicking a file's Open button sometimes shows crossed/mixed content). ROOT CAUSE: one FilePreviewInspector instance is reused across files (no key), and its load effect only called setText when data.content === undefined — so opening a second file that carries its own inline content never replaced the first (useState initializers don't re-run on prop change); url/bytes lingered too. FIX: the effect now resets text/url/bytes to the new file's baseline up front, before the async loads fill in only what the new file needs. Covers both the deterministic inline-content bleed and the fast-switch race (the existing cancelled guard drops the stale file's async result). 190 JS tests (1 new: reuse-across-files shows the new file), tsc, eslint green; DMG rebuilt.
2026-07-05 01:20 · CSV preview: dirty commas no longer explode the table (user: a design_tokens.csv row with 0 1px 3px rgba(0,0,0,0.04) rendered as many extra columns). ROOT CAUSE: agents emit CSV with unquoted CSS/JSON values whose internal commas a strict RFC parser splits into extra fields; TablePreview renders one per field, so that row spilled past the 4-col header and misaligned. FIX in parseDelimited: track bracket depth outside quotes — a delimiter inside ()/[]/{} is field content, not a separator (quoted fields still parse strictly, so well-formed CSV is unchanged; depth resets per line since brackets don't span rows). Rows are then normalized to header width (pad short, fold an over-long row's surplus into the last cell) so the table is always rectangular. Verified with the user's real file: 4 clean columns, shadow/clamp() values intact. 189 JS tests (2 new), tsc, eslint green; DMG rebuilt.
2026-07-05 00:45 · Markdown preview (editorial-blog paper) + pptx black-background fix (user: md should preview like a document; wanted a minimal blog feel with a touch of color; pptx slides sat on black). (1) .md gets its own preview kind with a Preview/Code toggle: MarkdownViewer grew a document variant styled as blog paper — a white card on the pane, warm ink (#2b2620), serif display headings (Iowan Old Style → Charter → Georgia; CJK → Songti), the app's terracotta brand (#c15f3c) as a layered accent (H2 accent bar, list markers, links, inline code), tuned tracking (CJK-safe near-zero on headings, +0.006em body), text-wrap balance/pretty, tabular-nums tables. Theme-independent by design (a document reads the same in dark mode). The shared component's element set (headings/ol/blockquote/pre/table/hr) also lifts chat markdown. (2) pptx black background ROOT CAUSE, reproduced in a standalone harness with the user's own deck + DOM inspection: pptx-preview hardcodes background: black on its .pptx-preview-wrapper, so every gap around slides showed black; fixed with a shadow-DOM override (transparent !important beats the inline style) — slides now float on the light canvas with shadows. Libraries stay: docx-preview / SheetJS / pptx-preview are the strongest pure-JS options (browser pptx rendering is inherently approximate). All three variants (EN report, CN blog, mixed elements) verified by screenshot. 187 JS tests (3 new), tsc, eslint green; DMG rebuilt.
2026-07-04 22:40 · HTML/PDF/image Preview fixed (user: index.html opened with code visible but Preview said "not found"). ROOT CAUSE, two layers in the preview file server: (1) artifact paths from write tools are ABSOLUTE (/Users/…/2026-07-04-2204/index.html) but preview_url pasted them into the URL as if workspace-relative — the server joined them under its root and 404'd every absolute-path artifact; (2) the server's root was captured once at first use and cached forever, so after the workspace-per-session change any session switch left it serving the wrong folder. FIXES: preview_url now relativizes absolute paths against the current workspace (sandbox-checked: outside-workspace paths are a clear error, not a silent 404); the server resolves each REQUEST against the workspace as it is now (serve(root_of) closure). User-verified in the app. 31 Rust tests (2 new), DMG rebuilt.
2026-07-04 22:30 · Live streaming text + file-path tool rows (user: a landing-page turn showed only "write / Working…" for ~50s — couldn't see what was being written or worked on). ROOT CAUSE, verified in opencode v1.17.13 source + the bundled binary: (1) OpenCode streams assistant text as message.part.delta events (full message.part.updated only fires at text-start with "" and at text-end with the whole passage) — the SDK client ignored deltas, so ALL agent prose popped in at once at the end; (2) during a write tool's argument streaming (the dominant wall time — tonight's 20KB index.html streamed 22:06:34→22:07:24) OpenCode 1.17.13 emits NOTHING (tool-input-delta is a no-op upstream; the part sits at {status:"pending", input:{}}), so no client can show live write content at this pinned version — upstream limitation, revisit on upgrade; (3) once input DID arrive, the tool row still ignored it — title fell back to input.command (bash) then the bare tool name. FIXES: SDK accumulates message.part.delta per partID into progressive text.updated events (cleared on session.idle; reasoning parts never seeded, so their deltas drop out); tool-row titles fall back to input.filePath so write/edit rows show the target file as soon as args parse; per-token events skip the debug-log IPC. VERIFIED LIVE against a real opencode server: text.updated arrived progressively 0→15→34→44 chars, final text identical to text-end. 184 JS tests (2 new; mock server made protocol-faithful: text streams as deltas), tsc, eslint green; DMG rebuilt.
2026-07-04 21:20 · Settings shows and changes the BASE workspace (user: Settings displayed a dated session folder like ".../2026-07-04-2103" instead of ~/Documents/OpenScience, and Reveal offered no way to pick a folder). Settings' Workspace card was reading the ACTIVE workspace (which follows each session into its dated subfolder); it now shows the BASE folder — the parent every new session's dated subfolder is created under — with two buttons: "Change…" (native picker → new set_workspace_base, persisted to base-workspace.txt; base_workspace_dir reads it first, so new sessions land under the chosen base; existing sessions keep their folders) and "Reveal" (new open_workspace_base — the sandboxed open_path can only resolve inside the active workspace). The per-session dated folder still shows in the conversation header chip, by design. 183 JS + 29 Rust tests, tsc, eslint, release build green; DMG rebuilt.
2026-07-04 20:55 · Session switching 9-20 ms, was 3-6 s (user: why is switching history so slow?). ROOT CAUSE: workspace-per-session + set_workspace killed and respawned the whole OpenCode sidecar on every cross-folder open (process boot ~2s + 1s-granularity reconnect polling + a 1.5s blind listSkills retry inside connect()). VERIFIED LIVE first: OpenCode serves many folders from ONE process via per-directory instances — a bare /event stream can't see other folders (the old comment was right) but /event?directory= CAN; /session/:id/* calls route by the session's own recorded folder (pwd proof); a directory-less createSession lands in the boot cwd (so it MUST carry the param). FIXES: SDK /event + createSession carry ?directory=; Rust set_workspace no longer restarts the sidecar; store connectRetry backs off 250ms×8 then 1s; loadCatalog moved off connect()'s critical path with the 1.5s blind sleep replaced by 400ms polling. MEASURED with the real SDK against the live sidecar: 3-folder hop = 20/9/9 ms including getMessages; scoped SSE delivers a real turn's tool + idle events; the shell runs in the session's folder, not the boot cwd. 183 JS + 29 Rust tests, tsc, eslint, release build green; DMG rebuilt.
2026-07-04 20:25 · Skeleton while switching sessions (user: the right pane sat blank for a moment when opening a history session). LiveSessionPage shows pulsing placeholder shapes that mirror the real thread layout (user card / agent text lines / a quiet tool row) whenever a session id is set but its history hasn't loaded (!thread.loaded) — covers the getMessages fetch and the sidecar restart on cross-folder opens. 183 JS tests, tsc, eslint green; DMG rebuilt.
2026-07-04 20:15 · Pasting "/name args" now chips too (user: paste didn't activate the command chip). The commit rule generalized from "name + trailing space only" to "known name + whitespace + rest": one change event with the whole pasted text commits the chip and the remainder (multi-line included) becomes the arguments; unknown names ("/etc/hosts …") stay plain text. 183 JS tests (2 new), tsc, eslint green; DMG rebuilt.
2026-07-04 20:05 · Composer: command chip + terminal-style input history (user: "/xxx should be a distinct block, not degrade into string text; ↑ should recall past inputs like a terminal"). (1) Committing a slash command — palette pick, or typing the full known name + space — turns it into an accent chip on the left (mirrors the shell-mode chip); the input then holds only arguments (mono font, accent border, argless Enter allowed); Backspace on empty input or the chip's × dissolves it back to editable "/name [args]" text; palette/shell detection are disabled while chipped so args starting with "/" or "!" stay arguments. (2) Every sent input (prompt, "!cmd", "/name args" in typed form) is recorded to localStorage (global, cap 100, consecutive-dedupe); ↑ with the caret at position 0 enters history navigation, ↑/↓ walk older/newer, walking past the newest restores the unsent draft, any edit exits navigation, the open palette keeps ↑/↓ priority. 181 JS tests (7 new composer tests; 1 updated: palette pick now chips instead of inserting "/name "), tsc, eslint green; DMG rebuilt.
2026-07-04 19:50 · Slash commands in history show as typed, not expanded (user: "/growth-marketing …" reloaded as the skill's full 6.7k-char template text as if they had pasted it). OpenCode stores the EXPANDED template + appended arguments as the user message with NO marker; GET /command exposes each command's template, so historyToThread reverse-maps: a user text that starts with a known template (longest-first) renders as "/name args". Caveat found live: MCP prompts report template as an argument-schema OBJECT, not a string — SDK now passes template through only when it is a string (would have crashed .trim()). VERIFIED against the real session over the live sidecar: the message renders exactly as typed. 174 JS tests (2 new), tsc, eslint green; DMG rebuilt.
2026-07-04 19:35 · History rendering cleaned up (user screenshots: repeated "The following tool was executed by the user" bubbles with bare "bash" rows; a subagent session opened from the sidebar showing its raw task prompt and read/glob spinning forever). Three fixes: (1) historyToThread now renders a user-run "!" shell turn like the live path — OpenCode stores it as a synthetic user text (synthetic:true, new field on HistoryPart) + a bash tool part; we show "! cmd" + inline output and never the marker text; bash rows also fall back to input.command as their title (was showing just "bash"); (2) steps frozen mid-run (runtime restarted / turn killed — e.g. the 18:35 stuck explore turn whose pending permission asks died with the old sidecar process) render as quiet "pending" + ONE red "Interrupted — this turn did not finish" line instead of spinning forever; (3) subagent child sessions (parentId set) no longer get their own sidebar row — they are internals of the parent conversation. NOTE: also repaired 3 literal NUL bytes accidentally written into runtime.ts's permission-sig template (worked fine, but grep treated the file as binary). 172 JS tests (4 new history tests), tsc, eslint green; DMG rebuilt.
2026-07-04 19:05 · Fixed the stuck "Explore …" turn + "Send failed: Load failed" (user session ses_0d01a1073ffe…: a /growth-marketing turn froze forever and the send reported failure). ROOT CAUSE (one bug, two symptoms): a task-tool subagent runs in a CHILD session; its external_directory permission asks carried the child id, but the UI only rendered asks where sessionId === currentId — the approval card never appeared, the subagent's read/glob blocked forever, and the still-open synchronous /command POST was killed by WKWebView's ~60 s fetch timeout → WebKit's generic "Load failed" shown as a send failure. FIXES: (1) SDK task tool events now carry childSessionId (from part metadata) and SessionMeta.parentId; store keeps a sessionParents map (live task events + session list on recovery) and the page resolves asks through rootSessionOf — subagent asks surface in the parent conversation with an "Asked by " label; (2) SDK permission mapping fixed for the V2 field names (permission/patterns, was reading nonexistent action/resources → cards would have shown "action" with no paths); (3) a sync shell/command POST that rejects AFTER SSE events arrived for the session is treated as "connection died, turn alive" — lock kept, no false failure line; session.idle ends the turn (SSE-sequence mark, no clock); (4) one reply answers all identical pending asks (same session+action+resources — the explore agent fired three at once); (5) working indicator says "Paused — the agent needs your answer below" while an ask is pending. VERIFIED: real SDK against the live stuck server session — all 3 pending asks now map to action external_directory with real paths and resolve to the parent session. 168 JS tests (4 new: child→parent mapping, dropped-POST-alive, genuine-failure line, batched reply), tsc, eslint, vite build green.
2026-07-04 10:45 · Composer command modes shipped (user: wanted a Claude-Code-style "!" command-line mode and "/" slash commands for skills/MCP/shortcuts). Mechanism — OpenCode-native, no fakery: "!" flips the input into shell mode (amber border, terminal chip, mono font) and Enter runs the line via POST /session/:id/shell — no model turn, executes directly in the session's workspace folder, recorded in session history as a bash tool part; "/" opens a command palette above the input fed by GET /command (OpenCode merges config commands + skills + MCP prompts into that one list) with filter-as-you-type, ↑/↓ + Tab/Enter autocomplete, Esc close, and POST /session/:id/command on send — an unknown "/name" stays a plain prompt (paths like /etc/hosts). Send lifecycle unified into one performTurn (prompt/shell/command). TWO RACES FOUND + FIXED during verification: (1) shell/command POSTs are SYNCHRONOUS (resolve after the turn; session.idle arrives first), so the running lock is set before the POST — set after, it would never clear; (2) new shellTurns flag (shows the bash OUTPUT inline for user-run commands only — agent bash steps stay quiet lines) must be cleared by the session.idle EVENT, not the POST settling, because SSE frames race the POST response on separate connections. VERIFIED live (Playwright against the real 1.17.13 sidecar + vite): palette filters "/ini"→/init, autocompletes; "!pwd && echo E2E-SHELL-OK" echoes, runs in the workspace, output shows inline in the thread, session lazily created, turn ends with no model configured. 164 JS tests (13 new: SDK endpoints, store lifecycle/races, palette + shell mode UI, inline output), tsc, lint, vite build green.
2026-07-04 10:15 · Proper send lifecycle, new → input → send → response (user: first message gave no feedback then the page suddenly switched; a second send was possible before any reply; errors — occasional "Load failed" — were invisible). Mechanism: (1) the message echoes into the thread INSTANTLY — a draft echoes under a local DRAFT_KEY thread whose blocks are grafted onto the real session id on create, so the page never visibly resets; (2) sending (click → POST accepted) plus runningSessions (until session.idle/error) lock the composer ("Waiting for the reply…") and show a bottom "Starting the session in its folder… / Working…" spinner row; (3) session-scoped errors fold into the conversation as red status lines (new "error" tone) and end the turn — no more silent failures; (4) the first POSTs after a sidecar restart retry once (kills most transient "Load failed"). 148 JS tests (7 new: instant echo/graft, second-send lock, idle/error turn end, create retry, hard-failure line), tsc, lint green.
2026-07-04 10:00 · Top-right status no longer churns on history/folder switches (user: badge flipped ready→connecting→ready and something flashed at the top-right; it should not change unless the runtime is really disconnected). The flash was the ConnBadge state flip plus the blue Connect button appearing while !connected. New store flag switching, set around the three deliberate sidecar-restart paths (cross-folder openSession, workspace pick, dated-folder first message); while set, the page treats the runtime as connected — badge stays "ready", no Connect button, no help card, composer stays enabled. Real failures still surface once the retry window is exhausted (switching clears in finally). 142 JS tests (1 new: switching lifecycle), tsc, lint green.
2026-07-04 09:55 · No more "New session"+workflow-starters flash while a history session loads (user: switching between history sessions showed the draft empty state before the real content). Cause: the empty state keyed off "thread has no blocks", which is also true for a session whose history hasn't loaded yet (seconds, on cross-folder opens). Fix: a URL with a session id is never a draft — the title stays blank instead of "New session" and WorkflowStarters render only on the real draft route (/live with no id). 141 JS tests, tsc, lint green.
2026-07-04 09:50 · Removed the "Connecting to the runtime…" in-content line (user: it appearing/disappearing made the page height shake). While connecting, nothing renders in the content flow — the header status badge is the feedback (its dot now pulses during connecting). The help card remains for real error/offline states. 141 JS tests, tsc, lint green.
2026-07-04 09:45 · No more error-card flash on folder switches (user: opening a history session in another folder — or starting a dated-default session — briefly showed "OpenCode runtime … Could not open OpenCode event stream" before the content). Cause: every cross-folder move restarts the sidecar on purpose, and (a) each failed reconnect attempt wrote its error into the store, (b) the big runtime help card rendered whenever status ≠ ready, including the deliberate "connecting" window. Fix: connectRetry now masks status AND error during the retry loop (the last error surfaces only if the whole window is exhausted), and LiveSessionPage shows a quiet "Connecting to the runtime…" one-liner while connecting, keeping the help card for real error/offline states. 141 JS tests (2 new: masking + exhausted-window), tsc, lint green.
2026-07-04 09:35 · History is now global across all workspace folders (user: switching folders changed the History list). Root cause: GET /session is scoped to the project the sidecar's cwd resolves to — non-git folders share one "global" project but a git folder is its own project, so picking one swapped the visible history. Fix in the SDK only: listSessions now uses GET /experimental/session (lists every project's sessions, newest first, each with its directory — verified against a live sidecar: 23 sessions across 2 projects) with a fallback to /session if the route is missing. Cross-project DELETE /session/{id} verified to resolve globally (200), and openSession already follows a session into its own folder, so no other changes. 139 JS tests, tsc, lint green.
2026-07-04 09:25 · Simplified the draft folder chip per user feedback ("a choose-folder icon is enough; dated is the default; keep-current not needed"): the dropdown menu is gone — a fresh draft's header now shows just a folder icon (tooltip names the dated folder the session will get); clicking it opens the native picker directly, and only after an actual pick does the chip show the chosen folder's name. Removed the setWorkspacePinned store action (pin = explicit pick via switchWorkspace; unpin = "New"/startDraft). 139 JS tests, tsc, lint, vite build green.
2026-07-04 09:20 · Moved the workspace-folder control out of the sidebar (user: the top-left switcher chip felt unnatural). The folder choice now lives where it matters — the session header: while a fresh draft is open, a small "New dated folder ▾" chip next to the "New session" title offers New dated folder (default, ✓-marked) / Keep current folder (…) / Choose folder…; once the session exists the chip becomes a quiet read-only indicator of the session's folder (tooltip = full path). New WorkspaceChip component (thread/), sidebar WorkspaceSwitcher deleted, store action generalized to setWorkspacePinned(bool). 138 JS tests (3 new WorkspaceChip component tests), tsc, lint, vite build green.
2026-07-04 09:00 · New sessions now default to their own fresh dated folder (user: pwd in a new session answered the bare ~/Documents/OpenScience; wanted <base>/<date-time> as default, or an explicit choice). A fresh draft's first message now runs new_dated_workspace(<YYYY-MM-DD-HHMM>) + kernel reset + reconnect BEFORE lazy session creation, so the session, kernel, Files and provenance all start inside the dated folder. Explicit choice = "pinning": picking a folder in the sidebar switcher ("New dated folder" / "Open folder…") pins the destination for the next session; a new "Keep current folder (…)" menu item pins without switching; "New" (startDraft) un-pins back to the dated default. While a draft is unpinned the switcher chip shows "New dated folder" (FolderPlus icon) so the destination is visible before sending. Frontend-only change (runtime.ts store + Sidebar.tsx; datedWorkspaceName moved to the store module); Rust untouched. 135 JS tests (5 new: dated-name format + store pin/unpin/lazy-create behavior via mocked sidecar), tsc, lint, vite build green.
2026-07-04 08:30 · Switchable workspace folders (user: sessions all dumped in one dir, no dating, no user choice). Sidebar now has a workspace switcher (folder chip + menu): "New dated folder" creates ~/Documents/OpenScience/<YYYY-MM-DD-HHMM> and switches to it; "Open folder…" picks any existing folder. The choice persists (app-data active-workspace.txt) and workspace_dir returns it, so the kernel / Jupyter / Files explorer / provenance / previews all operate in the active folder; the agent's OpenCode sessions land there too. New Rust commands: workspace_base, set_workspace, new_dated_workspace, pick_folder; workspace_dir = active override else base (base_workspace_dir keeps the migrations). Store switchWorkspace + openSession (follows a session into its own folder). KEY MECHANISM FINDING (via API probing + a wrong first cut): OpenCode's directory is a per-request query param and its /event SSE stream is directory-scoped — passing ?directory= on session/prompt routes the turn's events to a scope the app's global /event never sees, so turns silently produced NOTHING (empty assistant message). So scoping is done by the SIDECAR CWD (restart on switch, the same proven path as import-login), NOT a query param; reverted the ?directory= additions on createSession/sendPrompt/event. VERIFIED (model-independent, since the free Mimo model is currently returning empty completions — confirmed via an empty assistant message, unrelated to this change): switched to a dated folder → chip updates, folder created on disk, sidecar restarts + reconnects, a Python notebook cell os.getcwd() returned exactly /Users/asq/Documents/OpenScience/2026-07-04-0827 — proving kernel/Files/provenance follow the active folder. 130 JS + 29 Rust tests, tsc, lint, vite build green; installed to /Applications.
26-07-04 06:16 · Fixed "No module named matplotlib" in local notebook cells (user report). Root cause (systematic-debugging): the local kernel (kernel.rs) spawned bare python3 with NO PATH set, so a Finder-launched app (minimal PATH) resolved it to a system Python (CommandLineTools / /usr/bin, no matplotlib) — while the agent/sidecar runs python3 under the enriched PATH → the user's anaconda (WITH matplotlib). Kernel and agent used different Pythons. Not a regression from this session (kernel never set PATH); surfaced when running a matplotlib cell from Finder. Fix: the kernel now spawns with the SAME crate::runtime::enriched_path() the sidecar uses (made pub(crate)), and interpreter detection probes under that same PATH so detection and execution agree — so python3 resolves to the agent's scientific Python. VERIFIED on the packaged app (launched from Finder): new Python notebook cell import matplotlib, numpy, scipy; print(matplotlib.__version__) → matplotlib 3.10.1 (was ModuleNotFoundError). 130 JS + 29 Rust tests, tsc, lint, vite build green; installed to /Applications.
2026-07-04 05:15 · Usability pass (user asked to review comfort/naturalness of the UI) — verified on the packaged app. (1) Conversation was noisy: every mechanical step rendered as a heavy bordered card with raw absolute paths, reading like a shell trace. Fixed: ToolCallRow now renders successful/pending/running steps as quiet single-line log entries (muted monospace, no card); only waiting-approval/warning/failed keep a prominent card. tidyToolTitle shows workspace files by relative path (demo_analysis/analyze.py). todo* tool rows (opaque "N todos") are now dropped from the thread. Verified: the dose-response demo thread now reads as prose + artifacts, not command cards. (2) Sidebar had "Customize" (top) and "Settings" (bottom gear) both opening /settings — removed the redundant "Customize"; Settings is the single bottom entry. (3) DECISION (user): reverted the OS-keychain credential storage from earlier today back to the app-private mode-600 file — on unsigned/self-built copies a signature change makes macOS prompt for the login-keychain password every launch, a worse experience than the mode-600 file for a marginal gain; P2-3's no-leak Acceptance was already met. Removed keychain.rs + the keyring dep; KEPT the independent P2-4 exit-cleanup fix (Cmd+Q → RunEvent::Exit reaps the sidecar/kernel/Jupyter, which previously orphaned). Verified: reverted build launches with no keychain prompt, connects on free Zen, sidebar clean. 130 JS + 29 Rust tests, tsc, lint, vite build green; installed to /Applications.
2026-07-04 03:30 · Credentials at rest in the OS keychain (P2-3's open gap) + fixed a real orphaned-sidecar exit bug (P2-4). Provider credentials no longer sit in a plaintext file between runs: new keychain.rs (via the keyring crate — macOS Keychain / Windows Credential Manager) hydrates OpenCode's auth.json from the keychain before the sidecar starts and, on exit, writes it back and deletes the on-disk file. Never-lose invariant: the plaintext file is removed ONLY after a successful keychain write; any failure (or a keychain-less platform) keeps the file exactly as before — no regression (unit-tested via a SecretStore trait with an in-memory store: round-trip, save-failure-keeps-file, hydrate-never-clobbers, skip-empty). WHILE VERIFYING, found + fixed a genuine pre-existing bug: on macOS Cmd+Q/Quit the app terminates via RunEvent::Exit, NOT ExitRequested, so the exit cleanup NEVER ran — the OpenCode sidecar (and kernel/Jupyter) orphaned on every quit (confirmed: main app gone, sidecar PID still alive; 8 stale sidecars had accumulated), and my keychain persist wouldn't fire either. Cleanup now runs on BOTH events (idempotent). VERIFIED end to end on the packaged app: launch → Cmd+Q → sidecar reaped ✓ and auth.json moved into the keychain + removed from disk ✓ → relaunch → auth.json restored byte-identical (sha match) ✓ → OpenCode ready with the configured model (Mimo-V2.5-Free) ✓. (macOS keychain ACL trusts the app, so the item is readable by the app without a prompt but NOT by the security CLI — expected; verified via the app's own restore path.) Data-flow card copy updated (keychain, not "app-private file"). 127 JS + 33 Rust (4 new keychain, +1 ignored real-keychain) tests, tsc, lint, vite build green; installed to /Applications.
2026-07-04 02:55 · Workspace Files explorer shipped (P2-1's multi-file/"IDE for larger projects" gap). New sidebar "Files" page (FilesPage) browses the whole workspace tree — a breadcrumb for folder navigation, type-aware icons (folder/notebook/image/table/molecule/genome/text) + human sizes — and opens ANY file in the existing native viewers (reuses FilePreviewInspector + NotebookEditor, so figures/tables/PDF/molecule/genome/notebooks all work), not just files the agent mentioned. New Rust list_dir(rel) — non-recursive, workspace-sandboxed via resolve_under, skips dotfiles/node_modules/pycache, dirs-first sort (extracted to a testable dir_entries helper). VERIFIED end to end on the packaged app: Files → workspace listing with correct icons/sizes → clicked demo.pdb → opened in the 3D protein cartoon viewer (327 atoms) → navigated into canvas-project (breadcrumb "Workspace › canvas-project", subfolder contents listed). CAUGHT A REAL BUG only visible in the running app: DirEntry serialized is_dir (snake_case) while the frontend reads isDir, so folders rendered as files and clicking one hit "read failed: Is a directory" — fixed with #[serde(rename_all = "camelCase")] (the Rust unit test used the Rust field name so it passed regardless; the serde-boundary mismatch surfaced only in real-app verification). 127 JS (3 new) + 29 Rust (2 new) tests, tsc, lint, vite build green; installed to /Applications.
2026-07-04 02:45 · Native genome-track viewer shipped (P1-3's "then … genome tracks" — now BOTH a structure renderer and a track renderer ship, beyond the "at least one domain renderer" acceptance). New lib/genome.ts: pure, offline parser for BED / bedGraph / GFF3 / GTF / VCF → a uniform feature model, all coords normalized to 1-based inclusive (BED 0-based half-open, VCF POS+REF span, GFF/GTF as-is), attributes → name (GFF Name/gene_name/ID; GTF gene_name/gene_id), grouped by contig (busiest first), 50k-feature cap; plus greedy packRows so overlapping features stay visible. New GenomeView.tsx: an SVG track viewer — bp ruler with 1/2/5×10ⁿ "nice" ticks, features as row-packed rects colored by type (GFF/GTF) or strand via theme-aware --series-* tokens, contig <select>, hover tooltip (name/coords/strand/type/score), drag-to-pan + scroll-to-zoom (cursor-anchored, clamped to the contig), zoom/reset buttons, legend, MAX_ROWS/truncation notes. Wired the previewer registry: artifacts.ts previewKind "genome" for bed/bedgraph/bdg/gff/gff3/gtf/vcf (+REF_EXTS/MIME/EXT_KIND), FilePreviewInspector routes it with a Preview/Code toggle, Rust mime_for serves them as utf8 text so on-disk files reach the viewer. VERIFIED end to end on the packaged app: opened a real GFF3 (gene/mRNA/exon/CDS across chr1+chr2) → tracks rendered with the type legend, gene/mRNA on one row and exons/CDS packed below, contig selector "chr1 (10)", range readout; clicking zoom-in narrowed the view 720–15,280 → 5,379–10,621 and the ruler re-ticked (6k–9k) — interactive zoom confirmed. Debris removed. 124 JS (12 new: 8 parser + 4 view) + 27 Rust tests, tsc, lint, vite build green; installed to /Applications.
2026-07-04 02:35 · Windows robustness fix toward P1-4 (Windows parity). Audited the cross-platform paths since Windows is the one requirement whose acceptance can't be met/verified on this macOS host (needs a Windows runner + code signing — CI already builds NSIS/.msi via the build.yml matrix, and both sidecar fetch scripts correctly emit *-x86_64-pc-windows-msvc.exe). Found and fixed a real Windows-only defect: start_jupyter's orphaned-jupyter-lab cleanup (a crash/force-quit leaves one holding the fixed port; a second instance then wedges — the exact bug the Unix path guards against) was #[cfg(unix)]-only, so Windows had NO cleanup. Extracted it into kill_orphan_jupyter: Unix keeps the proven, path-scoped pkill -f <env>/bin/jupyter-lab (zero behavior change → no macOS regression); Windows now taskkills the recorded PID filtered to IMAGENAME eq python.exe (so a recycled PID on another process is spared). The managed jupyter-lab's PID is now written to jupyter-env/jupyter.pid on spawn for that precise, portable kill. VERIFIED: macOS unix path is a pure refactor (27 Rust tests green, no warnings); the Windows branch typechecks against x86_64-pc-windows-msvc (rustc --emit=metadata on the extracted std-only logic — llvm-rc for a full cross-build isn't on this host, but CI compiles the whole app on windows-latest). Remaining P1-4 gap is inherent to the host: producing + signing the installer and a real first-run on Windows can only happen on a Windows machine/CI.
2026-07-04 02:25 · Package-level environment capture shipped (closes P0-3's last gap — was pip-freeze/lockfile). Every provenance record now captures the installed Python packages, not just python/OS/app version. Design keeps the store small: pip freeze runs ONCE per app run (cached in a OnceLock, same as python_version — a per-write spawn would tax every agent edit), its output is written content-addressed to .openscience/env/<hash>.txt (DefaultHasher's fixed keys → deterministic addressing; identical environments dedupe to one lockfile, only written if absent), and the record's env carries just {count, hash}. New Rust read_env_lockfile(hash) (hash validated hex, can't escape the env dir) + write_lockfile/content_hash; capture_env now takes the workspace root. Frontend: ProvenanceEnv gains packages; ProvenancePanel shows an "N packages" chip that lazy-loads and reveals the full pip-freeze list in a scroll box; the Reproduce prompt now points the agent at the lockfile to reinstall matching versions if a result differs. VERIFIED end to end on the packaged app: a real agent turn wrote demo_pkg.py → its record carried packages {count 664, hash b19b27e709614985}, the 34 KB lockfile appeared at .openscience/env/b19b27e709614985.txt (real freeze: absl-py==2.1.0 … 664 pkgs), and the History panel rendered the "664 packages" chip → click revealed the scrollable list. Debris removed (file, provenance line, lockfile). 112 JS + 27 Rust tests, tsc, lint, vite build green; installed to /Applications.
2026-07-04 02:15 · Local R kernel shipped (closes P0-2's last gap — R was the only not-started P0 item). The notebook now runs Python OR R on a persistent local kernel, keyed by the notebook's kernelspec language. New base-R-only bridge runtime/kernel/kernel_bridge.R (no IRkernel/jsonlite/ZMQ — runs against any installed R, offline): holds one global env across cells, mirrors Jupyter semantics (final visible expression → result, intermediate visible values + cat/message → stdout, warnings inline via options(warn=1)), and emits the SAME JSON response shape as the Python bridge so Rust's read side is shared. kernel.rs generalized to a HashMap of kernels-by-language; request side branches (Python: inline JSON; R: host writes the cell to a code file, then pokes the id — zero escaping on the request). Rscript discovered via absolute-path candidates (incl. /opt/homebrew + /Library/Frameworks/R.framework) so the Finder-minimal-PATH GUI app finds it. Frontend: notebook-file.ts reads/writes the kernelspec (python↔R, code cells stay cell_type "code"); NotebookEditor shows a kernel badge, runs R cells, dispatches per-language; Notebooks page "New notebook" is now a Python/R menu. VERIFIED end to end on the packaged app (installed R 4.6.1 via brew to test): created an R notebook → cell ran cat+summary+sd → correct stdout ("mean is 4"), summary table, and last value [1] 2; a second cell used x from the first (y <- x*10 → [1] 20 40 60, sum(y) → [1] 120), proving cross-cell shared state; a syntax error rendered R's Error: <text>:1:5: unexpected symbol with the caret. Rust r_round_trips_and_keeps_state drives real Rscript over the exact protocol. 111 JS + 26 Rust tests, tsc, lint, vite build green; installed to /Applications. (IME note: pinyin input mangled typed <-/quotes during UI automation — verified via clipboard paste instead.)
2026-07-04 00:55 · Molecule preview is now INTERACTIVE 3D, not a static 2D image (P1-3 redesign, per user + borrowing OpenClaudeScience's 3Dmol.js approach). Replaced the openchemlib→static-SVG renderer with a 3Dmol.js WebGL viewer: drag to rotate, wheel to zoom, stick/sphere/cartoon toggle, reset, live atom count. Expanded formats from 4 to 12 — cif/mcif/mmcif/pdb/mol/mol2/sdf/xyz/pqr/cube all render directly; macromolecules (proteins/crystals, detected via HELIX/SHEET or many Cα) open in cartoon, small molecules in stick. SMILES (.smi/.smiles) has no coordinates, so it is converted to a coordinate-bearing molblock via openchemlib and handed to the same viewer — one code path, no static fallback. molecule.ts is now pure helpers (format map, macromolecule heuristic, default style, smilesToMolblock), unit-tested; the WebGL component is tested with 3Dmol mocked (asserts model+format handed over, style re-applied on toggle). Rust mime_for serves all 12 as utf8 text so on-disk files reach the viewer. 3Dmol + openchemlib both lazy-loaded into their own chunks (out of the main bundle). NOT screenshot-verified on the packaged app (headless WebGL); covered by build + mocked-viewer tests + a real openchemlib coordinate-generation test. 108 JS + 24 Rust tests, tsc, lint, vite build green.
2026-07-04 00:15 · Pre-commit review pass (27-agent high-effort review of the pending work): 10 confirmed defects fixed before the commit. Worst three would have shipped broken features — SDF records with a BLANK title line parsed to 0 atoms (trim() shifted the fixed-position molfile header; delimiter regex now consumes the $$$$ newline instead), on-disk .mol/.sdf/.smi files never rendered (mime_for served them as base64 → viewer text stayed null; now text types), and Reproduce silently overwrote text the user was typing (draft now appends). Also: reproduce prompts fence-escape embedded ``` and flag truncated records; Slurm array/het job ids ('123_[0-15]', '123+0') can be canceled (charset + remote single-quoting); squeue --me → `squeue -u "$USER"` (works on Slurm < 20.02); ssh host-key policy accept-new → strict `yes` with a run-`ssh `-once message (CLAUDE.md safety default: no silent first-contact trust); ssh-config aliases the connect path would reject are no longer suggested (and '+' allowed in hosts); corrupt hpc.json now surfaces a toast instead of a silent empty card + unhandled rejection; hpc-slurm skill's scp recipe used a $REMOTE that never survives across shell calls — replaced with the literal remote dir. 103 JS + 24 Rust tests, tsc, lint green.
2026-07-03 23:05 · Non-bio example project shipped (P1-1's last gap + P0-1's example gap). examples/climate-trends/: the REAL NASA GISTEMP v4 land-ocean global means CSV (12.9 KB, public domain, retrieved 2026-07-03, citation in its README) bundled into the installer as a resource. New Rust install_example (allowlisted names; recursive copy that NEVER overwrites — a re-install keeps user edits, unit-tested) + a 4th workflow starter "Explore an example: climate trends" whose prepare hook installs the files before sending the analysis prompt (install failure → toast, prompt NOT sent). VERIFIED on the packaged app: one click → README+CSV appear in the workspace → the agent (free mimo) loaded the real data (skiprows=1, ***→NaN), ran OLS via scipy, and produced warming_trend.png (190 KB) + report.md with defensible numbers (+0.08 °C/decade full record, +0.21 °C/decade 1975–present, R²=0.90, full decadal table, dataset citation) — a genuinely non-bio, real-data showcase. Testing gotcha: vitest/tinyspy derives an extra promise from a REJECTING vi.fn (its result tracking), reported as an unhandled rejection — mock rejection paths with plain closures instead. E2E debris removed. 90 JS + 23 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 22:55 · Provenance env capture + Reproduce shipped (closes P0-3's two gaps). Every new provenance record now carries env {python version (detected interpreter, cached once per app run), os-arch, app version}; History panel shows it as a chip next to the model. New per-version Reproduce action: drafts a structured prompt (recorded code + env note + "compare regenerated vs current") into the composer of the originating session — prefilled, never auto-sent, human in the loop. Plumbing: composerDraft one-shot field in the ui store, consumed by the Composer on render. VERIFIED on the packaged app end to end: real agent turn wrote demo_env/mean_demo.py → record carried env {python 3.11.7, macos-aarch64, app 0.1.0}; History showed py 3.11.7 · macos-aarch64 · app 0.1.0; clicked Reproduce → composer prefilled → sent → agent re-ran the recorded code, diffed, replied "files match exactly — nothing changed". Old records without env render unchanged (field optional). UI automation note: a concurrent agent on this machine steals focus — clicks now retry until the app process is verifiably frontmost. Debris removed (demo session, files, records). 88 JS + 22 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 22:45 · HPC / Slurm over SSH shipped (P2-2, was the only not-started requirement). Shape: the app drives the user's cluster with the system ssh + their own keys — nothing installed remotely, no credentials of ours. Rust hpc.rs: parse ~/.ssh/config Host aliases (wildcards/comments skipped), probe host (sbatch --version, distinguishes unreachable vs no-Slurm), list (squeue --me, name last so pipes in job names survive) and cancel jobs; host/job-id validated against option/shell injection (unit-tested). Chosen host lives in <workspace>/.openscience/hpc.json — the single source shared with the agent. Settings "Cluster (HPC)" card: connect (datalist of real ssh-config hosts + free text; unreachable hosts are NOT saved), status line (green/amber dot + slurm version), live queue with per-job cancel, Remove. New bundled skill hpc-slurm: read hpc.json (never guess the host), write sbatch script INTO the workspace (provenance picks it up), scp+sbatch, track via squeue→sacct (quote the state, don't assume success), fetch outputs back. VERIFIED end to end on the packaged app against a localhost "cluster" (real sshd + fake sbatch/squeue/scancel/sacct shims): card connect→"slurm 23.11.4"→queue row→cancel (toast + state file emptied over real ssh); then a real agent turn followed the skill mechanically — wrote slurm/os-e2e.sbatch (provenance v1), submitted (job 7744), read the empty output, self-corrected the script (v2), resubmitted (3258), sacct COMPLETED, fetched slurm-3258.out (sum=100) into the workspace and reported the quoted state. All test scaffolding removed after (shims, .zshenv PATH line, authorized_keys entry, workspace/session debris). 87 JS + 22 Rust tests, tsc, lint green; installed to /Applications.
2026-07-04 03:00 · Fixed a session-deadlock bug the user hit (P2-4): the agent's question tool (pick-an-option) and OpenCode permission prompts rendered as blank/stuck rows with NO way to respond — the run just hung, unusable. Wired OpenCode's interactive-request system end to end. SDK: normalize question.asked/question.v2.asked (+ replied/rejected) and permission.asked/.v2. into typed events; new methods answerQuestion/rejectQuestion/replyPermission/listQuestions/listPermissions. IMPORTANT contract discoveries (verified against the live server, not assumed): these are DIRECTORY-scoped GLOBAL endpoints GET/POST /question[/:id/reply|reject] and /permission[...], NOT the /api/session/:id/... variants (those returned empty); reply body is {answers:[[label,…],…]} / {reply:"once"|"always"|"reject"}; and the workspace query param is a wrk_ id — passing a path 500s the server, so send only ?directory=<path>. Store holds pending questions/permissions, updates from live events, and recovers them on session open (an ask can predate connect/reload). New InteractionPrompt card renders above the composer: single-select answers on click, multi-select/custom behind Submit, permission Allow-once/Always/Reject; blank interactive tool rows suppressed from the thread. VERIFIED on the packaged app's real runtime: triggered the question tool → answered via the corrected endpoint → question went running→completed and the agent PROCEEDED with the chosen file (read atlas.csv, began analysis). Live card render not screenshot-verified — a second autonomous agent on this machine kept moving/closing the app window across displays — but the UI is covered by component tests (quick-pick, multi-select, reject, permission) and the wiring is tsc-clean. 83 JS + 17 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 13:10 · Chart design system + command palette + empty-session redesign (P1-5). One validated palette (dataviz standard, checked against the app's real light #ffffff / dark #1e1d24 surfaces) is the single source of truth in three synced places: @ai4s/shared chartPalette (categorical/sequential/status, light+dark), index.css --series-* tokens, and runtime/skills/core/publication-figures/openscience.mplstyle — a first-party skill that makes every agent matplotlib figure publication-grade (2px lines, hairline y-grid, no top/right spines, left title, frameless legend) in the SAME 8 categorical hues as native UI. VERIFIED: rendered a 4-series figure with the style → exact palette (#2a78d6/#1baf7a/#eda100/#008300), clean chrome. Command palette rewritten so all 7 items are real actions (was 3 dead ones that just closed): new session, analyze-data + audit-report workflows (send the starter prompts), notebooks, skills, settings, theme — verified all visible in the packaged app. Course-correction on user feedback: my first cut put a Sessions/Notebooks/Skills stat-tile row on the empty session — vanity metrics that dominated the view; removed it (and the speculative StatTile/WorkspaceGlance) and redesigned the empty session as a quiet, centered welcome (serif headline, one refined 3-row starter card) — screenshot-confirmed. Dark mode is token-only (no hardcoded color), structurally assured. NOTE: the app got moved to a second display mid-verification; combined with a second autonomous agent on this machine, UI automation is unreliable here — a blind keystroke once landed in another app's browser form (no submit; harmless but noted) — so I verify via window-scoped capture when possible and structure otherwise. 78 JS + 17 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 12:15 · Curated open-source science connectors, one-click (P1-2). Rather than reimplement literature/DB access, we one-click provision EXISTING open-source MCP servers into a shared isolated env via the bundled uv, then register them — same shape as the Jupyter integration. New Rust science_mcp (setup_science_mcp installs one pip package into runtime/science-mcp-env and returns the managed python; science_mcp_python reports it; package name validated against arg/shell injection, unit-tested). Curated catalog lib/scienceConnectors.ts: paper-search-mcp (arXiv/PubMed/Crossref/Semantic Scholar/bioRxiv) and biomcp (PubMed/ClinicalTrials/variants), launched as python -m <module>. Settings MCP card shows them as "OPEN SOURCE" rows with source repo + Enable. New docs/CONNECT_YOUR_TOOLS.md (BYO MCP local/remote + minimal fastmcp example + skill install + safety). VERIFIED on the packaged app's real runtime: provisioned paper-search-mcp through the bundled uv into the app env → registered via the live config API → OpenCode reports it connected → drove a real agent turn: paper-search_search_arxiv returned 3 papers with real arXiv ids (incl. 2207.07410 AlphaFold-knots), exactly the auditable identifiers traceability-review consumes. (UI Enable button is the same enable path as Jupyter, tsc-checked; verified the underlying provision+register+connect chain directly because a second autonomous agent sharing this machine made window screenshots unreliable.) Verification debris removed (config entry, env, test sessions). 77 JS + 17 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 11:55 · First-boot reliability fix (P2-4): connectRetry window raised 30×0.5s → 120×1s. Root cause observed twice today: on a fresh install (or any re-signed rebuild) macOS TCC's "access Documents" consent blocks the sidecar inside getcwd until the user answers, so the webview's ~15–30 s retry budget expired and stranded users on an error screen a single manual Connect would fix. Verified on the packaged app: relaunch → TCC allowed → auto-connected in ~9 s with no manual Connect. 77 JS + 15 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 11:50 · One-click full workflows shipped (P0-1). The empty live session now shows three starter cards (components/thread/WorkflowStarters): "Demo: analysis end to end" (simulate → fit → figure → report, numbers traced to code), "Analyze my data", "Audit a report" (routes into traceability-review). One click sends the complete-workflow prompt. VERIFIED on the packaged app: single click → 65 s → run_analysis.py + figure1.png (real 4-param logistic fit with 95% CI band, R²=0.985) + report.md (numbers from code output) + summary_stats.json, all as clickable artifact chips with provenance records; figure renders natively in the right pane. Also re-observed the boot race (P2-4): webview's 30×connectRetry gives up while a TCC-delayed sidecar is still booting — one manual Connect fixes it; a longer/smarter retry is the natural next reliability fix. 77 JS + 15 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 11:40 · Plain-language data-flow disclosure shipped (closes P0-2's statement gap + P2-3). New "Privacy & data flow" card in Settings (components/settings/DataFlowCard): two columns — stays local (workspace files with real path, local kernel/Jupyter execution, session history + provenance, credentials in an app-private mode-600 file) vs. sent to the provider (messages + task-relevant file/command content, only during a turn, provider policy governs retention) + a skills/MCP third-party note; model chip is live (verified both "no model configured" and opencode/mimo-v2.5-free states on the packaged app). Copy promises scope, not guarantees — unit test asserts no "no errors / zero hallucination" phrasing. IMPORTANT correction while verifying: provider credentials are NOT in the OS keychain — OpenCode stores them in app-private auth.json (mode 600); REQUIREMENTS P2-3 status fixed, keychain migration recorded as open. 75 JS + 15 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 11:35 · Reviewer's three traceable checks shipped (P0-4). New first-party skill runtime/skills/core/traceability-review (bundled as the skills-core/ resource; deploy_bundled_skills now syncs both packs and skips SKILL.md-less placeholder dirs): citation audit via Crossref/arXiv/PubMed public APIs, untraceable-number flagging, and figure↔code staleness read from provenance.jsonl — output is the existing ```review contract, now with a check field (citation|number|figure). UI: ReviewerCard findings carry a check-type tag and are dismissible one by one (session-local; "N dismissed" in the header, "All findings dismissed." when empty). VERIFIED end to end on the packaged app with a fixture report (fake DOI + unsourced 37.4% + figure older than its code): the free mimo model followed the skill mechanically (Loaded skill → curl Crossref 404 → stat mtimes) and the card rendered exactly the three acceptance findings with correct levels/tags; dismiss click → "2 findings · 1 dismissed". Observed: the model used the mtime fallback because it claimed the hidden `.openscience/provenance.jsonl` didn't exist — skill now tells it to cat the file directly. Note copy never claims "no errors". 73 JS + 15 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 11:20 · Artifact provenance shipped (P0-3, the #1 gap): every successful agent write now appends a version record to <workspace>/.openscience/provenance.jsonl (workspace-relative path, version, timestamp, tool, sessionId, model, written content capped at 100 KB, log) via new Rust record_provenance/list_provenance (append-only, mutex-serialized, corrupt lines skipped, path-escape rejected); recording happens on live tool.updated events (deduped per callId; jupyter reads excluded, mutations included) — note it is live-only: turns that stream while the webview is still connecting are not backfilled, and failures now go to debug.log instead of vanishing. UI: FilePreviewInspector AND NotebookEditor gained a History toggle → ProvenancePanel (newest first, latest expanded: code, model chip, log, "Open conversation" → /live/:sessionId); artifact cards are now fully row-clickable. VERIFIED end-to-end on the packaged app: three API-driven real agent turns produced v1/v2 (two writes, one turn) and v3 (edit from a second session, correct cross-session increment); History panel screenshot-confirmed on the final build. Found along the way: rebuilt (re-signed) apps re-trigger macOS TCC "Documents" consent, and the pending dialog wedges the sidecar in getcwd — looks exactly like the app hanging; also open -a "Open Science" can launch the repo-target bundle instead of /Applications (use the explicit path). 72 JS + 15 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 09:40 · Live notebook UX fixes from the user's first real Jupyter-MCP run (4 real bugs). (1) Tool rows went blank on completion: finished MCP parts report title "" and ?? tool doesn't catch empty strings — now title || tool, unit-tested. (2) No live panel while the agent drives a notebook: jupyter_* tools now derive a notebook artifact from their notebook_path, and the session auto-opens that notebook in the right pane (once per path, manual close respected); NotebookEditor polls the file every 2 s while idle so the agent's cell inserts/executions appear live (own saves excluded via raw-text compare). (3) Figures were invisible: image/png outputs now parse into NotebookCell.image, render as , and round-trip through serialize. (4) Duplicate jupyter-lab after restart: app quit left an orphan; the fixed port then wedged and all MCP calls timed out — start_jupyter now pkills the env's stale jupyter-lab first. Verified on the packaged app via an API-driven real agent turn + window-scoped screenshot (no focus stealing): tool names persist, right pane auto-opens, scatter figure renders. 65 JS + 12 Rust tests, tsc, lint green.
2026-07-03 08:50 · Jupyter MCP integrated, one-click, in the installer (user ask). Best-practice shape: bundle uv as a second sidecar (fetch-uv.sh pinned 0.11.26, +~20 MB dmg → 67 MB) instead of bundling Python; "Set up & enable" in the MCP card provisions an ISOLATED env in app data (uv venv + pinned jupyterlab 4.4.1 / jupyter-collaboration 4.0.2 / jupyter-mcp-server / ipykernel — ~361 MB, user's system untouched), the app manages a headless jupyter-lab (new jupyter.rs: stable port+token persisted in server.json so the MCP entry survives restarts; root_dir = workspace so agent and Notebooks page share files; killed on exit; auto-restarts on launch via ensureJupyter), and the MCP entry (JUPYTER_URL/TOKEN/ALLOW_IMG_OUTPUT) is written into OpenCode's config. VERIFIED end-to-end on the packaged app: clicked the real button → env provisioned → jupyter-lab up → GET /mcp reports jupyter "connected" — the agent now has notebook tools. Placement note: MCP in Settings matches Claude Desktop (Connectors), Cursor (Settings→MCP), Goose (Settings→Extensions). 62 JS + 12 Rust tests, tsc, lint green.
2026-07-03 08:20 · MCP server management (user ask: skills + MCP support). New "MCP servers" card in Settings: rows show name, local/remote type, LIVE status (connected/failed/disabled dot from GET /mcp) and the command/URL; add form (name, type, command-or-URL) writes via PATCH /global/config (applies live, verified); Remove goes through the generalized remove_config_entry(section, key) Rust command (replaces remove_custom_provider; allowlist provider|mcp; pure JSON transform tested) + sidecar restart — full add/remove loop E2E-verified on the packaged app (clicked Remove in the real UI, config file confirmed cleaned). SDK: listMcpServers (status ⋈ config), addMcpServer, McpConfig/McpServer types. Skills were already manageable (live list + install-with-agent on the Skills page); fixed its stale .opencode/skill/ prose to skills/. 62 JS + 12 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 08:00 · Settings visual redesign (user: elements looked bad). Rebuilt the page as a consistent card system in the app's paper aesthetic: serif-titled cards with hairline-divided headers (Agent runtime / Model / Workspace / Appearance); one shared control kit (inputCls/btnGhost/btnAccent — every input and button h-9, 13px); providers as a single bordered list container (Zen gets an uppercase ring badge, connect-search row with inline icon on a tinted band, custom endpoint as a chevron disclosure row); dashed borders eliminated; select got a custom chevron; Appearance became a segmented control; runtime status line now shows the active model inline; import-CLI-login is an icon'd tertiary action. No logic changes. 62 JS + 12 Rust tests, tsc, lint green; installed + screenshot-verified.
2026-07-03 07:45 · Custom endpoints + provider-row semantics. (1) New "Custom endpoint" form in Settings (name, OpenAI-/Anthropic-compatible style, base URL, optional key, model ids) for self-hosted gateways and LOCAL Ollama (catalog only has ollama-cloud); writes provider config via PATCH /global/config (verified live: models selectable immediately, no restart); removal via new remove_custom_provider Rust command (edits opencode.jsonc + restarts sidecar; pure JSON transform unit-tested). Noted: Kimi coding plan (kimi-for-coding), DeepSeek, zhipu/minimax coding plans, LM Studio were ALREADY in the 150-provider catalog — searchable directly. (2) Fixed provider-row action semantics (user report): OpenCode Zen is built-in with no stored credentials — its dead "Disconnect" replaced with a "Built-in · free" label; other providers' action renamed Remove (credentials/config) to stop colliding with the runtime's Disconnect. 62 JS + 12 Rust tests, tsc, lint green; installed + screenshot-verified.
2026-07-03 07:25 · Settings provider fixes + workspace rename (3 user questions). (1) The connect list was wrongly sourced from GET /provider/auth (only 10 special-flow providers — Anthropic wasn't connectable); now a searchable box over the FULL catalog GET /provider (~150 providers, datalist autocomplete). Every provider gets a generic API-key row (PUT /auth works universally, placeholder shows its env var); special OAuth flows (incl. Copilot-style extra prompts — select/text inputs now supported and passed as authorize inputs) stack on top. (2) Clarified: the "MiMo V2.5 Free" default model was set by ME while verifying PATCH /global/config — not a prior user config; left in place as a working out-of-box default. (3) Workspace renamed ~/Documents/"Open Science" → ~/Documents/OpenScience (agent shell commands break on unquoted spaces); migration chain: OpenScience > rename "Open Science" > rename old app-data dir; verified live — files moved, old dir gone, sidecar cwd = new path. 62 JS + 11 Rust tests, tsc, lint green; installed to /Applications, search box verified by screenshot.
2026-07-03 07:05 · Removed the Review button from the artifact inspector (user: unnecessary). The ```review → ReviewerCard rendering stays — reviews surface when the agent produces them (skills / chat), not via a dedicated button; unused reviewPrompt deleted. 62 JS tests, tsc, lint green; rebuilt + installed.
2026-07-03 06:50 · Unified model configuration to ONE OpenCode-native path (user: configure OpenCode itself, not raw model keys). Settings rewritten around the sidecar's own API: Default model = grouped dropdown from GET /config/providers (includes OpenCode Zen free models — works out of the box), saved via PATCH /global/config (verified live: applies without restart); Providers = connected list with Disconnect (DELETE /auth) + connect flow per provider from GET /provider/auth (API key → PUT /auth/{id}; OAuth → authorize → system browser → paste code → callback); silent seed_auth copy REMOVED from sidecar spawn — now an explicit "Import OpenCode CLI login" button (new import_opencode_login command, restarts sidecar). Removed the old provider/key/baseURL form and two fake controls (workspace path input → real path + Reveal; Docker backend select). Sidebar Model pill is now real (shows the configured model name from /config). SDK: 8 new client methods + mock routes. Old configure_opencode Rust command kept but no longer wired to UI (candidate for an Advanced custom-endpoint flow or deletion). 62 JS + 11 Rust tests, tsc, lint green; installed to /Applications, verified by screenshot (model dropdown, Zen provider row, workspace path all live).
2026-07-03 06:35 · Conversation-first notebook UX (user's design: chat is the main surface, the agent drives notebooks, session ↔ notebook mapping visible). NotebookEditor extracted into a shared component (components/notebook/) with a new Reload button to pick up the agent's file edits. (1) .ipynb artifacts now open in the RUNNABLE notebook editor in the right pane next to the conversation (new "notebook-file" inspector variant) instead of raw-JSON preview. (2) Session header shows chips for every notebook the agent touched in that session (deduped artifact blocks) — click opens it beside the chat; active one highlighted. Notebooks page stays as the global library. Kernel-level agent control still pends the jupyter-server + jupyter-mcp route (PROGRESS 05:00 note). 62 JS + 11 Rust tests, tsc, lint green; installed to /Applications, editor re-verified (previous run's output loads back from file).
2026-07-03 05:50 · Closed two Claude-Science gaps: Notebooks entry + real Reviewer cards. (1) New sidebar "Notebooks" page: lists workspace .ipynb (new Rust list_notebooks), create/open/edit/delete cells, Shift/⌘+Enter runs on the real local Python kernel, debounced autosave writes valid nbformat back (new write_workspace_file, sandboxed) — the agent works on the same files. Verified END-TO-END by UI automation on the packaged app: clicked through sidebar → notebook → ran a cell → output "42" rendered AND persisted into the .ipynb. Two real bugs found+fixed by that E2E: .ipynb missed the text-MIME map (read came back base64 → "could not read"), and save-in-handler raced setState (persisted "running…" instead of the result) — now a debounced autosave effect. (2) Reviewer: "Review" button on the live file inspector sends a structured review prompt (uses integrity-auditor skill); a ```review fenced JSON block in the agent's reply is parsed (splitReview) into the existing ReviewerCard — streaming-safe, malformed JSON left as text. Not done (next): versioned artifact tabs with real per-version data (#1 of the gap table). 61 JS + 11 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 05:00 · Three fixes from user feedback. (1) History delete now confirms via a new in-app ConfirmDialog (window.confirm is unreliable in the webview); examples confirm as "Hide". (2) Workspace moved from the hidden app-data dir to the OS documents folder (~/Documents/Open Science, Windows/Linux via Tauri document_dir with $HOME/Documents fallback) — user-facing files belong in user-visible places; one-time migration renames the old dir (falls back to keeping the old location if rename fails), verified live: files moved, skills still listed, sessions intact. (3) Root-caused "no Python environment": Finder-launched apps get a minimal PATH so the agent couldn't see conda/Homebrew — sidecar now gets an enriched PATH (existing anaconda/miniconda/pyenv/homebrew/.local dirs prepended); verified by launching the packaged app with env -i and confirming /opt/anaconda3/bin in the sidecar's PATH. Bundling a managed Python/R runtime (micromamba) is a pending product decision. 56 JS + 11 Rust tests, tsc, lint green; installed to /Applications.
2026-07-03 04:30 · Composer attachment UX rework (3 user items). (1) Adding files is now silent on success — no toast. (2) Attached files render as removable chips (paperclip + name + X, surface-2 background) above the input instead of plain text in it — can't be mangled while editing; on send they become a "Files added to the workspace: …" note appended to the prompt, chips clear after. (3) Input is now an auto-growing textarea (caps at 160px then scrolls; Shift+Enter for newline), and a paste >2000 chars or >25 lines is auto-saved to the workspace as pasted.txt (new add_text_to_workspace Rust command, collision-safe) and becomes a chip instead of flooding the box. 56 JS (3 new mocked-bridge attach tests) + 11 Rust tests, tsc, lint green; rebuilt + installed to /Applications.
2026-07-03 03:50 · Composer fixes from user feedback (3 items). (1) Removed the divider line above the composer (live + example pages). (2) Fixed pinyin IME: Enter during composition was sending the message instead of picking the candidate — keydown now ignores isComposing / legacy keyCode 229 (WebKit), covered by a new Composer test. (3) Added a real "Add files" paperclip button (desktop only): native open dialog → files copied into the agent workspace (collision-safe naming name-1.ext, unit-tested) → names inserted into the prompt + success toast. New Rust command add_files_to_workspace. 53 JS + 11 Rust tests, tsc, lint green; rebuilt + installed to /Applications, verified by screenshot.
2026-07-03 03:40 · UI refinement pass from user feedback (5 items). Sidebar 272→232px; logo/brand shrunk to one compact 17px line with inline BETA; session header slimmed (18px→13px title, py-4→py-2.5) and its New/Disconnect buttons removed (New lives in the sidebar; users never need Disconnect — Connect still appears when offline); composer reduced to a single input+send row; Settings/Skills titles 2xl→xl. New: external http(s) links now open in the system browser (global click interceptor in AppShell + open_url Rust command) — previously a clicked link navigated the webview away with no way back. 51 JS + 10 Rust tests, tsc, lint green; rebuilt aarch64 .app/.dmg, installed to /Applications, verified by screenshot.
2026-07-03 03:20 · Fixed three UI polish bugs from user testing. (1) Downloads now use a native "Save As" dialog (new tauri-plugin-dialog + async save_text_file Rust command; browser dev falls back to Blob) with a new bottom-center toast for saved/failed feedback (silent on cancel) — wired into ArtifactInspector + FigureBlock; removed PdfInspector's dead Download button. (2) Removed the composer's three fake buttons (Plus/Tools-grid/Mic — all were no-ops). (3) Fixed the mismatched titlebar strip: macOS window now uses titleBarStyle Overlay + hiddenTitle, sidebar reserves a draggable 32px strip under the traffic lights — window background is now one continuous surface (verified by screenshot on the packaged app). Installed the new build to /Applications (old instance quit; session history intact). 51 JS + 10 Rust tests, tsc, lint, aarch64 .app/.dmg green.
2026-07-03 03:10 · Bundled the ai4s-skills pack (7 scientific skills: research-explorer/literature-survey/experiment-suite/paper-writer/integrity-auditor/mindmap-render/ai4s-agent) into the one-click installer. scripts/dev/fetch-skills.sh pins ai4s-research/ai4s-skills@8fa2ab0 into git-ignored runtime/skills/external/ (wired into CI); tauri.conf bundles it as a resource; on every sidecar start runtime.rs::deploy_bundled_skills syncs it into the app-private profile's global skills dir (xdg-config/opencode/skills/ — the workspace .opencode/skills/ stays for user installs). Found+fixed along the way: OpenCode skill instances are lazy per-directory, so bare GET /api/skill can return [] — SDK now sends ?directory=<workspace> (new workspace_path Tauri command) and loadCatalog retries once; fixed the install-skill prompt's wrong .opencode/skill/ (singular) path. Verified end-to-end on the built .app: 7 skills deployed to the private profile and listed by the sidecar's /api/skill. 49 JS + 10 Rust tests (2 new for skill sync), tsc, lint, aarch64 .app/.dmg (46 MB) green. Known issue (pre-existing): sidecars orphan when the app is killed instead of quit — found 6 stale opencode serve processes from earlier runs (cleaned up).
2026-07-03 02:20 · Published to GitHub: https://github.com/ai4s-research/open-science (public, master). Two commits: accumulated feature work (previews/artifact resolution/kernel) + the Open Science rebrand. Secret scan on pending changes came back clean; sidecar binaries and installers stay git-ignored.
2026-07-03 02:10 · Rebranded to "Open Science" (slogan: "An open AI workbench for scientists — Your research partner for rigorous science"). README now opens with the banner (docs/assets/banner.webp) + new title/slogan; sidebar shows the cloud logo (src/assets/logo.webp) left of "Open Science"; productName/window title/index.html title renamed — bundles are now "Open Science.app" / "Open Science_0.1.0_aarch64.dmg". Deliberately unchanged: bundle identifier com.ai4s.workbench (renaming it would orphan the existing Application Support workspace/data) and internal @ai4s/* package names. AGENTS.md records the branding rule. 49 JS tests, tsc, packaged build green.
2026-07-03 01:55 · Set the app icon from the user's cloud/terminal artwork (webp, 1254px, transparent corners verified, content plate ~82% of canvas — matches macOS icon proportions). Converted to 1024px PNG and ran tauri icon: regenerated icon.icns (Dock), icon.ico (Windows), and all png/Android/Windows-store sizes in src-tauri/icons/; repacked aarch64 .app/.dmg with the new icns embedded. Note: Finder/Dock may show a cached old icon until the app is moved/relaunched.
2026-07-03 01:35 · Fixed messy Office preview styling (user: WPS looks far better). Root cause was mostly OUR CSS, not the renderers: Tailwind preflight resets (list-style, margins, img display/height) hit elements the renderers leave to browser defaults, and the app theme's inherited font/color (light text in dark mode) bled into document content expecting black-on-white. Fix: all three previews now render inside a Shadow DOM (document stylesheets can't cross it) with an explicit base reset — black text, CJK-aware font stack, neutral backdrop; docx-preview's own page chrome (white sheet on gray) now applies cleanly, pptx slides get centered cards with shadows. xlsx switched from our first-row-header grid to SheetJS sheet_to_html (merged cells → rowspan/colspan, gridline look like WPS; 500×50 cap preserved, escaping verified by test). Found+fixed along the way: useShadowPage used useRef+useEffect so a late-mounted host (XlsxView after data load) never got its shadow root — now a callback ref. Verified by mounting the real components with the user's actual docx/xlsx/pptx in jsdom (content lands inside shadow root, tables/merges present); 49 JS + 8 Rust tests, tsc, build green; repacked aarch64 .app/.dmg. Remaining gap vs WPS is inherent: font substitution (no embedded-font rendering) and renderer approximations.
2026-07-03 00:30 · Added inline docx/xlsx/pptx preview (was: "Open in the default app" only; user expects Codex-desktop-like inline viewing). All local, no conversion service, each renderer dynamic-imported into its own lazy chunk: docx → docx-preview 0.3.7 (HTML render); xlsx → SheetJS 0.20.3 from the official CDN tarball (npm's 0.18.5 is stale + ReDoS advisory) with sheet tabs + capped grid (500×50, first row as header, "charts not rendered" note); pptx → pptx-preview 1.0.7 (slide list, bundles echarts so pptx charts render). Chose SheetJS over exceljs after exceljs crashed on the real user file: workbooks containing charts hit "Cannot read properties of undefined (reading 'anchors')" in reconcile — SheetJS ignores drawings. New previewKind values docx/xlsx/pptx replace last night's "external"; FilePreviewInspector reads binary artifacts as base64 → ArrayBuffer; TablePreview extracted for csv/xlsx reuse. Verified against the user's actual canvas-project files in jsdom: docx renders DOM, pptx reports 5 slides, xlsx grids 3 sheets (chart file included); 48 JS + 8 Rust tests, tsc, vite build green; repacked aarch64 .app/.dmg. Caveat: jsdom needed same-realm bytes for jszip instanceof checks — webview unaffected. Pending: visual check in the packaged app.
2026-07-03 00:10 · Fixed file chips: only 2 of 5 generated files were clickable and index.html previewed as "not found" while canvas.pdf worked. Root causes: (a) REF_EXTS lacked docx/xlsx/pptx so Office files never became chips; (b) refs extracted from prose (bare filenames like index.html) were resolved verbatim against the workspace root — the file actually lives in canvas-project/; the pdf only worked because a stale copy sat at the root. Fixes: new Rust resolve_artifact (literal path if it exists, else bounded basename search — skips hidden/node_modules/pycache, ≤10k entries/depth 8, newest mtime wins on duplicates); AgentMessage now resolves every mention and only renders chips for files that exist, carrying the resolved path; docx/xlsx/pptx added to ref/MIME maps on both sides with a new "external" preview kind (note + "Open in the default app" button — no inline viewer for Office formats). Verified: repro test with the exact agent message extracts all 5 files; 47 JS + 8 Rust tests green (new: locate literal/bare/missing/duplicate); tsc + vite build clean. Pending: restart the app to pick up the new Rust command. · Fixed PDF preview not rendering in the packaged app (WKWebView): silent blank, no error, while "Open externally" worked. Two macOS WKWebView gaps: (a) pdf.js 6 calls Promise.withResolvers() (Safari 17.4+) — added a polyfill in main.tsx; (b) loading the worker as a .mjs module over tauri:// via workerSrc failed silently — switched to Vite's ?worker import + GlobalWorkerOptions.workerPort, so Vite bundles/instantiates it as a CLASSIC worker (new Worker(url), no type:module) that WKWebView runs reliably. Re-verified render in Chrome (no regression); rebuilt aarch64 .app/.dmg. Pending user confirmation it now renders in the WKWebView build.
2026-07-02 23:20 · Made execution-produced files first-class previewable artifacts (fixes two test findings: HTML showed code but no render; a PDF produced by running python was un-clickable prose only). Root cause: deriveArtifact only caught write/edit tool calls, so files created as a side effect of bash/code runs never surfaced. Added: (1) extractArtifactRefs() — pulls workspace file paths out of agent prose (strips backticks/quotes, ignores URLs) so canvas-project/canvas.pdf becomes a clickable chip under the message; (2) Rust read_artifact (workspace-sandboxed: canonicalize + starts_with check, 25 MB cap, text→utf8 / binary→base64 via a std-only encoder) and open_path (OS opener, per-OS); (3) new FilePreviewInspector that renders by type — HTML → live <iframe srcdoc sandbox> with a Preview/Code toggle, PDF → pdf.js (pdfjs-dist, worker bundled as an asset; packaged app has csp:null so no restriction), image → <img> (svg/png/jpg/gif/webp), text → CodeViewer — loading bytes lazily from disk or using inline write-tool content. Also fixed the kernel to run in the agent's workspace (current_dir) so notebook code sees the same files. Verified in Chrome with a real 39 KB matplotlib month-night PDF: pdf.js rendered all layers; HTML srcdoc executed canvas JS live. JS typecheck/lint/40 tests (new: extractArtifactRefs/previewKind/refToArtifactBlock) + 5 Rust tests (new: base64 vectors) green; rebuilt aarch64 .app/.dmg (45 MB), confirmed pdf.worker asset + read_artifact/open_path embedded.
2026-07-02 22:15 · Made the notebook a REAL local Python kernel (was: dispatch-to-agent stub) — the honest local-first answer to "can't this run locally". Added runtime/kernel/kernel_bridge.py: a stdlib-only persistent process speaking line-delimited JSON ({id,code}→{ok,stdout,result,error}), one shared namespace across cells, Jupyter-style last-expression value, tracebacks on error. Rust kernel.rs embeds the bridge (include_str!), spawns it (cross-platform Python detection: py/python + %USERPROFILE% dirs on Windows, python3 + /opt/anaconda3 etc. on mac/Linux — GUI apps launched from Finder/Explorer have a minimal PATH), and does synchronous write-line/read-line over piped stdio; commands kernel_execute/kernel_reset registered + killed on exit. Frontend lib/kernel.ts + NotebookInspector run cells on the real kernel in the desktop app (async, "running…" → real output), falling back to the agent/hint in browser dev. Verified: bridge driven directly (2+2→4, state persists across cells x*10, 1/0→traceback); a Rust integration test drives the real bridge over the same protocol (4 Rust tests green); JS typecheck/lint/35 tests/build green. Note: remote-cluster timers, real PDF (pdf.js + local pdflatex/xelatex — both present), and figure-byte rendering (Tauri file read) are all equally local-capable and remain as follow-ups.
2026-07-02 21:50 · Closed the gap between the three Claude-Science reference shots (live workbench) and our build (static mock + bare live chat) by making the workbench interactive and wiring the live agent to it. (1) Live artifact surfacing: the SDK was dropping tool state.input/output; now it captures them, a pure deriveArtifact classifies file-writing tool calls (figure/script/report/table/notebook by extension), and foldEvent/historyToThread surface them as deduped ArtifactCard blocks openable in the live inspector — a real agent session now shows the files it produces instead of only text+tool rows. (2) Figure annotation authoring (shot 1): click a figure to drop a numbered pin, write a note, Send → forwarded to the agent as a follow-up prompt (coords clamped to bounds); multiple pins. (3) Artifact version switching + downloads (shot 1): per-version code/log/review; prev/next chevrons + pill switch content; Download writes a real Blob. (4) Notebook expression input (shot 2): the "Type an expression and press Enter" line is now a working input that appends a cell and dispatches to the agent's kernel. Verified in a real browser: v1↔v2 switches code and the Review check, annotation Send box appears, notebook input renders. typecheck/lint/35 JS tests (new: artifacts + foldEvent-artifact + figure-authoring)/build all green.
2026-07-02 11:10 · Bundled OpenCode as a Tauri sidecar (one-click install) with full isolation from any user-installed OpenCode. Rust runtime module spawns the bundled binary on a dedicated free port, with an app-private XDG config/data dir (~/Library/Application Support/com.ai4s.workbench/runtime/), --cors "*", and kills it on exit; configure_opencode writes the key into that private config and restarts the sidecar. Frontend auto-starts + connects on launch (browser dev still uses a manual server). Sidecar binary is git-ignored + fetched by scripts/dev/fetch-opencode.sh (wired into CI). Verified end-to-end on the built .app: bundled opencode runs from inside the bundle on a random port (e.g. 54229) while a user's own opencode serve on 4096 keeps running untouched; global ~/.config/opencode unchanged; app auto-creates sessions on its bundled runtime. dmg 44 MB. Rust 3 tests + JS 24 tests + typecheck/lint/build green.
2026-07-02 10:05 · Wired the in-app API key into OpenCode (closes the "usable" gap): Tauri Rust command configure_opencode merges provider key + model + baseURL into ~/.config/opencode/opencode.json (3 Rust unit tests). Settings "Save" calls it (browser falls back with guidance). Verified end-to-end at the OpenCode level: writing that config flipped OpenCode's error from "Model not found" to "invalid x-api-key" — i.e. it now reads the config, resolves the model, and uses the key; a valid BYOK key completes a real turn. typecheck/lint/24 JS tests/3 Rust tests/build all green.
2026-07-02 09:35 · Switched agent runtime Hermes → OpenCode (pinned v1.17.13). Rewrote packages/sdk to the real OpenCode HTTP+SSE protocol (OpenCodeClient: POST /session, POST /session/:id/prompt_async, GET /event SSE; normalizes message.part.updated text/tool + session.idle/error). Installed the real OpenCode binary, ran opencode serve, and drove the actual app against it: verified GET /event 200, POST /session 200, live "OpenCode · ready", prompt sent (204), streamed events rendered. Found+fixed two real bugs while driving it: (a) unbound fetch → "Illegal invocation" in browser; (b) session.error message nested at error.data.message. Labeled the three mock sessions honestly as "Examples"; live agent work is the OpenCode-backed "New" session. Full agent turn still needs a configured model (opencode auth login or a provider key) — currently surfaces the real "Model not found" error. typecheck/lint/24 tests/build all green.
2026-07-02 09:00 · Added the Tauri 2 desktop shell (chosen architecture: bundle Hermes as a sidecar for one-click install). Installed Rust 1.96; scaffolded apps/desktop/src-tauri (Cargo/tauri.conf/main/lib + shell plugin), generated app icons, and built a real macOS bundle: AI4S Workbench.app + AI4S Workbench_0.1.0_aarch64.dmg (5.0 MB, shell only — Hermes not yet bundled). Verified the .app launches and runs the UI. Added GitHub Actions matrix (macOS aarch64/x86_64 + Windows x86_64) to produce .dmg and NSIS/.msi installers — Windows must be built in CI. Confirmed real Hermes uses an OpenAI-compatible API server (hermes gateway, 127.0.0.1:8642, Bearer API_SERVER_KEY, streams chat.completion.chunk + hermes.tool.progress) and owns provider keys; SDK protocol still to be re-pointed from the placeholder gateway to this.
2026-07-02 08:20 · Slice #2 (Hermes integration layer): packages/sdk HermesClient speaks the Hermes TUI Gateway JSON-RPC contract over WebSocket (session.create/prompt, streamed message.delta/tool.start/tool.complete/approval.request/session.done), transport injectable. Wired into the desktop: live /live session folds streamed events into thread blocks, real runtime status in the sidebar + Settings Gateway URL + Connect. Proven against a protocol-compatible mock gateway both in an integration test and live in the browser UI (connect → prompt → streamed tool + text + done). 24 vitest tests, typecheck/lint/build green. Note: a real agent turn still needs the actual Hermes binary + a model API key (kept empty per requirement).
2026-07-02 08:05 · Slice #1 (UI shell + static workspace) built browser-first: pnpm workspace + Vite/React/TS/Tailwind/Radix. Three-column Claude-Science-style UI (sessions sidebar / thread / contextual inspector) with warm paper theme, reproducing all three reference screenshots (figure+artifact, table+notebook, literature+PDF) from mock data. Settings has fillable, empty API-key fields with Hermes as runtime. Verified on Mac: typecheck ✓, eslint ✓, 19 vitest tests ✓, vite build ✓; screenshots confirmed against references.
2026-07-02 07:04 · Initialized project skeleton: monorepo directory tree (apps / packages / runtime / docs / examples / scripts), AGENTS.md + CLAUDE.md symlink, README, MIT LICENSE, .gitignore, and English PRD + TECHNICAL_DESIGN docs. No build tooling yet.