What's New

Release notes for K2SO. Updates are published to GitHub.

v0.36.13

Latest
April 30, 2026

# K2SO 0.36.13 — Pinned chat tab: daemon-first, CLI-reachable, refresh-able

The workspace's pinned chat tab — the persistent Claude session you talk to when you click the agent's tab — was not playing well with the daemon. Three things were off, all visible to anyone using k2so msg from another terminal:

1. The chat tab registered its v2 PTY under a renderer-only key (tab-agent-chat::), invisible to anything addressing the workspace agent by its actual name.

2. The awareness bus's liveness check (is_agent_live) only walked the legacy session::registry, so v2-only sessions looked offline → every Live signal hit the wake provider instead of injecting → duplicate sessions, missed messages.

3. Even when the inject path *did* find the session, it wrote the body and the \r (Enter) in one syscall — TUI input widgets like Claude Code's read that as a multi-line paste and the message landed typed-but-not-sent.

This release fixes all three plus adds a refresh button on the chat tab so you can recover when the underlying Claude process exits (/exit, crash, etc.) without quitting the whole app.

What changed

Chat tab attaches to the workspace agent's canonical session

AgentChatPane now passes attachAgentName= to TerminalPane, so /cli/sessions/v2/spawn registers under the agent's real name (e.g. manager, pod-leader). On mount, the tab also queries a new daemon route to detect "is this agent already running headless?" — letting it attach to a live PTY across Tauri quit/reopen instead of orphaning it and spawning a duplicate. Same architectural shape as 0.36.11's heartbeat surfacing, applied to the persistent chat surface.

Concrete consequences:

-k2so msg --wake "..." reaches the chat tab's PTY. (Previously routed to a different session keyed under the renderer's tab UUID.)
-Quit Tauri → daemon keeps the PTY alive → reopen Tauri → tab re-attaches to the same conversation. (Previously: blank Claude session each time.)
-Heartbeat-spawned auto-launches and chat-tab spawns converge on one PTY per workspace agent (previously: two parallel sessions with no shared state).

`is_agent_live` no longer blind to v2 sessions

k2so_core::awareness::egress::is_agent_live used to walk only the legacy session::registry — a holdover from the pre-A8 days when every session was Kessel-T0. Post-A8, every system-driven session is v2 and lives in v2_session_map (which the registry doesn't see), so every Live signal sent to a v2-only agent treated it as offline and routed straight to the wake provider, bypassing inject entirely.

The InjectProvider trait now has an optional is_live(agent) method (default returns false — back-compat). The daemon's DaemonInjectProvider overrides it with session_lookup::lookup_any so liveness checks see both maps. Egress consults the registry first (legacy path), then the provider (v2 path), then concludes offline.

This unblocks awareness-bus delivery to every v2 agent, not just the chat tab. Heartbeats benefit too.

Inject does a two-phase write (body, settle, Enter)

Both inject paths — DaemonInjectProvider::inject and the pending_live drain in v2_spawn::handle_v2_spawn — now mirror what heartbeat_launch::run_inject was already doing for the Launch button:

`rust

session.write(body)?;

std::thread::sleep(std::time::Duration::from_millis(150));

session.write(b"\r")

`

A single combined body+\r write was being seen as a paste burst by the TUI input widget; raw-mode input widgets distinguish "fast-arriving bytes" (paste, internal newlines as literals) from "human-paced" (key by key, \r = submit). Splitting the write across two syscalls with a 150ms settle puts us on the human-paced side of that heuristic.

Chat tab refresh button

A small refresh icon now lives on the right side of the chat tab header. Click it to:

1. POST /cli/sessions/v2/close to tear down the current PTY (clears the agent_sessions.active_terminal_id column via the existing unregister hook).

2. Reset the local mount state (launchConfig=null, ready=false).

3. Bump a key on TerminalPane so it fully unmounts/remounts and the next mount fresh-spawns via /cli/sessions/v2/spawn.

Useful when you've typed exit, the Claude process has crashed, or the session is just unresponsive — you don't have to quit-and-relaunch the whole app to get back in.

Under the hood

-DB migration 0037 — adds agent_sessions.active_terminal_id (NULLABLE TEXT). Stamped synchronously by v2_spawn::handle_v2_spawn when a workspace-agent-keyed session registers; cleared by the v2_session_map::unregister cleanup hook on PTY exit. Mirror of 0036's agent_heartbeats.active_terminal_id.
-New daemon route /cli/sessions/lookup-by-agent?agent= — walks session_lookup::lookup_any (both maps) and returns {agentName, sessionId, sessionAlive, isV2}. Used by AgentChatPane on mount for diagnostic visibility; the actual attach happens via the attachAgentName prop passing through to /cli/sessions/v2/spawn's find-or-spawn.
-New Tauri commandk2so_session_lookup_by_agent(agent) proxies to the daemon route.
-AgentSession schema helperssave_active_terminal_id, clear_active_terminal_id_by_terminal, clear_active_terminal_id. Mirrors AgentHeartbeat's helpers.
-InjectProvider::is_live — new optional trait method with default false. DaemonInjectProvider overrides it with session_lookup::lookup_any(agent).is_some().
-signal_format::inject_bytes / egress::render_signal_for_inject — both now return body bytes without a trailing newline. The submit \r is the caller's responsibility (two-phase contract).

Filed for follow-up

-Copy Session ID on the pinned chat tab's right-click menu — chat tab's daemon session UUID is the same kind of stable ID regular tabs already expose; should be addressable the same way.
-k2so workspaces (yellow pages) — single CLI command to list every known workspace + its primary agent + alive/asleep, so callers don't have to grep .k2so/agents/<...> to figure out who they're talking to.
-k2so msg "..." auto-resolve + auto-wake by default — today's --wake is opt-in; should be the default since "send a message to the agent" usually means "land it in the live session, wake them if asleep." --inbox becomes the explicit opt-in for file-drop.
-Surface workspace, is_primary, isV2, spawned_by fields on /cli/agents/running — current response forces callers to filesystem-grep to attribute a session to a workspace.

Upgrade

Use the in-app updater (Settings → General → Check for updates) or download the DMG from the GitHub release page below.

Download K2SO_0.36.13_aarch64.dmg (38.3 MB)

v0.36.12

April 30, 2026

# K2SO 0.36.12 — Hotfix: Chat re-surface dedup

Clicking a chat in the ChatHistory drawer used to open a duplicate tab every time, even if that exact session was already running in another tab. So you'd resume a chat, switch to another tab to check something, click the chat row again to come back — and end up with two tabs both running claude --resume instead of being routed back to the one you already had open.

This release adds a dedup check to the click handler: if a tab is already running this session (same command + sessionId in args), it focuses that tab instead of spawning a duplicate.

What changed

handleSessionClick in ChatHistory.tsx now scans every tab across all split groups before calling addTabToGroup. If it finds one whose terminal item's args contain the clicked session's ID, it focuses that tab (via setActiveTab for the main group or setActiveTabInGroup for splits) and returns early.

Cross-worktree resumes are exempt — when the workspace branch differs from the session's origin branch, K2SO appends --fork-session to create a *new* conversation branch off the original. That's a fresh conversation by design, so it gets its own tab even if the origin session is open elsewhere.

Why this is its own system, not shared with heartbeat surfacing

0.36.11 added a similar "find existing tab" branch for heartbeat row clicks (in tabs.ts::openHeartbeatTab). The two flows look alike but solve different problems:

-Heartbeat surfacing has to reach into the daemon to ask "is there a live PTY for this heartbeat?" — daemon-spawned heartbeat PTYs aren't visible to the renderer until surfaced. Then it adopts the existing PTY through k2so_session_set_surfaced rather than re-resuming.
-Chat re-surface is purely a renderer concern. Chats live as files on disk (~/.claude/projects/.../.jsonl etc.), and the renderer-spawned claude --resume tab is the only "live" surface. No daemon round-trip needed; just match against open tabs.

So the two systems share the matching shape (command + sessionId in args) but live in separate code paths. Keeping them separate means future changes to one — e.g., when the daemon-id-space unification lands and chats stop relying on args-scanning — don't ripple through the other.

Upgrade

Use the in-app updater (Settings → General → Check for updates) or download the DMG from the GitHub release page below.

Download K2SO_0.36.12_aarch64.dmg (38.3 MB)

v0.36.11

April 30, 2026

# K2SO 0.36.11 — Heartbeat sessions: surface, don't resume

Clicking a heartbeat in the workspace tab drawer used to *resume* the chat into a brand-new PTY every time, even when the daemon already had a live session for that heartbeat running in the background. So you'd click Launch, watch the wakeup land in your tab, click the row to come back later, and end up looking at a fresh blank Claude Code session — not the conversation the heartbeat just had.

This release fixes the underlying daemon-vs-renderer PTY visibility gap that caused it. Heartbeat tabs now reliably surface the *existing* session instead of spawning a duplicate, and closing one minimizes the tab without killing the PTY (so you can re-summon it from the row click any time).

What changed

One PTY per heartbeat, surfaced on demand

Each heartbeat row now stores an active_terminal_id column pointing at its currently-live PTY's daemon session id. smart_launch stamps this synchronously alongside last_session_id whenever a heartbeat fires (fresh fire, inject into existing, or resume-and-fire). When you click the heartbeat row in the tab drawer:

1. Renderer asks the daemon "is there a live PTY for this heartbeat?"

2. If yes → daemon returns the session's id + agent_name; renderer attaches a new tab to that exact PTY (/cli/sessions/v2/spawn returns reused: true, no fresh resume).

3. If no → fall through to the legacy fresh-resume path (claude --resume in a new tab).

No more two-PTYs-one-conversation. The tab attaches to whichever PTY the daemon is already running, regardless of how it got spawned (Launch button, scheduled fire, awareness-bus inject).

Close button is now `–` (minimize), not `×` (kill)

The close button on heartbeat tabs renders as a minus glyph — and clicking it leaves the daemon-owned PTY running so the heartbeat keeps firing on schedule. The braille working-spinner still shows when the agent is doing something; hovering reveals the glyph, same flow as regular tabs but with minimize-not-kill semantics. Tooltip: "Hide tab — heartbeat keeps running in the background."

Detection works two ways for backward compatibility:

-New surfaced tabs carry heartbeatName stamped on their data.
-Pre-existing tabs (from before this release) cross-reference: any tab whose claude --resume args match any heartbeat row's lastSessionId is treated as a heartbeat tab.

So even tabs you opened before this release get the right close behavior the next time the heartbeat fires into them.

Lazy cleanup + boot sweep keep the column honest

If the PTY exits (claude --print finishing, watchdog kill, daemon crash), the v2_session_map's child-exit observer nulls active_terminal_id automatically. Reading the column also lazy-cleans: if the recorded id no longer maps to a live session, the daemon nulls it inline so the next read reflects reality. And on daemon boot, any non-NULL active_terminal_id whose session isn't in the freshly-rehydrated map gets cleared — so a daemon restart doesn't leave stale pointers behind.

Under the hood

-DB migration 0036 — adds agent_heartbeats.active_terminal_id (NULLABLE TEXT) and agent_sessions.surfaced (INTEGER, defaults 0; existing user-owned sessions backfilled to 1).
-New daemon HTTP route /cli/heartbeat/active-session — walks both legacy + v2 session maps via session_lookup::snapshot_all, returns the live session's agent_name so the renderer can attach using the canonical key (heartbeat-spawned PTYs register under the workspace's primary agent name, not tab- — without the agent_name passthrough the renderer would never find them).
-New Tauri commandsk2so_heartbeat_active_session, k2so_session_set_surfaced. The latter emits HookEvent::SessionSurfaced so the renderer's listener creates a tab attached to the existing PTY, building the tab in a single setState so attachAgentName is on the data before TerminalPane mounts (otherwise /cli/sessions/v2/spawn fires before the override lands and the daemon spawns a duplicate).
-TerminalPane.attachAgentName prop — overrides the auto-derived tab-${terminalId} when surfacing a daemon-spawned session.
-Heartbeat tabs force alacritty-v2 renderer — daemon-spawned PTYs only live in v2_session_map, so a tab attached to one must use the v2 path regardless of the workspace's renderer setting.
-Child-exit observer in v2_spawn — subscribes to DaemonPtySession's alacritty event broadcast and unregisters on ChildExit, which triggers the active_terminal_id clear via v2_session_map::unregister's hook.

Filed for follow-up

-Single-id-space cleanup (.k2so/prds/post-landing-cleanup.md, new add-on section): renderer's tab id and daemon's session id should converge into one canonical UUID. Today they're bridged via agent_name; the bridge works but every consumer that wants to join the two has to remember it. Queued for the unification migration alongside the existing v1 retirement.
-Single-agent workspace migration: workspaces have one agent + many heartbeats, not many agents. Future migration will simplify addressing (: becomes redundant, heartbeats become first-class addressable as :heartbeats:).
-Scheduler test timezone flake (.k2so/work/inbox/bug-scheduler-test-timezone-flake.md): pre-existing test fixture builds DateTime by converting from UTC, fails on machines west of UTC-2. Production behavior is correct (heartbeats follow OS local time, including across timezone changes); only the test is flaky.

Upgrade

Use the in-app updater (Settings → General → Check for updates) or download the DMG from the GitHub release page below.

Download K2SO_0.36.11_aarch64.dmg (38.3 MB)

v0.36.10

April 28, 2026

# K2SO 0.36.10 — Add Workspace: three-option onboarding (Adopt / Start fresh / Do it later)

When you add a workspace that already has CLI-LLM context files (CLAUDE.md, GEMINI.md, .cursor/rules/k2so.mdc, .goosehints, AGENT.md, etc.), K2SO now asks how you want to handle them instead of silently archiving everything. The Add Workspace dialog gets a three-option radio picker — and the dialog itself leads with a plain-language explanation of *why* K2SO unifies these files in the first place, so first-time users aren't dropped into a wall of file paths.

What changed

The Add Workspace dialog now has three modes when existing harness files are detected:

1. Adopt one as Project Knowledge — Pick the file whose body should seed .k2so/PROJECT.md (the single source of truth K2SO compiles every CLI-LLM SKILL from). The picked file's body becomes PROJECT.md; the original is archived to .k2so/migration/ like every other file. This is the right choice if one of your existing files already has the project context you want every AI tool to share.

2. Start fresh — The previous default. Every existing harness file is archived; PROJECT.md starts empty and you fill it in deliberately. Right when your existing files are stale, tool-specific (e.g., a Cursor-only rule), or you'd rather start clean.

3. Do it later — New. Drops a .k2so/.skip-harness-management flag. K2SO writes its own internal SKILL.md (so heartbeats and agent launches still work) but does not touch any of your CLI-LLM harness files. CLAUDE.md, GEMINI.md, .cursor/rules, etc. stay exactly as you had them. Reversible — the flag is a single file you can delete to re-run onboarding later.

Plus a couple of quality-of-life touches:

-The dialog now leads with why K2SO does this in plain language ("Tell K2SO once, every AI tool listens") instead of burying it behind a "▸ Why does K2SO do this?" expandable.
-The "Plan for this workspace" file list is now a ▸ Show file plan toggle — secondary detail, not the dominant content. Auto-expanded when you pick Adopt so you can see (and click) the candidate files.
-Radio dots are blue (var(--color-accent)) in both AddWorkspaceDialog and RemoveWorkspaceDialog for consistency.

Under the hood

-New k2so-core::agents::onboarding module (six unit tests). Logic lives entirely in core so the CLI and Tauri share the same implementation — the renderer is pure display + button-clicks.
-New Tauri commands: k2so_onboarding_scan / _adopt / _skip / _start_fresh.
-New daemon HTTP routes: /cli/onboarding/scan / /adopt / /skip / /start-fresh.
-New CLI subcommand: k2so onboarding {scan, adopt , later, fresh} for headless operation.
-skill_writer::write_skill_to_all_harnesses and the workspace regen orchestrator now check the skip flag and short-circuit harness fanout when it's set — K2SO's internal .k2so/skills/k2so/SKILL.md still gets written either way.

Restore symmetry preserved

The existing k2so workspace remove --mode restore-original flow continues to work for adopted workspaces: Adopt's archive lives in the same .k2so/migration/ folder as every other archived file, so Restore Original brings everything back byte-for-byte from the snapshot taken at adopt-time. PROJECT.md edits made *after* adoption don't affect what Restore Original puts back.

Upgrade

Use the in-app updater (Settings → General → Check for updates) or download the DMG from the GitHub release page below.

Download K2SO_0.36.10_aarch64.dmg (38.3 MB)

v0.36.9

April 28, 2026

# K2SO 0.36.9 — Workspace Knowledge & Persona editors point at the source files

The two AIFileEditor surfaces in Settings — Workspace Knowledge and the agent Persona editor — were each editing the *wrong* file: a compiled output that K2SO regenerates on every launch. So your edits would seem to take, then vanish on the next regen pass. This release fixes that and also tells the AI inside the editor where the real source files live.

What was wrong

K2SO compiles a single SKILL.md per agent (and one for the workspace) from two source files:

-.k2so/PROJECT.md — workspace-level knowledge (tech stack, conventions, build commands, key paths)
-.k2so/agents//AGENT.md — that agent's role / persona / standing orders

The compiled SKILL is then symlinked or marker-injected into every harness file the workspace might encounter — CLAUDE.md, AGENTS.md, GEMINI.md, .cursor/rules/k2so.mdc, .goosehints, .opencode/agent/k2so.md, .pi/skills/k2so/SKILL.md. That fan-out is by design: write the agent's protocol once, every CLI LLM tool reads the same source of truth.

The two editors were pointed at the wrong layer:

1. Workspace Knowledge was opening .k2so/skills/k2so/SKILL.md — the compiled output.

2. Agent Persona was a four-tab editor mixing AGENT.md (source) with the agent's CLAUDE.md and the workspace CLAUDE.md (both compiled outputs).

Both surfaces had bidirectional adoption logic that *tried* to detect drift in the compiled file and commit it back to the source — but the user-facing flow was confusing ("which tab is the truth?") and the source/output mismatch led to silent overwrites.

What changed

Workspace Knowledge now opens .k2so/PROJECT.md directly. On close, it triggers k2so_agents_regenerate_workspace_skill so your edits propagate to every harness file in one pass.

Agent Persona is now a single-file editor pointed at AGENT.md, mirroring the Wakeup editor's shape. The four-tab layout is gone; CLAUDE.md / AGENTS.md / GEMINI.md / etc. are no longer presented as edit surfaces because they're not — they're compiled outputs.

Compiled SKILL.md gets a footer. Every generated SKILL (per-agent and workspace-level) now ends with a "How to update this skill" section that cites absolute paths to the source files:

`

How to update this skill

This SKILL is auto-generated by K2SO from source files. Your edits to

this file will be overwritten on the next regen — edit the sources

instead and K2SO will recompile.

-Workspace knowledge — edit /abs/path/.k2so/PROJECT.md
-Your role / persona / standing orders — edit /abs/path/.k2so/agents//AGENT.md

If the user asks you to update workspace knowledge or your persona,

propose the edit and confirm before writing — these files affect every

future wake of every agent.

`

So the AI living inside any harness now has a stable contract: SKILL is read-only context, source files are where actual changes go.

What's not affected

-Existing PROJECT.md / AGENT.md content — preserved as-is. The new editors just open the file you already had.
-Existing self-improvement drift adoption — still works. If an agent writes inside the SKILL.md SOURCE_PROJECT_MD or SOURCE_AGENT_MD markers below , that drift is still committed back to the right source file on the next regen, with the same mtime/hash conflict-resolution logic as before. The footer just adds an explicit signal that direct source-file edits are preferred.
-Wakeup editor (Settings → Heartbeats) — unchanged. Was already a single-file source-edit surface; that shape is what the Persona editor now mirrors.

Upgrade

Use the in-app updater (Settings → General → Check for updates) or download the DMG from the GitHub release page below.

Download K2SO_0.36.9_aarch64.dmg (38.3 MB)

v0.36.8

April 27, 2026

# K2SO 0.36.8 — Hotfix: Chat tab stops auto-triaging on app relaunch

The persistent-agent Chat tab no longer fires a fresh wake (with the agent's WAKEUP.md as the first user message and an optional /compact directive) every time the K2SO app relaunches. If you saw your workspace's pinned agent suddenly start triaging the inbox after upgrading K2SO to 0.36.5/0.36.6/0.36.7, this fixes it.

What was happening

The pinned Chat tab in each workspace (the one that hosts the persistent agent's conversation) was using k2so_agents_build_launch to spawn its claude PTY. build_launch is the function the heartbeat scheduler and "Launch agent" button use to *wake an agent up with full context* — it injects the agent's WAKEUP.md content as the positional first user message and prefixes /compact every 20 wakes to keep history bounded.

That's the correct behavior for a deliberate wake (heartbeat fire, manual Launch click). It is NOT the right behavior for the Chat tab on app relaunch:

1. K2SO auto-update relaunches the daemon

2. Daemon restart kills the agent's PTY

3. Chat tab re-mounts, sees no live PTY, falls through to build_launch

4. build_launch constructs claude --resume --append-system-prompt and spawns it

5. Agent reads the WAKEUP, sees inbox items, starts triaging — without the user ever clicking anything

Three K2SO releases (0.36.5, 0.36.6, 0.36.7) each triggered this on auto-update. 0.36.4's triage_decide gate didn't help because this path didn't go through triage_decide — it went straight through build_launch.

What's fixed

A new lighter Tauri command, k2so_agents_resume_chat_args, returns a *bare resume* command for the Chat tab:

-If we have a saved session id for this agent and the session file exists on disk: claude --dangerously-skip-permissions --resume
-Otherwise: claude --dangerously-skip-permissions (fresh)

No system-prompt injection. No positional WAKEUP body. No /compact. The Chat tab is for *chatting with* the agent — the agent should only triage when explicitly asked (heartbeat fire, manual button).

AgentChatPane.tsx now calls this command instead of build_launch. build_launch itself is unchanged and is still the right primitive for actual wake events.

What's not affected

-Scheduled heartbeats — if you have an agent_heartbeats row enabled, it still fires the agent on schedule via build_launch. Same as before.
-Manual "Launch" button — still fires a wake with full WAKEUP context. Same as before.
-k2so heartbeat fire CLI — unchanged.
-Existing chat history — the saved session id is preserved; on first relaunch with 0.36.8 your Chat tab reattaches to the same conversation it had before, just without the surprise wake.

Upgrade

Use the in-app updater (Settings → General → Check for updates) or download the DMG from the GitHub release page below.

Download K2SO_0.36.8_aarch64.dmg (38.3 MB)

v0.36.7

April 27, 2026

# K2SO 0.36.7 — Alacritty (v2) is the new default for fresh installs

The daemon-hosted Alacritty renderer (the one that survives Tauri quit and supports heartbeat continuity) is now the default for fresh installs. The legacy in-Tauri renderer remains available — labeled "Alacritty (Legacy)" in Settings → Terminal — for users who relied on the older lifecycle, but it's no longer what new users land on out of the box.

What's new

Default renderer is now `alacritty-v2`

Fresh installs of 0.36.7+ open new terminal tabs against the daemon-hosted v2 renderer by default. Practical effects:

-Terminal sessions survive K2SO quit. The next time you open the app, the daemon already has the PTY running; the tab just reattaches.
-Heartbeats can target the session — wake-injects work the same way they do for Claude Code chats today.
-The PTY is shared between Tauri and the mobile companion (when that ships), so you can pick up a session on another device.

The A1–A5 phase plan from .k2so/prds/alacritty-v2.md landed across 0.34–0.36; v2 is now production-hardened.

Existing users keep their choice

Zustand's persist middleware means existing users' renderer preference is preserved on upgrade. If you previously picked "Alacritty (Legacy)" or "Kessel (BETA)" — or never touched the dropdown and were on the old default — your selection sticks. Only installs with no prior K2SO state see the new default.

UI labels and hints refreshed

The Settings → Terminal → "Terminal Renderer" dropdown still shows all three options ("Alacritty", "Alacritty (Legacy)", "Kessel (BETA)"), but the surrounding tooltip and search-manifest description now reflect v2's status as the new default. The "while v2 finishes baking" wording is gone.

Internals

-src/renderer/stores/terminal-settings.ts — default flipped + comment block refreshed.
-src/renderer/stores/tabs.tsSerializedItem.renderer type extended to include 'alacritty-v2'. This was a long-standing TS warning where the runtime value (which K2SO already wrote to disk for users who opted in) didn't match the static type. With v2 now the default, every new install will be persisting this value, so the type needed to be honest.
-src/renderer/components/Settings/sections/TerminalSection.tsx — comment, tooltip, and search keywords aligned with the new default.

Upgrade

Use the in-app updater (Settings → General → Check for updates) or download the DMG from the GitHub release page below.

Download K2SO_0.36.7_aarch64.dmg (38.3 MB)

v0.36.6

April 27, 2026

# K2SO 0.36.6 — Codex chat resume joins the Big 4

The Chats drawer now lists, click-resumes, and auto-restores Codex CLI sessions alongside Claude, Cursor, Gemini, and Pi. With this release, every major coding-CLI provider has parity: chat list filtered to the current workspace, one-click resume from the drawer, on-quit save of the live session id, and on-relaunch auto-restore via the saved id.

What's new

Codex sessions in the Chats drawer

Click any Codex session to spawn a tab running codex resume — picking up exactly where you left off. Sessions are read from ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl and filtered to the active workspace via the literal cwd recorded in each rollout's session_meta header (line 1). Worktrees collapse back to the parent project the same way Claude sessions do.

Titles are pulled from ~/.codex/history.jsonl indexed by session id rather than from the rollout file directly. The rollout's first user message is polluted by an injected AGENTS.md blob; the flat history is the clean source — one line per real user prompt.

Subcommand-style resume

Codex is the first provider to use a subcommand for resume rather than a flag. K2SO's data model now distinguishes resumeFlag (Claude/Cursor/Gemini/Pi) from resumeSubcommand (Codex). Click handler, on-relaunch deserialize, and the launch-default-agent path all branch on which is set. For Codex specifically, preset args are dropped on resume because codex resume only accepts a small subset of options — the saved session already carries its own model and permissions from when it was first started.

On-quit save + on-relaunch auto-restore

Same flow that's been working for Claude/Cursor (and gained Gemini/Pi in 0.36.5). When you Cmd+Q with a live codex tab open, K2SO walks the layout and stamps the live session id onto the terminal item before serializing. On next launch, the deserialize path re-spawns the tab as codex resume automatically.

Why we waited

Codex CLI's resume API was labeled experimental_ for most of late 2025 — the only options were codex resume --last (no specific id) or codex -c experimental_resume="" (path-based escape hatch). Codex 0.125 stabilized the contract: codex resume (UUIDs take precedence; thread names also work) plus codex resume --last and codex resume --all. K2SO targets that stable shape — earlier Codex versions won't get one-click resume.

Upgrade

Use the in-app updater (Settings → General → Check for updates) or download the DMG from the GitHub release page below.

Download K2SO_0.36.6_aarch64.dmg (38.3 MB)

v0.36.5

April 27, 2026

# K2SO 0.36.5 — Chats drawer: full Big-4 support (Gemini + Pi added)

The Chats drawer now lists, click-resumes, and auto-restores-on-relaunch sessions from all four major coding-CLI providers: Claude, Cursor, Gemini, and Pi. Previously only Claude and Cursor were wired up.

What's new

Gemini sessions are first-class citizens

Click any Gemini session in the Chats drawer to spawn a tab running gemini --resume with your preset args carried through. Sessions are read from ~/.gemini/tmp//chats/*.jsonl and filtered to the current workspace via Gemini's own ~/.gemini/projects.json slug map (so worktrees collapse back to the parent project the same way Claude sessions do). The first user message becomes the chat title.

A subtlety worth knowing: Gemini's filename only contains an 8-character prefix of the session UUID, but resume needs the full UUID. The parser reads the full id from line 1 of the JSONL header — that's the one place a naive implementation would break.

Pi sessions are first-class citizens

Same shape as Gemini, with one important difference: Pi's resume flag is --session , not --resume . (Pi's --resume is its interactive session picker — no id arg.) Sessions live at ~/.pi/agent/sessions//_.jsonl. Each session file's line-1 header carries the literal cwd, which we use for project filtering — that's more robust than reverse-engineering Pi's slug encoding across worktrees.

On-quit save + on-relaunch auto-restore for Gemini and Pi

The same flow that's been retiring Claude/Cursor tabs gracefully now extends to Gemini and Pi. When you Cmd+Q with a live gemini or pi tab open, K2SO walks the layout before serializing and stamps the live session id onto each terminal item. On next launch, the deserialize path re-spawns the tab as gemini --resume or pi --session so you pick up exactly where you left off — no manual hunt through the Chats drawer to re-resume what you were already working on.

What's fixed

Click-to-resume sent the UUID as a chat message for Pi

Pre-0.36.5, the Chats-drawer click handler hardcoded --resume as the resume flag for every provider. Claude and Cursor tolerate that fine. Pi parses --resume as "open the picker (no id)" and treats the uuid as a positional message argument — so clicking a Pi session opened the picker, then once you selected something, the uuid string got sent as your first chat message. Fixed: the click handler now reads config.resumeFlag from the per-provider config (--session for Pi, --resume for the others).

Preset args stripped more aggressively

If your Pi (or any) preset had --resume, -r, --continue, -c, or --session baked into the command string, those would carry through into the resume command and shadow our explicit session selection. The preset-arg filter now drops every session-selection flag before appending the explicit , so a preset like pi --resume no longer breaks click-to-resume.

Known unknown — Codex deferred

Codex CLI's session-resume API is still labeled experimental_ and hasn't stabilized — codex resume --last (no specific-id support) and codex -c experimental_resume="" are the only options today. We'll add Codex support once OpenAI ships a stable --resume (or equivalent). Planned for a follow-up release.

Upgrade

Use the in-app updater (Settings → General → Check for updates) or download from the GitHub release page below.

Download K2SO_0.36.5_aarch64.dmg (38.3 MB)

v0.36.4

April 27, 2026

# K2SO 0.36.4 — Hotfix: gate inbox-driven triage on heartbeat mode

Second hotfix in the same evening as 0.36.3, closing the last leak in the legacy "decide whether to wake an agent based on inbox contents" path that 0.36.3 partially fixed.

What was still happening after 0.36.3

0.36.3 retired the renderer's auto-retriage loop, which was the most visible firing path. But the underlying decision function — k2so_agents_triage_decide — had no project-level gate of its own. So any caller (the /cli/heartbeat HTTP route, the renderer's launch-failure-retry, manual k2so heartbeat CLI invocations) would still return launchable agents whenever a workspace inbox had items, regardless of whether that workspace had its heartbeats disabled.

In practice that meant a workspace with agent_heartbeats rows all set to enabled=0 and projects.heartbeat_mode='off' could still fire wakes if anything triggered a triage decision against it. We saw this happen on the K2SO workspace itself: agents got auto-spawned and made commits to feature branches (which were rolled back as part of this release).

What's fixed

k2so_agents_triage_decide now reads projects.heartbeat_mode and returns an empty launchable list when it's 'off'. Same gate the new scheduler_tick already uses; now both code paths agree.

The function is also flagged #[deprecated] with the legacy-per-agent-heartbeat tag, so the compiler emits a warning at every remaining call site. That's the kill list for the broader cleanup planned in 0.37.x — by then we expect to remove triage_decide, read_heartbeat_config, AgentHeartbeatConfig, and the on-disk .k2so/agents//heartbeat.json files entirely.

Local LLM cleanup

While investigating, we noticed src-tauri/src/commands/assistant.rs::safe_generate_for_triage — a public wrapper from the original "local on-device LLM reads inboxes and decides whether to wake papa-Claude" automation — had zero callers anywhere in the codebase. The LLM-driven decision path was retired earlier this year in favor of the script-based path; the wrapper was just leftover scaffolding. Removed.

The local LLM remains in active use for the Workspace Assistant (Cmd+L) feature; only the orphan triage wrapper was removed.

Rollback of unauthorized commits

Three commits made on top of 0.36.3 by an auto-fired agent (companion API background-terminal-spawn endpoint and cumulative display_offset in reflowed grid events) were reverted. The original work stays in git log/git show for future reference if we decide to ship those features properly later, but their effects are undone in main.

Upgrade

Use the in-app updater (Settings → General → Check for updates) or download the DMG from the GitHub release page below. After upgrade, workspaces with all heartbeats disabled stay silent — no more auto-spawned chats.

Download K2SO_0.36.4_aarch64.dmg (38.3 MB)

v0.36.3

April 27, 2026

# K2SO 0.36.3 — Hotfix: retire legacy auto-retriage loop

Hotfix for a legacy code path that was firing wakes against workspaces with no scheduled heartbeats. If you saw chats unexpectedly appear in workspaces you hadn't configured for autonomous agent activity, this release stops it.

What was happening

A renderer-side loop, originally written for the pre-0.30s "agent always re-fires after stopping" model, was still wired up. Whenever any Claude session in a heartbeat-enabled project ended, the loop ran a separate triage path that read the legacy .k2so/agents//heartbeat.json config (the per-agent adaptive heartbeat from before workspace-scoped scheduled heartbeats existed) and immediately re-spawned the agent in a new tab. The new spawn ran its WAKEUP, ended, fired the loop again, and so on.

The loop was self-perpetuating — autoBackoff slowed it but never stopped it — and it bypassed the projects.heartbeat_mode='off' gate that the new system uses, because it called triage_decide (legacy gate, reads heartbeat.json directly) instead of scheduler_tick (DB-gated). That's why opting a workspace out of heartbeats via the UI didn't stop the wakes.

What's fixed

The renderer's 'stop'-event auto-retriage block is removed. Sessions now end and stay ended until their next scheduled fire from agent_heartbeats (the DB-backed system shipped in 0.36.0+). Workspaces with no scheduled heartbeats are silent.

The legacy heartbeat.json files on disk are preserved for now — a follow-up release will sweep them along with the rest of the per-agent heartbeat code (currently flagged as deprecated; the compiler emits warnings at every call site so the cleanup diff writes itself).

Diagnostic instrumentation added

A few opt-in traces landed alongside the fix, off by default:

-K2SO_TRACE_HEARTBEAT_JSON=1 — prints a backtrace whenever any code reads a heartbeat.json file. Useful if a similar leak surfaces in the future.
-K2SO_TRACE_WAKE_SPAWN=1 — prints a backtrace at every wake-spawn entry point (daemon-side and Tauri-side). Confirms what's firing wakes.
-K2SO_PERF=1 — opt-in for the [perf] *_tick histogram lines in the dev console. Was always-on in debug builds, drowning out other tracing; now opt-in.
-localStorage.K2SO_V2_ACTIVITY_VERBOSE='1' — opt-in for the per-title-change [v2-activity] TITLE line in the renderer console. Was always-on in dev mode, ~1 line/sec per active agent; now opt-in.

What didn't change

Everything else from 0.36.2 ships unchanged: default presets, LLM provider icons, Heartbeats settings page, Reset Built-ins, mobile companion deprecation notice. Scheduled heartbeats (the new DB-backed system) continue to fire on their configured cadence.

Upgrade

Use the in-app updater (Settings → General → Check for updates) or download from the GitHub release page below.

Download K2SO_0.36.3_aarch64.dmg (38.3 MB)

v0.36.2

April 27, 2026

# K2SO 0.36.2 — Default-preset overhaul, real LLM provider icons, Heartbeats settings polish

This release replaces every default-preset emoji with the real provider mark (Claude, Codex, Gemini, Cursor, Pi, OpenCode, Goose, Aider, Ollama, Copilot, Open Interpreter), reorders the built-in list to reflect actual usage, drops Code Puppy as a built-in (you can still add it as a custom preset), and lands a long backlog of Settings UI polish — most visibly the Wake Scheduler page renamed and re-themed as "Heartbeats."

What's new

Real LLM provider icons everywhere

Every spot that surfaces an agent — the launch bar, terminal tab headers, the saved-chats drawer, and the Editors & Agents settings page — now renders the actual brand mark instead of a llama emoji or a generic prompt glyph. We vendored ten SVGs locally (no runtime URL fetches, fully offline-safe) sourced from Lobe Icons (MIT), Simple Icons (CC0), and the official pi.dev and openinterpreter.com logos. Color marks (Claude, Codex, Gemini, Copilot) keep their brand fills; monochrome marks (Cursor, Goose, Ollama, OpenCode, Pi, Open Interpreter) use currentColor so they adapt to the app theme. Aider keeps its custom green-A glyph because Aider's official mark is a horizontal wordmark, illegible at icon sizes.

The previous behavior was that the seed planted a 🦙 / 🌐 / 🦢 emoji as the icon for Ollama, Open Interpreter, and Goose, which beat the SVG renderer. Those overrides are now cleared.

Default presets reordered, Code Puppy removed, Pi added

The new canonical order for fresh installs:

1. Claude

2. Codex

3. Gemini

4. Cursor Agent

5. Pi

6. OpenCode

7. Goose

8. Aider

9. Ollama

10. Copilot

11. Open Interpreter

Code Puppy was removed as a built-in. The CLI is still supported as a custom preset — add it manually if you use it. Pi is now a default built-in (it wasn't in the Rust seed before, only in the Tauri "reset to defaults" list), so fresh installs and "Reset Built-ins" both surface it.

On upgrade, existing users keep their re-orderings and customizations untouched. Two automatic things happen on first launch:

-Code Puppy is removed from your built-in list (one-shot DELETE migration).
-If you didn't already have Pi, it gets inserted at its canonical position. Users who already have a Pi entry from a previous "Reset to defaults" keep theirs as-is.

Settings → Heartbeats (was: Wake Scheduler)

The settings page that controls how launchd fires heartbeats has been renamed and re-themed to match the rest of the settings shell:

-Square corners, theme-matching font sizes, beta pill instead of (BETA).
-Width constrained to one third of the page so future right-side panels (fire history, preview) have somewhere to live.
-The "Wake system from sleep" toggle now matches the Mobile Companion toggle exactly (w-7 h-3.5 outer, w-2.5 h-2.5 thumb).
-Conditional dividers: the bottom border on the Mode block only renders when "Heartbeat every N minutes" is selected, so the page no longer shows doubled separator lines above the Apply button.
-Dirty-state tracking rewritten as a deep-equality check against the last-applied snapshot instead of a manual flag, fixing the bug where flipping a setting back to its applied value still showed "Unsaved changes" or where Apply seemed to no-op.

Reset Built-ins is now a real reset

The Reset Built-ins button on Editors & Agents now wipes every is_built_in = 1 row before re-seeding, instead of only deleting the IDs in its current list. This drops Code Puppy, repairs any stale rows from the older bug where two seed lists disagreed on the Pi/Goose/Ollama/Interpreter ID-to-name mapping, and gives you a guaranteed-clean canonical state. Custom presets you've added are not touched.

Settings nav reordered

In the left sidebar of the Settings panel: Timer dropped to the bottom, Keybindings sits above it, Mobile Companion and Heartbeats moved under Editors & Agents to keep the agent-related sections grouped.

Mobile Companion deprecation notice

The Mobile Companion settings page now states explicitly that 0.29.x is the last K2SO version that supports the legacy mobile app, and that mobile-app support will return in a future version. The companion HTTP/WS endpoints are still in the daemon for that future work; only the user-facing app pairing is on pause.

Beta pill standardized

Every place that used (BETA) text — Heartbeats section, Mobile Companion, Agentic Systems toggle, Agent Settings — now renders the same square bg-[var(--color-accent)]/15 pill.

Heartbeats panel polish

The workspace-tab Heartbeats list now shows a count badge next to the section header (omits archived), and rows sort with disabled heartbeats pushed to the bottom so the live ones stay near the top.

Internal cleanup

AGENT.md was previously labeled as "Code Puppy" in the harness-discovery comments, the diagram on Workspaces → Agent Settings, and the file-collision preview. AGENT.md is now a multi-tool standard (per the agent.md spec), so all those references say "agent.md spec" instead. The plumbing didn't change — only the wording.

Upgrade

Use the in-app updater (Settings → General → Check for updates) or download the DMG from the GitHub release page below.

Download K2SO_0.36.2_aarch64.dmg (38.3 MB)

v0.36.1

April 27, 2026

# K2SO 0.36.1 — Drag-and-drop + clipboard fixes for Alacritty (v2)

Quick-fix release closing three "it just works in v1, doesn't in

v2" gaps that surfaced after 0.36.0 shipped.

What's fixed

Drag from Finder onto a v2 terminal pane

Tauri's webview intercepts external drags at the window level

(emitting tauri://drag-drop) when dragDropEnabled is on. The

v2 TerminalPane was relying on React's standard onDrop, which

never fires for drags that originate outside the window — so

dropping a file from Finder did nothing.

Fix: v2 now subscribes to tauri://drag-drop exactly like

the legacy AlacrittyTerminalView did, hit-tests the drop position

against its container so split-pane layouts route correctly, and

sends the formatted path payload through its existing WS

sendInput. Multi-file drops are space-joined; image paths trigger

bracketed-paste wrapping so Claude Code's [Image #N] detector

fires.

Drag from K2SO's file tree onto a v2 terminal pane

The internal lib/file-drag.ts helper tracks file-tree drags

manually (because startDrag hands control to the OS, so

re-entering the same window doesn't fire tauri://drag-drop).

On mouseup it hit-tests for [data-terminal-id] and called

invoke('terminal_write', { id, data }) — but that Tauri command

only knows about the legacy in-process terminal_manager. v2

sessions live in the daemon's session map and write through their

own WS, so the call would silently fail.

Fix: the v2 TerminalPane container now exposes

data-terminal-id={session_id} + data-terminal-kind="v2", and

file-drag.ts dispatches a k2so:terminal-write CustomEvent for

v2 panes instead of the legacy IPC call. v2 listens for the event

and routes the payload through its existing sendInput.

Right-click Copy / Cut in the file tree → Cmd+V in the terminal

useFileClipboardStore.copy() and cut() only wrote to the

in-app Zustand store — never the OS clipboard. The terminal's

paste handler reads e.clipboardData.getData('text') (and falls

back to clipboard_read_file_paths for NSFilenamesPboardType),

so an in-app Copy was invisible to it.

Fix: copy() and cut() now also call

navigator.clipboard.writeText(...) with the shell-escaped paths

so Cmd+V in any terminal pane (or any other native app) picks

them up. The in-app Paste menu still works the same way via the

Zustand state.

Forward-compat notes

The lessons learned during this fix are captured in

.k2so/prds/kessel-t1.md under **Creature-comfort parity

requirements** so the future Kessel-T1 renderer doesn't trip

the same wires:

-External vs internal drags use different code paths

(tauri://drag-drop window event vs lib/file-drag.ts

mouseup-tracking)

-Pane containers must expose both data-terminal-id and

data-terminal-kind="" so file-drag.ts can route

-Don't add fallthroughs to terminal_write to "find the

right map" — use the per-pane CustomEvent pattern so each

renderer's write path stays scoped to its own component

Download K2SO_0.36.1_aarch64.dmg (38.2 MB)

v0.36.0

April 26, 2026

# K2SO 0.36.0 — Multi-heartbeat workspaces + scheduler rewrite

This release ships the full per-heartbeat sessions feature

(P1–P4) and the scheduler reliability rewrite (P5) that made

it actually fire on time. After upgrading, every workspace can

have multiple named heartbeats, each with its own dedicated chat

session, and the daemon-side cron will fire them at their

configured cadence — including sub-5-minute schedules.

The headline fix: heartbeats now reliably fire from cron.

0.35.x had three latent bugs that combined to make scheduled

fires silent on most workspaces — see "Why heartbeats weren't

firing" below.

What changed (user-visible)

Heartbeats sidebar panel + per-heartbeat chats

Every workspace now shows a Heartbeats section in the

Workspace tab. Each row has:

-A status indicator (live / resumable / scheduled / archived)
-The heartbeat's name + compact schedule summary ("Daily 9 AM",

"Every 30m", "Mon/Wed 7 AM", etc.)

-A Launch button that fires the wakeup immediately

Clicking the row opens or focuses the heartbeat's chat — each

heartbeat keeps its own dedicated Claude session distinct from

the agent's primary chat. Past sessions are resumable from the

sidebar entry even after the daemon restarts.

Smart launch (Launch button + cron + CLI converge)

A single decision tree handles every wake path:

1. Fresh fire — no saved session yet → spawn a new PTY with

the WAKEUP.md as --append-system-prompt.

2. Inject — saved session is currently running → write the

wakeup body into the live PTY's stdin so it lands as a turn

message in the existing chat.

3. Resume + fire — saved session exists, no live PTY →

spawn fresh with both --resume and

--append-system-prompt .

This logic lives in the daemon (heartbeat_launch.rs), so the

Launch button, the k2so heartbeat launch CLI verb, and the

scheduler tick all converge on identical behavior.

Workspace panel cleanup

The top of the Workspace tab now shows two static labels:

`

Workspace Type Manager

Agent Name sarah

`

Replaces the legacy heartbeat-status indicator (deprecated now

that workspaces can have multiple heartbeats). The agent-name

resolution is mode-aware — Sarah workspace correctly shows

sarah instead of the alphabetical-first agent.

Why heartbeats weren't firing (and what P5 fixed)

The pre-0.36 scheduler had four compounding bugs:

1. Stale projects-list file

~/.k2so/heartbeat-projects.txt was the gate — if a workspace

wasn't in this file, cron skipped it forever. The file was only

written by the Tauri command k2so_agents_install_heartbeat,

which ran once at first install. New workspaces, archived rows,

and changed schedules never updated it.

P5.6 fix: new endpoint /cli/heartbeat/active-projects

returns the list directly from agent_heartbeats on every

tick. heartbeat.sh queries the daemon instead of reading a

file. The legacy heartbeat-projects.txt is removed at daemon

boot.

2. Frequency mode mismatch — daily/weekly/monthly never fired

agent_heartbeats.frequency stored values like "daily" or

"weekly", but scheduler.rs::should_project_fire only

matched "hourly" and "scheduled". The match arm fell

through to _ => false, silently skipping every daily and

weekly heartbeat from cron. Manual Launch button clicks worked

because they bypass the schedule check.

P5.5 fix: daily/weekly/monthly/yearly are now

aliased to scheduled at the boundary, so legacy data and

cron tick converge without any DB migration.

3. launchd `StartInterval=300` floored cadence at 5 minutes

Even with the projects file populated, sub-5-minute schedules

(e.g. hourly with every_seconds=120) couldn't fire faster

than the launchd tick. A heartbeat configured for "every 2

minutes" would fire every 5 at best.

P5.7 fix: default wake_scheduler.interval_minutes

dropped from 5 to 1. New installs tick at 60s. Existing users

can set 1 in Settings → Wake Scheduler. Empty ticks return in

microseconds, so the cost increase is negligible.

4. No concurrency control — overlapping ticks could double-fire

The pre-0.36 path had a TOCTOU race: scheduler eval checked

is_agent_locked at one moment, spawn ran later. Two ticks

arriving in the same window could both pass the check and both

spawn, leading to duplicate Claude processes for the same

heartbeat.

This race was masked by StartInterval=300 (ticks were 5min

apart). Dropping cadence to 60s would have exposed it.

P5.2 fix: new try_acquire_heartbeat CAS in

agent_heartbeats using BEGIN IMMEDIATE. A 20-thread

contention test (db::schema::concurrency_tests::try_acquire_heartbeat_exactly_one_winner_under_parallel_contention)

proves exactly one winner under load.

Scheduler architecture (for the curious)

Six new daemon-side primitives, all derived from K8s CronJob /

River / Oban:

| Primitive | Source pattern | What it does |

|---|---|---|

| concurrency_policy (forbid/allow/replace) | K8s concurrencyPolicy | Per-row toggle for "what if previous fire is still running?" |

| starting_deadline_secs | K8s startingDeadlineSeconds | Skip a fire that's more than N seconds late (default 600s) |

| active_deadline_secs | K8s activeDeadlineSeconds | Per-spawn timeout (default 30s) — wraps smart_launch in tokio::time::timeout |

| in_flight_started_at lease | River / Oban | RFC3339 timestamp; cleared by stamp_fired_and_release on success or boot-time sweep_stale_leases (5 min) |

| Semaphore::new(6) + JoinSet | tokio | Bounded parallel fan-out over candidates per tick |

| BEGIN IMMEDIATE CAS | SQLite | Atomic check-and-set of the in-flight lease — closes the TOCTOU race |

Each primitive is independently revertable. Phased rollout

recorded in ~/.k2so/k2so.db migrations 0034–0035.

Test coverage

Total: 283 tests pass (was 276 before P5).

New scheduler / concurrency tests:

-try_acquire_heartbeat_exactly_one_winner_under_parallel_contention — 20 threads race to claim the same heartbeat row; exactly one wins.
-try_acquire_heartbeat_release_allows_reacquire — full acquire → release → re-acquire cycle.
-stamp_fired_and_release_clears_lease — success path stamps last_fired and clears the lease atomically.
-sweep_stale_leases_clears_old_in_flight_rows — boot-time recovery from a daemon that crashed mid-spawn.
-try_acquire_heartbeat_allow_policy_skips_lease_checkconcurrency_policy='allow' permits parallel spawns.
-daily_mode_aliases_to_scheduled_and_fires — regression test for the silent-skip bug above.
-weekly_mode_aliases_to_scheduled — same pattern for weekly schedules.

Migration notes

-Schema migration 0035_heartbeat_concurrency_policy.sql runs

automatically on first daemon boot post-upgrade. Adds four

columns to agent_heartbeats with safe defaults that preserve

current behavior.

-heartbeat-projects.txt deleted on first daemon boot — the

daemon scans agent_heartbeats directly now.

-launchd plist NOT auto-reloaded — existing users who want

the faster cadence should open Settings → Wake Scheduler and

set interval to 1 minute (or click Apply with their current

settings to refresh the plist).

-No frontend changes required — the heartbeats UI was already

shipped in P3 (0.35.x).

Rollback

Each P5 phase is independently revertable:

-Phase 1 (schema): no reversal needed; columns sit unused if

P5.2+ are reverted.

-Phase 2 (CAS): revert heartbeat_launch.rs and triage.rs;

helpers stay in schema.rs as dead code.

-Phase 3 (lease lifecycle): same as P5.2.
-Phase 4 (bounded pool): revert the run_candidates_bounded

function; the serial loop is restored.

-Phase 5 (policy + frequency aliasing): revert

scheduler.rs + heartbeat.rs aliasing; daily/weekly stop

firing again (preserves pre-0.36 behavior, broken as it was).

-Phase 6 (projects.txt deprecation): revert

k2so_agents.rs writer + triage.rs endpoint; heartbeat.sh

template falls back to projects.txt iteration.

-Phase 7 (StartInterval): change default back to 5.
Download K2SO_0.36.0_aarch64.dmg (38.2 MB)

v0.35.6

April 25, 2026

# K2SO 0.35.6 — Alacritty (v2) UX parity: activity, cursor, Active Bar

After 0.35.4 (A9 daemon-headless plumbing) and 0.35.5 (auto-update

spawn-retry), v2 was functionally complete but visually behind

legacy in three places that touched workflow on every keystroke:

1. **No braille spinners on tabs / no entries in the sidebar's

Active section** when an LLM was working in a v2 pane.

2. Stray cursor block parked at the bottom-left of TUI panes

(Cursor Agent, Claude Code) — alacritty's real cursor sat

below the rendered UI while the TUI drew its own visual

cursor inside an input box higher up.

3. Active Bar didn't surface workspaces on visit — they only

appeared after navigating away.

This release closes all three.

What changed

Title + Bell forwarded over the v2 WS protocol

crates/k2so-daemon/src/sessions_grid_ws.rs adds two new

outbound message types alongside Snapshot / Delta /

ChildExit:

-{event:"title", payload:{title}} — every alacritty title

change (OSC 0/1/2, plus ResetTitle as empty). Renderer

uses it the same way legacy uses terminal:title: Tauri

events: braille-spinner glyph in the title prefix → working,

✱-family glyph → idle. Drives the activity store directly.

-{event:"bell", payload:null} — terminal bell (\a).

Same signal iTerm uses for "agent waiting" notifications.

Renderer treats it as a definitive idle transition.

TerminalPane.tsx subscribes to both, calls

useActiveAgentsStore.recordTitleActivity on transitions, and

strips the leading marker glyph from the cleaned title before

calling setTabTitle. Net effect: tabs and sidebar light up

their braille spinners on the same signals legacy already

honored, with no daemon-side scanning of viewport text.

Viewport-text fallback (detectWorkingSignal) is still wired in

TerminalPane.tsx for tools that don't issue OSC title cycles.

That path is also where the multi-LLM signal additions land:

-cursor-agent: "planning next moves" + "taking longer than

expected"

-pi (pi-mono): "working..." / "thinking..."
-tenere: "🤖: waiting"
-llm-tui-rs: "loading..."

The full list is in src/renderer/lib/agent-signals.ts

each entry's intended tool is annotated.

Active-agents store fix for v2 panes

pollOnce was deleting outputTimestamps for every paneId not

in newAgents (the legacy terminal_get_foreground_command

result, which doesn't see v2 sessions). That nuked the

OUTPUT_TRUST_GRACE_MS signal that keeps a hook-driven 'working'

status alive through the cleanup branch — meaning every poll

cycle wiped the v2 spinner state.

Fix: skip the timestamp delete for any paneId already in

paneStatuses. Hook-driven legacy panes are unaffected (they're

already in newAgents via the KNOWN_AGENT_COMMANDS check).

recordTitleActivity now ALSO populates paneProjectMap on the

first 'working' transition (mirroring what handleLifecycleEvent

does on a hook 'start' event). Without this, the sidebar's

getProjectStatus had no way to attribute a working paneId to a

workspace, so the Active Bar's project-level spinner stayed

dark even when the tab spinner was lit. As a side effect the

project's lastInteractionAt gets bumped on activity → 24h

Active Bar tenure.

Active Bar always shows the active workspace

useActiveBarItems previously gated rule 3 ("active project")

on hasActiveAgents || hasHookActivity. v2 panes whose detection

hadn't lit up yet would never enter the bar — and once you

navigated away, you'd lose any "I was just here" trail.

New rule: any active workspace is always in the Active Bar.

setActiveWorkspace now calls touchInteraction(projectId),

which sets lastInteractionAt → the workspace stays in Active

for 24 hours after your last visit. Right-click → Dismiss

clears it.

Cursor visibility honors DECTCEM

The daemon's grid serializer hardcoded `CursorSnapshot.visible =

true`. After A8 made every Cmd+T tab v2 by default, TUIs that

issue \e[?25l (Cursor Agent's "Plan, search, build anything"

input, Claude Code's prompt, vim's normal mode) had a

phantom cursor block parked wherever alacritty's real cursor

ended up — usually the bottom-left of the rendered area.

Fix: crates/k2so-core/src/terminal/grid_snapshot.rs reads

term.mode().contains(TermMode::SHOW_CURSOR) for both the full

snapshot path and the delta path. TerminalPane.tsx honors the

flag and matches legacy's gate exactly:

`

showCursor = cursor.visible && displayOffset === 0

`

Inverse-cell rendering uses terminal defaults

runStyle was producing no styles when inverse=true AND

fg=null AND bg=null — a common combination for TUI-drawn

cursors. The cell rendered as plain text instead of an inverted

block. Updated to fall back to the terminal's configured

defaultFg / defaultBg, so inverse: true actually swaps in

the expected colors.

Hollow cursor on unfocused TUI panes

When a v2 pane that's running a TUI loses focus, the cursor

overlay scans the visible grid for the inverse cell, then

overlays a div that:

-Fills with the terminal's default bg (covers the TUI's white

block)

-Re-renders the cell character in default fg color (so it

looks like normal text, not the inverted-into-the-block form)

-Adds a 1px caret-color border around the cell

Net effect: the cursor flips between solid bright block

(focused) and hollow outline with normal-colored character

(unfocused) — the same focus-state UX v2's regular shells got

from day one, now extended to TUIs.

The overlay extends 1px above the row to absorb the line-box

bleed that was making the top edge appear thicker than the

other sides on retina displays. border instead of

box-shadow inset for both scenarios; the latter snaps

unevenly at fractional pixel ratios.

What's NOT in this release

-Bell signal isn't wired in Kessel (legacy session-stream)

yet — the protocol carries it for v2 only. A follow-up can

add Frame::Bell to the legacy frame stream.

-Watchdog idle escalation against v2 sessions still skips

panes that aren't in k2so_core::session::registry

(registry-backed activity tracking for v2 is a separate

follow-up — see A9 phase 2 docs).

Upgrade

Standard auto-update path — install + relaunch. The

spawn-retry-during-daemon-restart fix from 0.35.5 covers the

~3-5s window where the daemon swaps over.

Download K2SO_0.35.6_aarch64.dmg (38.2 MB)

v0.35.5

April 25, 2026

# K2SO 0.35.5 — Hotfix: v2 panes survive auto-update relaunch

Quick follow-up to 0.35.4: every "install and relaunch" was

breaking your v2 terminals.

What you saw

After clicking "install and relaunch" on the auto-updater, every

v2 (Alacritty) pane that was open before the update would remount

into an error state:

> Alacritty v2: spawn fetch failed: TypeError: Load failed

Legacy panes were unaffected. Closing + reopening the tab worked

around it, but it was a real disruption to flow — exactly the

opposite of what we want from auto-update.

Why it happened

v2 panes spawn over HTTP against the daemon's

/cli/sessions/v2/spawn endpoint. Legacy panes spawn in-process

via Tauri IPC and don't talk to the daemon over a socket at all.

When the auto-updater installs and relaunches:

1. The new Tauri binary boots immediately.

2. The renderer mounts → v2 panes fire spawn fetches right away.

3. The k2so-daemon process is still running the old binary.

4. Tauri's version-mismatch handshake (landed in 0.35.0) detects

the drift and kicks the daemon to restart with the new binary.

5. There's a ~2–5 second window where the daemon's HTTP socket

is closed (it's launching). The spawn fetches in step 2 land

in that window and fail with TypeError: Load failed — the

browser's standard signal for "connection refused."

Legacy panes never hit step 2; they boot through Tauri commands

locally and survive transparently.

Fix

src/renderer/terminal-v2/TerminalPane.tsx now retries the boot

sequence (creds resolve + spawn fetch) for up to 10 seconds with

exponential backoff (250 ms → 500 ms → 1 s → 2 s, capped at 2 s).

-Network errors (TypeError: Load failed, connection

refused) → retry, daemon is restarting.

-HTTP 5xx → retry, daemon answered but is mid-init.
-HTTP 4xx → surface immediately, this is a real request

error (missing field, bad body) and won't get better.

-Deadline exceeded → surface the error after 10 s with the

elapsed time in the message so it's clear why the wait was long.

Each retry invalidates the daemon-creds cache (so a daemon that

restarted on a new port is picked up fresh) and updates the

perf-log breadcrumb (`spawn_retry attempt=N delay_ms=X

elapsed_ms=Y`) so future debugging has a trail.

The legacy renderer's behavior is unchanged (it doesn't share

this code path).

Why this couldn't be unit-tested

The bug only reproduces during the live update flow: a fresh

binary boots, the daemon is mid-restart, the renderer's

useEffect fires within the gap. There's no good way to simulate

that without a real daemon-restart cycle. The fix has been hand-

checked against the failure modes by inspection, but its real

test is the next install — which is exactly the situation it's

trying to fix.

Upgrade

This is the first install after 0.35.4 where the new behavior

applies. Future updates should be transparent (loading spinner

for ~3-5s while the daemon restarts, then the panes attach).

Download K2SO_0.35.5_aarch64.dmg (38.2 MB)

v0.35.4

April 25, 2026

# K2SO 0.35.4 — A8 + A9: daemon goes v2-native, headless-ready

The architectural follow-up to 0.35.0–0.35.3. Now that

Alacritty (v2) is the default renderer everywhere a Tauri tab

is involved, this release rewires the daemon's CLI tools so they

also see v2 sessions — closing every audit gap the v2 cutover

exposed and unblocking the headless-server vision (Tauri can quit;

the daemon, agents, heartbeats, and signals keep running).

The headline number: heartbeats end-to-end now work against v2

agent sessions in every spawn path, including the

Tauri-closed/launchd one.

What was broken before this

After 0.35.0 made v2 the default and 0.35.1/.2/.3 fixed the

PATH-from-launchd, login-shell .zshrc sourcing, and child

TERM=dumb color regressions, an audit found **15 daemon call

sites** that only consulted the legacy session_map and were

blind to v2 sessions. Concretely:

-k2so msg --wake returned *"no live session"* for

every v2 tab, even though the session was right there.

-Heartbeat-driven Tauri-closed wakes spawned a *legacy*

session, ignoring the user's renderer preference.

-The pending_live durable signal queue never drained on v2

spawn — wake-queued signals were silently lost.

-/cli/terminal/{write,resize} 400'd on v2 session ids.
-Mobile companion + sidebar live count showed 0 for v2 tabs.
-Watchdog idle-escalation never fired against v2 sessions.

Unit tests stayed green throughout — these were daemon-runtime

integration paths none of the unit suites exercised.

What's in 0.35.4

A8 — frontend mounts already v2 (committed alongside)

Four uncommitted A8 edits ride along in this release: every

non-tab terminal mount now uses (v2) instead of

the legacy . Affects:

-src/renderer/components/AgentPane/AgentPane.tsx
-src/renderer/components/BackgroundTerminalSpawner.tsx
-src/renderer/components/AIFileEditor/AIFileEditor.tsx
-src/renderer/stores/tabs.ts (renderer-type return fix)

These are the system-driven terminal mounts (agent panel,

heartbeat-wake background spawn, AI file editor preview). They

were the easy half of the v2 cutover; A9 closes the other half.

A9 — daemon-side v2 plumbing

Three coordinated phases, all in this single release per

end-state target:

#### Phase 1 — Awareness-correct & no signal loss

-New module crates/k2so-daemon/src/session_lookup.rs

introduces LiveSession::{Legacy, V2} with polymorphic

write / resize / cwd / command / session_id,

plus lookup_any / lookup_by_session_id / snapshot_all /

list_agents that walk both maps.

-providers.rsDaemonInjectProvider::inject and

DaemonWakeProvider::try_auto_launch switch to

session_lookup::lookup_any. Closes "msg --wake doesn't see

v2" + "wake duplicate-spawns against live v2".

-v2_spawn.rshandle_v2_spawn now drains

pending_live::drain_for_agent on register, mirroring the

legacy spawn contract. No more silently-lost wake signals on

v2 boot.

#### Phase 2 — Observability surfaces see v2

-terminal_routes.rs/cli/terminal/write,

/cli/sessions/resize, and /cli/agents/running all switch

to lookup_any / snapshot_all. Mixed legacy + v2 sessions

enumerate cleanly.

-companion_routes.rs/cli/companion/sessions and

/cli/companion/projects-summary use snapshot_all. Mobile

companion + sidebar finally see every live session.

-watchdog.rs — tick walks both maps. Legacy still uses

session.kill(); v2 unregisters from v2_session_map to

trigger the IO-thread-exit-via-Arc-drop SIGHUP path. (V2

registry-backed idle tracking lands in a follow-up; for now,

v2 sessions skip the escalation ladder rather than misfiring.)

#### Phase 3 — Daemon-spawned agents go to v2 (the architectural step)

crates/k2so-daemon/src/spawn.rs::spawn_agent_session_v2_blocking

is the new helper. It takes the same SpawnAgentSessionRequest

and returns the same SpawnAgentSessionOutcome as the legacy

async spawn_agent_session, but the session it produces is a

DaemonPtySession registered in v2_session_map.

Migrated callers:

-DaemonWakeProvider::try_auto_launch (heartbeat headless

wake, the launchd-fired Tauri-closed path).

-terminal_routes::spawn_terminal_impl (/cli/terminal/spawn,

/cli/terminal/spawn-background).

-agents_routes::* (/cli/agents/launch,

/cli/agents/delegate, spawn_wake_via_session_stream).

What stays on the legacy path: only awareness_ws::handle_sessions_spawn

(POST /cli/sessions/spawn), reached only when a user explicitly

selects Kessel in Settings → Renderer for a Cmd+T tab.

After 0.35.4, both wake paths converge on v2 ownership.

Heartbeat headless wake produces a v2 session whether Tauri was

open at wake time or not.

Test coverage gaps closed

Two new regression tests in

crates/k2so-daemon/tests/providers_inject_integration.rs:

-daemon_inject_provider_writes_bytes_to_v2_session — register

a v2 DaemonPtySession under an agent name, call the inject

provider directly, assert Ok. Pre-A9 this would have

returned NotFound because the provider only walked legacy

session_map.

-daemon_inject_provider_finds_legacy_first_then_v2 — register

the same agent name in both maps, prove inject succeeds

regardless of map registration order.

These are the tests that, had they existed, would have caught

the "msg --wake doesn't see v2" issue before it shipped. Future

hotfixes for daemon-runtime integration paths should land in

this suite.

Existing test files updated to use session_lookup::lookup_any

where they previously asserted session_map::lookup:

-agents_routes_integration.rs
-scheduler_wake_integration.rs
-triage_integration.rs
-terminal_routes_integration.rs

Also fixed two test files that had been calling

awareness_ws::handle_sessions_spawn (an async fn) without

.await and were relying on a compile error nobody had run

into yet: spawn_to_signal_e2e.rs,

pending_live_durability.rs. Those tests pass now under

cargo test.

Total: **all workspace tests pass; 4 inject integration tests

including 2 new v2 regressions; 0 type errors.**

What's NOT in this release

-Watchdog escalation against v2 sessions (registry-backed idle

tracking for v2 is a follow-up).

-Retiring SessionStreamSession / Kessel-T0 entirely. That

endpoint stays alive for users who explicitly select Kessel.

-Frontend changes beyond A8. The renderer surface is unchanged.

Definition of done — what now works end-to-end

1. k2so msg --wake reaches v2 sessions.

2. Heartbeat wake against an offline agent ends up creating a

v2 session, regardless of whether Tauri was open at wake.

3. pending_live drains on every spawn, both maps.

4. Companion + sidebar live counts include v2.

5. /cli/terminal/{write,resize} route to whichever map owns

the session.

6. Legacy session_map only grows on explicit Kessel spawns.

That last point is the qualitative test for the headless-server

vision: with v2 as the default, the daemon should function with

the legacy session_map permanently empty.

Upgrade

The auto-updater will swap binaries and prompt to relaunch. The

daemon picks up the new binary on relaunch via the version-

mismatch auto-restart path landed in 0.35.0.

Download K2SO_0.35.4_aarch64.dmg (38.2 MB)

v0.35.3

April 25, 2026

# K2SO 0.35.3 — Hotfix: colors restored in Alacritty (v2)

Quick follow-up to 0.35.2: the claude --resume spawn worked

again, but everything was monochrome. Claude Code's banner, your

shell prompt, ls --color, vim, fzf — all rendering with no

color in v2 panes.

Root cause

When v2's daemon spawns a child PTY via alacritty_terminal::tty::new,

the child inherits an env where TERM defaulted to dumb. Most

TUIs check TERM to decide whether to emit ANSI escape sequences;

TERM=dumb is the universal "I don't support color" signal, so

they fall back to plain text.

The legacy renderer has always set this explicitly in

alacritty_backend.rs:332-334:

`rust

pty_options.env.insert("TERM".to_string(), "xterm-256color".to_string());

pty_options.env.insert("TERM_PROGRAM".to_string(), "K2SO".to_string());

pty_options.env.insert("COLORTERM".to_string(), "truecolor".to_string());

`

v2's daemon_pty.rs shipped without those three lines. Children

got TERM=dumb and politely turned colors off. Adding the three

entries to v2's pty_options.env mirrors legacy and restores

parity.

Fix

Three env entries added to crates/k2so-core/src/terminal/daemon_pty.rs::DaemonPtySession::spawn:

| var | value | purpose |

|---|---|---|

| TERM | xterm-256color | base terminal capability advertisement |

| COLORTERM | truecolor | hints 24-bit color support to programs that look for it |

| TERM_PROGRAM | K2SO | identifies us to programs that key behavior off the host (e.g., iTerm-style detection) |

Each is added via entry().or_insert_with(), so callers that

explicitly set their own values (test fixtures, future heartbeat

specifics) override our defaults cleanly.

Verification

End-to-end probe via the daemon's /cli/sessions/v2/spawn

endpoint, asking the child to echo its env:

`

TERM=xterm-256color

COLORTERM=truecolor

TERM_PROGRAM=K2SO

`

Claude Code's banner now renders in full color again.

Why we missed it

Same shape of testing gap as 0.35.0 / 0.35.1: 379 unit tests cover

library logic but don't simulate "what env does a daemon-spawned

child actually inherit?" The PATH-enrichment regression test added

in 0.35.1 caught that specific class of bug; we'd need an analogous

"verify spawned child env contains TERM/COLORTERM" assertion. Filed

alongside the broader end-to-end-spawn-probe follow-up.

Tests: still 381 (291 + 15 + 75 — all pass; no test added in this

hotfix because the fix is a 3-line literal env insert, easier to

verify by inspection than to wrap in a probe).

Download K2SO_0.35.3_aarch64.dmg (38.2 MB)

v0.35.2

April 25, 2026

# K2SO 0.35.2 — Hotfix #2 for the launchd-PATH gap

The fix shipped in 0.35.1 still left ~/.local/bin (and other

user-configured prefixes) out of the captured PATH for users whose

~/.zshrc is where their PATH augmentations live. v0.35.0's spawn

error came back. This release fixes it for real.

The miss in 0.35.1

enrich_path_from_login_shell invoked the user's shell with

-lc (login + non-interactive). On zsh, -l sources

~/.zshenv, ~/.zprofile, and ~/.zlogin — but **NOT

~/.zshrc**. zsh only sources .zshrc for *interactive* shells.

Many users (myself included) put their user-bin-dir prepends in

.zshrc:

`sh

export PATH="$HOME/.local/bin:$PATH"

`

So 0.35.1's helper *did* run and *did* set PATH on the daemon —

just to a PATH that was missing the very dirs we needed. Hence

claude (at ~/.local/bin/claude) still wasn't findable.

The fix

One character change: -lc-ilc. The -i flag tells

zsh to behave as interactive too, which makes it source ~/.zshrc.

Now the captured PATH matches what a real interactive terminal

sees — including ~/.local/bin, ~/.bun/bin, ~/.cargo/bin, npm

globals, and anything else the user adds in .zshrc.

Two ancillary tweaks went in alongside:

-Stderr from the rc-source pass is now redirected to /dev/null

so noisy plugins (oh-my-zsh, p10k, etc.) don't leak warnings

into the daemon's stderr log.

-The captured PATH is read from the *last non-empty line* of

stdout, in case the user's rc files print a banner or other

text before our printf %s "$PATH" payload.

Tests updated

-enrich_path_widens_sparse_launchd_default — unchanged (still

paves PATH down to launchd default, calls the helper, asserts

the result widens).

-enrich_path_safe_to_call_multiple_times — replaces the strict

idempotency test from 0.35.1. Some .zshrc files reorder PATH

dirs across invocations, so exact-equality after two calls was

the wrong assertion. Production calls the helper exactly once;

the test now verifies "safe to call multiple times without

crashing or zeroing PATH." Total: still 381 tests.

Why we missed it twice

Even with the unit tests in 0.35.1, this slipped through for the

same reason as the original 0.35.0 miss: cargo test runs in a

shell-rich PATH where the widens assertion passes regardless

of which rc files the helper actually sources. The test catches

"does enrich do anything" but not "does enrich pick up *all*

the user's PATH augmentations."

End-to-end coverage — boot the actual binary under launchd and

probe /cli/sessions/v2/spawn with a ~/.local/bin tool — would

catch this class of bug, and is filed as a follow-up. Won't ship

in this hotfix.

Upgrade behavior

Same as 0.35.1: the auto-updater delivers the new binary, and the

0.35.0 version-mismatch auto-restart fires when 0.35.2 boots and

finds the launchd-held 0.35.1 daemon still running.

Download K2SO_0.35.2_aarch64.dmg (38.2 MB)

v0.35.1

April 25, 2026

# K2SO 0.35.1 — Hotfix: spawning user-installed tools

Hotfix for 0.35.0: in production installs, opening a tab whose

command was a user-installed tool — claude, cursor-agent,

gemini, anything in ~/.local/bin / /opt/homebrew/bin /

/usr/local/bin — failed with:

`

v2 spawn failed: Failed to spawn command 'claude':

No such file or directory (os error 2)

`

What was happening

macOS's launchd starts K2SO.app and the k2so-daemon sidecar

with a deliberately sparse PATH (/usr/bin:/bin:/usr/sbin:/sbin).

Unlike a Terminal session, launchd does not source your

.zshrc / .bash_profile, so the prefixes you've configured for

your tools never make it into K2SO's environment. When the daemon

calls posix_spawn("claude", ...) directly, the kernel can't find

the binary.

The Alacritty (Legacy) renderer had the same gap — it just rarely

surfaced because most users invoke claude by typing it into an

already-running shell session, where shell rc files have already

done their PATH-enrichment work.

The fix

Both processes (Tauri app and daemon) now adopt the user's login

shell PATH at startup. k2so_core::enrich_path_from_login_shell

runs the user's $SHELL once with -lc 'printf %s "$PATH"',

adopts the result, and is done. Children inherit the rich PATH

through normal posix_spawn semantics — no per-spawn shell

wrapper, no per-call lookup. ~30-50ms one-time startup cost,

silent fallback if the shell exec fails.

This is the standard macOS-GUI-app pattern (used by VS Code,

Atom, Tower, GitHub Desktop, basically any .app that needs to

spawn user-installed binaries). The "heart" of the fix, not a

workaround — the actual problem is "this macOS process needs the

user's PATH," and the fix puts the user's PATH on the process.

Tests added

Two regression tests in k2so-core:

-enrich_path_widens_sparse_launchd_default — paves PATH to

the exact launchd default this incident hit, calls the helper,

asserts the result is wider.

-enrich_path_is_idempotent_on_already_rich_path — calls the

helper twice, asserts state is stable.

Total test count: 381 (up from 379).

Upgrade behavior

If you installed 0.35.0 already, the auto-updater will deliver

0.35.1 in the usual way. The version-mismatch auto-restart we

shipped in 0.35.0 itself fires here — when 0.35.1's Tauri starts

and finds the launchd-held 0.35.0 daemon still running, it

kickstarts launchd's com.k2so.k2so-daemon so the daemon

binary refreshes in-place. No manual "Settings → Restart Daemon"

click required.

What stays the same

Nothing else changed — same Alacritty_v2 renderer, same v2-perf

instrumentation, same selection-tracks-scroll, same WS Close-frame

hardening, same kessel-t0 archive layout. This is a one-line

behavioral fix plus regression tests.

Download K2SO_0.35.1_aarch64.dmg (38.2 MB)

v0.35.0

April 25, 2026

# K2SO 0.35.0 — Alacritty_v2 daemon-hosted terminal

This release introduces Alacritty_v2, a new terminal renderer

that runs on the K2SO daemon instead of inside the Tauri process.

Sessions survive app quit, heartbeats can target them naturally,

and the on-screen experience is byte-identical to the legacy

renderer. It ships as an opt-in choice in Settings — "Alacritty

(Legacy)" remains the default while we burn it in.

Highlights

Alacritty_v2 — daemon-hosted terminal renderer

Pick "Alacritty" in Settings → Terminal → Renderer to opt in.

Once selected, new terminals open against the daemon-hosted

path; existing tabs keep whichever renderer they were created

with.

What this gives you:

-Sessions survive Tauri quit. The daemon owns the PTY master;

closing the K2SO window doesn't SIGHUP your shell.

-Heartbeats work natively. The daemon-hosted Term is a

first-class session in v2_session_map, so wake-triggered

signals can target it without any in-process coordination.

-UX parity with legacy. Scroll, reflow on resize, scrollback,

Cmd-click links, paste from Finder, drag-and-drop, focus

retention across split panes, Cmd+Shift+=/− zoom — all match

the legacy renderer exactly.

The daemon serves a single Tauri-side viewer per session over

/cli/sessions/grid (WebSocket), streaming JSON snapshot + delta

payloads from the alacritty Term. No local Term, no local ANSI

parser, no APC coordination on the client.

Daemon version-mismatch auto-restart

When you install a new K2SO over an old one, macOS lets the new

binary land on disk while launchd keeps the old daemon

process alive. Until now, a Settings → Restart Daemon click was

required to actually pick up the new binary. Now Tauri's startup

checks the daemon's reported version against its own and runs

launchctl kickstart automatically if they disagree. Look for

[version-check] MISMATCH … in the Tauri stderr the first time

this fires.

Selection now tracks scroll

In Alacritty_v2: highlight some text, then mouse-wheel-scroll the

viewport. The highlight now rides along with the content

visually, instead of staying at the screen position the original

text used to occupy. Native gestures — double-click word,

shift-click extend, Cmd+A, Cmd+C copy — keep working.

Cleaner WebSocket teardown

The daemon's grid-WS now sends a Close frame before tearing down

TCP, both on client-initiated close and on child_exit. WebKit

no longer logs spurious "The network connection was lost" / "ws

error" when a child process exits or a tab unmounts.

`[v2-perf]` launch-time instrumentation

Every Alacritty_v2 spawn now emits [v2-perf] log lines at each

stage — pty_open, term_new, event_loop_spawn, ws_accept,

first_snap — on the daemon side, plus matching frontend stages

(mount, creds, spawn_fetch, ws_open, first_snapshot,

first_render, tui_first_paint) in DEV builds. A one-shot

SUMMARY line at first render gives the full breakdown.

Measured cold-spawn-to-first-render: ~50 ms. Warm: ~25 ms.

The remaining 1-second-or-so before your prompt appears is your

shell's startup, not K2SO.

Internal — Kessel-T0 archived

The Kessel byte-stream reader path (where each Kessel pane hosted

a local mini-alacritty Term and consumed raw PTY bytes from the

daemon) was paused after 0.34.x — the byte-level approach turned

out not to be a viable foundation for the intended use cases.

Sixteen commits of stabilization work from that direction are

preserved on the kessel-t0 branch and the kessel-t0-archive

tag, both reachable from main's merge graph. The two

"what we learned" docs were cherry-picked forward:

-.k2so/prds/kessel-resize-architecture-notes.md
-.k2so/prds/kessel-instant-everywhere.md

The latter's UX-feel principles still apply to any future

renderer work, including post-0.35.0 v2 perf.

Notes

-Alacritty_v2 is opt-in in this release. The default stays

on Alacritty (Legacy). We'll flip the default once v2 has

burned in across more workflows.

-Kessel (renderer) sessions are unaffected by this release.
-CLI tools that spawn agent sessions still use the legacy

renderer; routing them through v2 is queued as a follow-up.

Download K2SO_0.35.0_aarch64.dmg (38.2 MB)

v0.34.2

April 23, 2026

# K2SO 0.34.2 — Full conversation on resume

Kessel now shows the complete conversation in scrollback when

you resume a Claude Code session, instead of just the most recent

few lines. The daemon captures Claude's full cold-start paint at

an oversized canvas, then cleanly seals that content into

scrollback before handing the pane off at your real window size.

Highlights

Full conversation history on `claude --resume`

Previously, resuming a session painted Claude's current UI at 24

rows and nothing else — all the prior conversation lived in

Claude's state but never made it into the terminal. Now:

-Daemon spawns every session at an oversized canvas so Claude

paints its full conversation context.

-A grow_boundary marker cleanly separates the grow-phase paint

from the live session — no garbled "two renderings colliding"

artifacts.

-Client pushes all grow-phase content into scrollback before

Claude's post-SIGWINCH repaint can clobber it. Scroll up — the

whole conversation is there.

Works for fresh spawns, resumes, heartbeat-triggered wakes, and

every subscriber that attaches after spawn time. No special path

for the first viewer vs the hundredth.

Scroll fixes

-Bottom row staleness is gone. Scrolling up into scrollback

then back down now re-renders the revealed rows correctly

instead of keeping the pre-scroll content. The row-memo

comparator now checks reference identity, not just the damage

flag.

-Cursor follows the viewport. When you scroll into scrollback,

the cursor stays visible at its translated position instead of

disappearing. It only hides when scrolled past the cursor row.

Settle behavior fix (resume reliability)

Dropped the bracketed-paste fast-settle path that was firing

during Claude's cold-start — before Claude had read the saved

conversation from disk. Now settle is idle-only (400ms quiet

after the first frame) plus a 3s ceiling. Adds ~300ms to fresh-

launch spawn time in exchange for correct resume capture. A

worthwhile trade — the resume path is the whole point.

Observability

Daemon logs now surface the ring state end-to-end:

-`grow-shrink: session X settled via Y — ring before shrink:

frames=N text=T mode=M bytes~=B subscribers=S`

-emitted grow_boundary frame (target=CxR, grow_rows=500)
-`subscriber for X will drain replay: frames=N text=T

text_bytes=B mode=M grow_boundary=Y/N`

Enough to diagnose "did the ring have the data?" vs "did the

client render it?" independently. Visible in

~/.k2so/daemon.stderr.log.

Architecture notes

Full design of the next steps is captured in

.k2so/prds/canvas-plan.md. 0.34.2 ships Phase 1 of that

plan (the seam fix); Phases 2-5 add a byte-stream subscription

tier and a Tauri-local alacritty_terminal::Term per Kessel

pane so reflow, selection, and scrollback become first-class

instead of handwritten. Not in this release; tracked separately.

Bug fixes

-sealGrowPhase — fixes the specific case where the bottom

24 rows of a grown conversation were being wiped by Claude's

post-SIGWINCH ClearScreen. Full conversation now lands in

scrollback intact.

-Safety fallback on older daemons: if the client doesn't see a

grow_boundary marker within 3 s of the subscribe ack, it

measures the real container dims and falls back cleanly.

-DOM selection is cleared on scroll so highlights don't desync

from their content. Proper follows-content behavior will land

in Phase 5.

Known limitations

-Scrollback doesn't reflow on window resize. The grid grows

and shrinks correctly at the cell level, but scrollback rows

retain their capture-time width. The Canvas Plan (Phase 4-5)

solves this by moving each Kessel pane onto a local

alacritty_terminal::Term, which reflows natively.

-Selection drops on scroll rather than following content. A

proper content-space selection overlay is in Phase 5.

-For CUP-based TUIs (Claude, htop), scrollback is a moment-

capture at spawn time, not a continuous feed — Claude's post-

boundary UI updates don't add to scrollback. The PRD's T1

(stream-json) path is the long-term answer for unlimited

semantic scrollback.

Install

Same as prior releases — download the signed, notarized DMG from

the GitHub release page. In-app auto-updater will pick up 0.34.2

automatically if you're on 0.34.0 or later.

Download K2SO_0.34.2_aarch64.dmg (38.0 MB)

v0.34.1

April 22, 2026

# 0.34.1 — Kessel goes from BETA to "feels like a real terminal"

> tl;dr 0.34.0 shipped the Session Stream pipeline and the new Kessel React terminal renderer as an opt-in BETA, with a documented list of fidelity gaps. 0.34.1 closes almost all of those gaps and pushes Kessel's launch latency to parity with Alacritty. It's still BETA, still opt-in — but picking Kessel no longer means trading correctness or speed for the chance to try something new.

0.34.0's release notes ended with: *"All of these are fidelity issues, not correctness bugs. Users who need them today pick Alacritty. 0.34.N is where the polish lands."* This is that release.

What the user feels

1. Launches at parity with Alacritty

Real-numbers goalpost, measured on an M-series Mac with Claude Code as the worst-case TUI:

| Metric | 0.34.0 Kessel | 0.34.1 Kessel | Alacritty |

|---|---|---|---|

| First-tab spawn → cursor visible | 3000–10000 ms | 80–120 ms (warm) | 80–120 ms |

| Cmd+T → Claude tui-ready | ~3600 ms | 463–510 ms | 402–405 ms |

| Typing latency | visibly laggy | at parity | baseline |

The fix was roughly 10 different things, not one: a persistent reqwest::blocking::Client in Tauri (kills the 600ms tokio-cold-start on first spawn), an http_client() pool-warm at Tauri main(), cached daemon creds that skip the disk read after the first call, an O(1) in-memory counter for pending-live queue drains (avoiding a directory scan on every spawn), an optimistic pane mount that draws the grid + cursor before the daemon round-trip returns, opt-in Alacritty Term dual-parse so production sessions skip a 4.6× system-time tax, and a fix to the macOS launchd ProcessType that had been silently pinning Claude to low-priority scheduling (this alone was the 3-second Claude lag).

2. Vim and Claude Code both render correctly now

Alt-screen buffer (DECSET ?1049 / ?47) is wired. vim, htop, less, tmux's alt-screen mode, and Claude Code's interactive panel all switch buffers cleanly and restore on exit. Scrollback is suppressed inside alt-screen (matches every real terminal).

3. Paste works

Bracketed-paste mode (DECSET ?2004) is honored. Paste into Claude Code, into zsh, into vim insert mode — each gets the right \e[200~ / \e[201~ framing so the receiving program knows it's paste, not typing. Natural text-editing chords (Cmd+Backspace, Option+Left/Right, Cmd+Arrow) now produce the right sequences.

4. Cursor behaves

-Always visible. Claude Code and other TUIs hide the cursor with DECTCEM so they can paint their own — but our DOM pane had no way to render that custom glyph, so users "lost the cursor in Claude Code." Kessel now ignores DECTCEM and always shows its own caret. Solid when the tab has focus, hollow outline when it doesn't (matches the native macOS text-input caret convention).
-No more blink, no more hopping. Rosson specifically asked for a stable solid caret; that's what you get. The resting-cursor state machine defers rendering intermediate positions when a TUI repaint bursts a cursor through several locations (Claude's bottom-border refresh used to make the caret visibly shoot to row 0 and back). Small moves (typing, Enter, line wrap) commit immediately; large moves wait 60ms for the burst to settle. Same-row snaps to col=0 also settle — that was the "cursor jumps onto the > prompt for a frame" pattern in Claude Code's input repaint.
-DECSCUSR shape. CSI Ps SP q is honored, so vim's mode indicator (block in normal mode, bar in insert) works.
-DECSC/DECRC + ESC 7 / ESC 8. Legacy save/restore cursor sequences wired.

5. Trackpad scroll isn't insane

The old handler treated every wheel event as a discrete tick advancing 3 lines. On macOS trackpads, which fire 30-60 pixel-delta events per swipe, that meant one swipe scrolled 90-180 lines. Fixed by porting Alacritty's approach: accumulate pixel deltas, flush every 50ms, convert to lines at 1 line per cellHeight. A 100px swipe on a 20px cell now scrolls 5 lines — matching every native macOS text view. The scrolling.multiplier config is now a sensitivity scalar (1.0 = Alacritty-equivalent, default).

6. Scrollback doesn't render at the wrong width

Rows pushed to scrollback while the grid was narrower (e.g., during the pre-first-ResizeObserver window at 80 cols) used to keep their narrow cell count forever — scrollback rendered as a short-line block until enough new content rolled through to replace them. grid.resize() now also pads/trims every scrollback row to the current cols, so any resize event makes the entire viewport consistent.

*(True soft-wrap re-flow — re-joining a logical line that was split across two narrow rows — is a harder problem parked for the Phase 4.7 word-editor pass.)*

7. Bell, focus reporting, synchronized output, autowrap

Small but real:

-Bell (\x07). Configurable: visual flash, audio chirp, or both. No more silent bells.
-Focus reporting (DECSET ?1004). When neovim or tmux asks for it, focus/blur events write CSI I / CSI O so the TUI can dim its UI while unfocused.
-Synchronized output (DECSET ?2026). BEGIN_SYNCHRONIZED_UPDATE / END_SYNCHRONIZED_UPDATE sequences buffer writes at the grid layer so a full repaint commits atomically to the DOM. Kills mid-repaint flicker.
-Autowrap (DECSET ?7) + application cursor keys (DECSET ?1) both honored.

8. Keyboard bugs that were silently broken

-Backspace. LineMux was popping bytes from pending_text on \x08, which ate the BS before it reached TerminalGrid — so the shell saw the keystroke but the pane didn't. Fix: pass \x08 through to the grid's writeChar. Fixes up-arrow history replay as well.
-Ctrl+Backspace. Sends ESC+DEL (backward-kill-word), matching Alacritty. Previously sent plain \x08 which shells interpret as single-char delete.
-Shell was in canonical mode. Bare-shell spawn now uses -il so zsh loads .zshrc and ZLE activates — backspace, history, word-motion all work as expected.

9. FD leak plugged

Each Kessel tab held a PTY master FD + reader thread + archive handle. Closing the tab wasn't telling the daemon to tear down the session, so resources piled up. At ~14 open tabs the per-process FD limit (ulimit -n default 256) hit and every further spawn failed with dup of fd 255 failed. Fix: kessel_close IPC on tab unmount → /cli/sessions/close → session teardown.

10. Config surface for customization

New KesselConfig + React context:

`ts

{

font: { family, lineHeightMultiplier, ... }

colors: { foreground, background, palette, cursor, selection }

scrolling: { cap, multiplier }

cursor: { defaultShape, settleMs, blinkIntervalMs, thickness, ... }

bell: { mode: 'none' | 'visual' | 'audio' | 'both', durationMs }

mouse: { ... }

performance:{ ... }

}

`

Every knob has a sensible default, so users see zero change unless they opt in to customize. Foundation for user-facing settings in a later release.

Still not in Kessel (carried forward to 0.34.2+)

-Mouse reporting (tmux scroll-wheel forwarding, click-to-position-cursor). Will need /cli/sessions/write-mouse and an X10/SGR encoder on the renderer side.
-True scrollback re-flow. See above — the "pad rows to new width" fix handles the visible gap, but doesn't re-join soft-wrapped logical lines. Requires tracking the "this row wraps into the next" bit at write time.
-Theme support in the config. The colors surface exists; wiring it to user-selectable themes lands later.
-Phase 3.2 hardening (harness watchdog, archive rotation, per-coordination-level budgets, real scheduler-wake of offline agents) partially landed under G1–G6 but the manual smoke-test still owes Rosson a three-terminal verification pass.

Under-the-hood highlights

Launch-perf plumbing

-src-tauri/src/commands/kessel.rs — new kessel_spawn, kessel_write, kessel_resize, kessel_close, kessel_warm_http commands. All share a persistent http_client() OnceLock with pool_idle_timeout=60s. DaemonCreds cached via creds_cache() RwLock so the second tab's spawn skips the disk read entirely.
-src-tauri/src/main.rswarm_http_pool_async() kicks off the reqwest runtime during Tauri boot so the first kessel_spawn doesn't pay the tokio cold-start.
-crates/k2so-daemon/src/pending_live.rspending_state() OnceLock counter cache avoids the per-spawn read_dir of the pending-live queue directory (was ~30ms per spawn, now O(1) when queue empty).
-crates/k2so-core/src/terminal/session_stream_pty.rs — new SpawnConfig.track_alacritty_term: bool flag. Production defaults to false, skipping the dual alacritty_terminal::Term::advance(...) pass that was burning 4.6× more system time than the PTY reader. Tests still opt in for screen-scrape verification.
-crates/k2so-core/src/wake.rs — launchd plist now ships Interactive instead of Background. Was the single biggest contributor to Claude's 3-second lag (macOS was giving the daemon's PTY child — including Claude's SIMD paths — low-priority scheduling).

Parser / renderer coverage

-LineMux — added dispatch for DECSET ?1, ?7, ?1004, ?2026, ?25, ?1049, ?47, ?2004; DECSCUSR via CSI+space intermediate; ESC 7 / ESC 8; bell. Bug fix: the \b byte is now pushed to pending_text (was popped, eating it).
-SessionStreamView.tsx — resting-cursor settle state machine (including same-row col=0 settle), focus tracking for the solid/hollow cursor, WS frame batching per requestAnimationFrame, line-level damage tracking (per-row React.memo predicate — ~22× fewer cell iterations per keystroke), optimistic pane mount during spawn, 50ms pixel-accumulator wheel handler.
-grid.ts — synchronized-output buffer, DECTCEM / DECSCUSR state, autowrap, scrollback-row resize.

Benchmarks + instrumentation

-crates/k2so-core/examples/kessel_spawn_bench.rs — head-to-head zsh -ilc exit spawn benchmark. Result: Kessel 177ms vs Alacritty 181ms (not the bottleneck).
-scripts/kessel-ui-bench.ts — UI-layer latency bench (WS round-trip, throughput, stale-pane). p50 WS round-trip = 1ms (also not the bottleneck).
-First-frame + tui-ready timing instrumentation in both renderers, shared predicate (any of alt_screen / bracketed_paste / focus_reporting mode transition). Console log:

`

[Kessel] ready tab-abc e2e=103ms total=85ms rust=31ms

(creds=0ms ser=0.1ms http=15ms resp=9.4ms de=0.5ms)

[Kessel] tab-abc first-paint≈108ms (Cmd+T → cursor visible)

[Kessel] tab-abc tui-ready=463ms (alt_screen ON → TUI is interactive)

`

Upgrade path

Bit-for-bit compatible with 0.34.0. Flag-off (default renderer = Alacritty) users see zero change. Users who flipped the Settings → Terminal → Terminal Renderer toggle to Kessel in 0.34.0 will pick up all of the above automatically.

Rollback knobs preserved:

-Per-project use_session_stream='off' setting — legacy path for any workspace that hits a Kessel edge case.
---no-default-features at build time — disables the Session Stream feature entirely.
-git reset --hard v0.33.0 + rebuild — nuclear.

Credits

49 commits on feat/session-stream since 0.34.0 by Rosson Long + Claude (Opus 4.7, 1M context). Work spanned Phase 4.6 (Kessel parity + polish: cursor, alt-screen, paste, bell, config, keyboard fidelity) and the launch-perf optimization pass (L1.1 / L1.4 / L1.5 / L3.1 + reqwest-warm + process-priority + FD-cleanup). Several bugs were caught by Rosson running real workflows in the dev build — the three-second Claude lag, the FD-exhaustion crash at ~14 tabs, the cursor jumping to col=0, the scroll oversensitivity, the narrow scrollback after resize.

Manual smoke verification: terminal renderer, Claude Code launch, backspace, up-arrow history, trackpad scroll, window resize, cursor-follow, alt-screen entry/exit. Three-terminal awareness-bus end-to-end (Phase 3 signal path) still pending a dedicated smoke — next up.

Download K2SO_0.34.1_aarch64.dmg (38.0 MB)

v0.34.0

April 21, 2026

# 0.34.0 — Session Stream + Kessel: a second terminal renderer

> tl;dr The daemon now owns every /cli/* route (Tauri is a pure HTTP/WS client), every PTY session is observable by any subscriber via a typed Frame stream, and there's a new React terminal renderer (Kessel) that you can toggle on per-preference. Alacritty stays the default — Kessel is explicitly BETA and opt-in. 1045+ tests green. Five months of architecture work, shipped.

This is the pipeline release. 0.33.0 made the daemon persistent; 0.34.0 makes it the *only* HTTP server and gives every byte that flows through it a typed, subscribable shape. Every new capability we build from here (highlight-and-ask-Claude, timeline scrubber, mobile companion parity, Metal rendering) derives from the Session Stream primitive this release introduces.

The product headlines

1. Daemon-complete architecture

Tauri no longer binds a TCP listener. Every /cli/* route in K2SO is served exclusively by k2so-daemon. The Tauri desktop app is a pure consumer: HTTP client for commands, WebSocket client for streams. This means:

-Lid-closed operation. Agents, scheduled heartbeats, companion sessions, every k2so msg and k2so agents triage — none of them require the K2SO window to be open. The daemon runs them.
-Thin clients are first-class. The same WebSocket protocol Kessel uses is available to any mobile companion, web viewer, or remote-attach CLI tool. They all see the same Frame stream.
-Remote connection story is unblocked. Daemon on workstation, viewer on phone over tailscale — same protocol as local.
-Open-core split mechanical. The daemon is MIT. Premium UI viewers can live in a separate crate without compromising the open primitive.

2. Session Stream (Frame pipeline)

Every PTY session the daemon owns is now a subscribable Frame stream. Consumers subscribe to /cli/sessions/subscribe over WebSocket and receive typed events:

`

Frame::Text { bytes, style } — UTF-8 + SGR colors + attrs

Frame::CursorOp { Goto/Up/Down/EraseInLine/... }

Frame::SemanticEvent { kind, payload }

Frame::AgentSignal { ... }

Frame::RawPtyFrame { bytes } — opaque passthrough

`

The archive writer writes these to NDJSON on disk (rotating), so every session is replayable. The awareness bus fans signals out to subscribers with Live (real-time inject) vs Inbox (async notice) semantics.

3. Kessel — a second terminal renderer (BETA)

Settings → Terminal → Terminal Renderer now lets users pick between:

-Alacritty (legacy) — the classic in-process alacritty_terminal engine + DOM renderer. Production-hardened; the default. Unchanged behavior for every user.
-Kessel (BETA) — subscribes to the daemon's Session Stream WebSocket and renders from Frame events. Device-agnostic (no Tauri coupling), SGR-colored, cursor-blinking, paste-enabled.

Flipping the toggle only affects new terminals; existing tabs keep the renderer they were created with. Users who hit any Kessel gap can flip back and keep working.

Known Kessel gaps (documented in-app as BETA):

-Cursor can "hop" during rapid Claude output (500ms blink masks most of it).
-Alt-screen buffer (vim / htop) not yet wired — full-screen TUIs will look garbled.
-Bracketed-paste mode not honored (raw pastes still work fine for bash/zsh/claude single-line).
-Mouse reporting (tmux scroll-wheel) not forwarded.

All of these are fidelity issues, not correctness bugs. Users who need them today pick Alacritty. 0.34.N is where the polish lands.

4. Harness Lab (Cmd+Shift+K)

A dedicated sandbox for testing new TUIs or previewing mobile-companion layouts. Drop in a command, hit Spawn, see a live Kessel pane. Independent of your real tabs — safe to experiment without affecting your project terminals. Intended to become K2SO's "tuning bench" for future harness adapters (Phase 6).

5. Awareness Bus primitives

Cross-agent signals are now a first-class primitive. The CLI's k2so msg routes through it (no more Tauri-only /cli/msg handler); the bus handles Live vs Inbox delivery, pending-live durability (signals persist if the target is offline), and audit (every signal writes an activity_feed row).

6. Tiered CLI help

k2so help shows the ~20 verbs a Workspace Manager or custom agent uses day-to-day. k2so help --advanced surfaces the full surface — heartbeat schedule CRUD, daemon lifecycle, session plumbing, hook diagnostics. Less overwhelming on first encounter.

7. Test suite expansion

-445 core integration tests (from 275 in 0.33.0)
-79 daemon integration tests (from 48)
-70 Kessel TypeScript unit tests (new)
-531 shell-based behavior tests
-Total: 1045+ passing

A new tests/README.md maps the full testing surface for future contributors.

What's new under the hood

Kessel module (`src/renderer/kessel/`)

`

types.ts Frame / Style / CursorOp wire types (TS mirror)

client.ts KesselClient — WS lifecycle, envelope parsing

grid.ts TerminalGrid — 2D cell + cursor + scrollback

style.ts SGR → React.CSSProperties

selection.ts Pure selection geometry (for future programmatic features)

SessionStreamView.tsx The pane — renders grid, handles keys/resize/paste

KesselTerminal.tsx Tab-pane wrapper — spawns on mount, feeds SessionStreamView

HarnessLab.tsx Cmd+Shift+K sandbox

`

All gated behind the user-preference renderer toggle. Dead-code for anyone using Alacritty.

SGR parsing in LineMux

The daemon's LineMux now maintains an SGR state machine — it parses ESC[m sequences and populates Style on Frame::Text. Supports:

-16-color palette (30-37, 40-47) + bright variants (90-97, 100-107)
-256-color (ESC[38;5;N)
-Truecolor (ESC[38;2;R;G;B)
-Bold / italic / underline
-Reset + attribute-off codes

Wire format matches alacritty's Style struct so a future consumer could feed either source into the same renderer.

Daemon is the sole HTTP server (Phase 4 migration)

H1 — H7 of Phase 4 moved every /cli/* route from src-tauri/src/agent_hooks.rs (which is now dead code) into k2so-daemon. heartbeat.port / heartbeat.token are owned by the daemon; Tauri reads them to populate its in-process hook_config for alacritty child-env injection.

New daemon routes (all of them serving what the old Tauri HTTP listener served):

`

/cli/terminal/read H1 — replay frames as displayable lines

/cli/terminal/write H1 — PTY bytes in

/cli/terminal/spawn{,-background} H3 — spawn via daemon

/cli/agents/running H2 — session_map enumeration

/cli/agents/{launch,delegate} H5 — daemon-owned agent launches

/cli/companion/{sessions,projects-summary} H4 — cross-workspace queries

/cli/sessions/resize I7 — resize a live session's PTY

/cli/sessions/subscribe D4 — the Frame stream WS

`

Full Phase 4 completion note at .k2so/notes/phase-4-daemon-standalone-complete.md.

Phase 4.5 completion note at .k2so/notes/phase-4.5-kessel-complete.md.

What's NOT in 0.34.0 (deferred to 0.34.N / later)

-Alt-screen buffer / bracketed-paste / mouse reporting in Kessel. 0.34.1 polish targets.
-Theme support in Kessel. xterm-defaults only for now.
-Tier 1 harness adapters (stream-json per-harness for Claude Code / Codex / etc.). Phase 6; 0.34.1 / 0.35.0 target.
-Metal punch-through renderer (Phase 8). 0.34.2 target.
-Pi extension pack (Phase 7). Deferred indefinitely.
-Alacritty removal. Intentionally kept as a fallback alongside Kessel. Reframed as "renderer options" instead of a phase gate.

Upgrade path

Backwards compatible with 0.33.0. Every user sees zero change unless they:

-Open Settings → Terminal and flip "Terminal Renderer" to Kessel (BETA).
-Open the Harness Lab (Cmd+Shift+K).

For everyone else, terminals render exactly as they did in 0.33.0 — same alacritty engine, same DOM spans, same behavior.

Credits

65+ commits on feat/session-stream by Rosson Long + Claude (Opus 4.7, 1M context) across Phases 1 → 4.5. Commit ladder organized as C1-C6 (Tier 0 producer), D1-D7 (Session Stream), E1-E8 (Awareness Bus), F1-F3 (live inject + durability), G0-G6 (hardening), H1-H7 + 4 fixes (daemon migration), I1-I11 + 7 fixes + 2 polish (Kessel). See .k2so/notes/phase-*-complete.md for the detailed per-phase narratives.

Download K2SO_0.34.0_aarch64.dmg (37.9 MB)

v0.33.0

April 20, 2026

# 0.33.0 — Persistent agents: K2SO Server that outlives the window

> tl;dr K2SO now ships a second bundled binary — k2so-daemon — that runs as a launchd agent. Close the K2SO window and the agents, terminals, heartbeats, and mobile-companion server keep running. Open the window later and you reconnect to everything in progress. A menubar icon keeps the server visible while the window is hidden, and Cmd+Q / the red close button now do two distinct things (both user-configurable).

This is the architecture release. 0.32.13 proved the core was fast enough. 0.33.0 rebuilds how K2SO is *shaped*: a persistent device-local service with a thin Tauri UI client, not a single process whose lifetime is tied to an open window.

The product headline

Your agents keep running when the laptop lid is closed.

Before 0.33.0, closing the K2SO window stopped every agent, every heartbeat, every companion session. The window *was* the product. Now:

-Close the K2SO window (red button) → the K2SO Server keeps running in the background. Agents continue. Heartbeats fire. Mobile companion stays connected.
-Open the K2SO window later → reconnects to the server. Agent state is live, not stale, not reloaded from scratch.
-Lock the Mac and walk away → the daemon keeps working. Close the lid, agents pause (macOS sleep); open the lid, agents resume.
-Cmd+Q → quits *everything* (window + server). Deliberate full shutdown.

No other open-source AI workspace tool ships this today.

What's new in the UI

Menubar icon

K2SO now installs a template icon in your menubar. It's visible whether the K2SO window is open or hidden. Click it to see:

-Server Status — Running (up Xm) with a green square, Unreachable with orange, or Not running with red.
-Ngrok URL — your mobile-companion tunnel address, if active.
-Connected parties (N) — live list of mobile companions currently subscribed.
-Quit K2SO — deliberate full shutdown (same as Cmd+Q).

Settings → General → K2SO Server

A new pane shows the daemon's status at a glance: Running/Unreachable/Not installed, PID + uptime, and a "Restart" affordance when you need to recycle the process. The View log link tails ~/.k2so/daemon.stdout.log inline — no terminal required for triage.

"Keep server running when the window is closed" toggle

Also in Settings → General. Controls what the red close button does:

-ON (default) — red button hides the window and keeps the K2SO Server running. Menubar icon stays visible. Reopen K2SO from the Dock to come back.
-OFF — red button behaves like a full quit, same as Cmd+Q. Useful if you'd rather not have a background process after closing the window.

Cmd+Q always quits everything regardless of the toggle. Industry-standard behavior for the power-user shortcut.

Auto-start on every launch

On every K2SO launch, the Tauri app checks whether the daemon plist is loaded in launchctl. If not (because you red-buttoned with the toggle OFF, or rebooted), it re-loads it automatically. You should never have to manually restart the server.

What's new under the hood

Cargo workspace + daemon crate split

The source tree splits into three Rust crates:

`

crates/

k2so-core/ — pure library. db, llm, terminal, companion,

agent_hooks, scheduler, push, perf. No Tauri dep.

k2so-daemon/ — tokio async binary. Owns the persistent-agent

runtime: scheduler, companion tunnel, push targets,

HTTP/WS server, launchd plist lifecycle.

src-tauri/ — thin Tauri client. Window + menus + UI commands.

Proxies state-mutating work through the daemon.

`

Both binaries link k2so-core. The daemon launches under launchd (~/Library/LaunchAgents/com.k2so.k2so-daemon.plist) with RunAtLoad: true + KeepAlive: true — crash-safe, login-start, survives Tauri process recycling.

Device-local, no cloud dependencies

K2SO 0.33.0 does not depend on any Alakazam Labs servers. Full stop. The daemon runs entirely on your machine. The mobile companion connects through your own ngrok tunnel.

Push notifications use a pluggable PushTarget trait. v1 ships with three implementations:

-NoOp (default) — no push. Mobile companion sees updates when it's foregrounded.
-Webhook — you provide a URL; the daemon POSTs agent events there.
-NtfySh — self-hosted push via ntfy.sh on your own infrastructure.

A future "K2SO Cloud Push" (paid subscription, Alakazam Labs APNs sender) is designed as a fourth PushTarget::K2soCloud impl that drops in without core or daemon changes. Not part of 0.33.0.

HTTP IPC between Tauri and the daemon

The daemon serves a token-authed HTTP server on 127.0.0.1:. Tauri commands proxy state-mutating work to the daemon through this endpoint. Same auth pattern as the existing agent_hooks bridge in 0.32.x — extended, not reinvented.

60 /cli/* routes ported to the daemon (see the persistent-agents PRD at .k2so/prds/persistent-agents.md for the full list). Every CLI verb (k2so work create, k2so delegate, k2so review approve, k2so agents running, etc.) now talks to the daemon. The CLI surface and behavior are unchanged — only the process that handles the request has moved.

/hook/* routes (agent lifecycle webhooks) also moved to the daemon, so Claude Code sessions can fire hooks whether or not the Tauri app is open.

Daemon → Tauri WebSocket event channel

The daemon broadcasts events (agent state changes, heartbeat fires, companion session join/leave) to any connected Tauri client via a WebSocket. The UI updates in real time. Multiple Tauri clients *can* connect simultaneously (groundwork for future multi-window scenarios).

launchctl lifecycle model

-First install — Tauri runs the install_daemon_plist_v2 code migration. Writes the plist under ~/Library/LaunchAgents/ and loads it. Idempotent.
-Every launch — Tauri runs ensure_loaded(): checks launchctl list com.k2so.k2so-daemon, loads the plist if not already loaded. Safe no-op when the daemon is alive.
-Uninstall — handled automatically when you remove K2SO.app. No separate uninstall flow needed in-product.

macOS signing + notarization

Both k2so (Tauri app) and k2so-daemon are signed with hardened runtime and notarized via Apple. The scripts/release.sh pipeline bundles, signs, and notarizes both binaries.

Tests & verification

All suites pass on feat/persistent-agents prior to release:

| Suite | Count | Result |

|---|---|---|

| cargo test --workspace --lib | 291 (62 src-tauri + 229 k2so-core) | 0 failed |

| bunx tsc --noEmit | — | clean |

| tests/behavior-test-tier3.sh | 385 passed, 0 failed, 4 skipped | skips are retired LLM-triage paths |

| tests/cli-integration-test.sh | 111 passed, 0 failed | — |

Per-release performance envelope from 0.32.13 still applies — no regressions measured.

Breaking changes

None for end users. CLI behavior is preserved. Settings format is additive (new keep_daemon_on_quit key defaults to true). The agent_hooks HTTP token/port files (~/.k2so/heartbeat.port, ~/.k2so/heartbeat-token) continue to work; the daemon now also publishes ~/.k2so/daemon.port and ~/.k2so/daemon.token for HTTP IPC.

Known limitations (deferred to 0.33.1+)

-heartbeat.port can drift out of sync with the live daemon when the daemon restarts (port rotates). CLI tooling that reads heartbeat.port may hit stale ports until the next heartbeat-port sync. Documented in .k2so/notes/daemon-ux-followups.md.
-Locked-screen push notifications — platform constraint (iOS blocks non-APNs delivery to a locked device). True "phone rings in your pocket" delivery requires APNs, which is out of scope for the device-local v1. Power users can wire up PushTarget::Webhook or NtfySh for their own push flow today.
-Windows / Linux daemons — macOS-first release. systemd --user (Linux) and Windows service equivalents are follow-on work.

Rollback

v0.32.13 (tag + DMG) remains the permanent rollback target on GitHub. If you hit a blocker in 0.33.0, downgrade to 0.32.13 — schema migrations in 0.33.0 are additive (new columns only), so the old binary still reads the database.

To fully revert: uninstall ~/Library/LaunchAgents/com.k2so.k2so-daemon.plist via launchctl unload + rm, then reinstall 0.32.13.

Next

0.34.0 is queued up: Session Stream + Awareness Bus — device-local primitives that let multiple clients subscribe to the same PTY session without reflow fighting, and let agents discover each other's work without polling. PRD draft at .k2so/prds/session-stream-and-awareness-bus.md.

Download K2SO_0.33.0_aarch64.dmg (37.6 MB)

v0.32.13

April 19, 2026

# 0.32.13 — Performance pass (P0 + P1 + P2), measured end-to-end

> tl;dr The terminal-grid change detection path goes from SipHash over every line every 100ms to a single u64 compare (~9,000× faster for the per-tick work). SKILL.md regeneration moves off the startup critical path — cold boot drops from 3.83 s to perceived ~30 ms on the measured test setup. File-watcher IPC pressure goes from up to 592 emits per window to 1 under heavy save storms. One-time migrations now self-gate; future launches skip them instead of rescanning every project. SQLite gets a pragma pass (busy_timeout 500ms, 20 MB cache, 64 MB mmap, memory temp store) + prepare_cached on the hot INSERT/UPDATE paths. Bench evidence in src-tauri/benches/perf.rs — criterion saves baseline + delta under target/criterion/report/.

A staged performance pass landing three phases in one release, benchmarked against five reference Rust projects (Zed, WezTerm, Spacedrive, Helix, ripgrep) and verified with live instrumentation in dev builds.

Instrumentation (P0) landed first so every subsequent change has a measurable before/after number — the table at the bottom is the actual evidence, not an estimate.

Phase breakdown

P0 — Measurement foundation (instrumentation only, no behavior change)

Added src-tauri/src/perf.rs — a small measurement module with two primitives:

-perf_timer!(name, { block }) — single-call timing, logs elapsed microseconds through log_debug!.
-perf_hist!(name) — RAII guard that drops into a process-global rolling histogram (last 500 samples → p50/p99/mean/count/min/max, auto-flushes a summary line every 100 observations).

Gated on cfg(debug_assertions) || K2SO_PERF env var — zero cost in release builds without the env var.

Instrumented 8 hot paths + 5 startup phases:

-terminal_poll_tick (the 100ms companion poll loop)
-grid_hash (the DefaultHasher change-detection block)
-broadcast_grid (WS grid broadcast including reflow + encode)
-reflow (grid reflow per client)
-scheduler_tick (agent heartbeat scheduler)
-file_search (fuzzy-file LLM tool)
-fs_watcher_batch (per-window batch size + emit count)
-Startup: db_init, migrate_window_state, migrate_workspace_layouts, skill_regen_loop, setup_total

4 new histogram unit tests, 188 total lib tests green.

P1 — Quick wins

*(landed in same release — numbers in table below)*

-P1.1: DefaultHasher (SipHash) → ahash::AHasher for grid change detection. ~3× faster non-cryptographic hash; we don't need DOS resistance on a local change-detection path.
-P1.2: SQLite PRAGMA expansion — busy_timeout 5000ms → 500ms (matching Zed; 5s was masking real contention as UI hangs); added cache_size = -20000 (20MB), mmap_size = 67108864 (64MB), temp_store = MEMORY. Free wins both Zed and Spacedrive run with.
-P1.3: prepare_cached for the hot SQL queries (agent_sessions INSERT/UPDATE, activity_feed append, heartbeat_fires INSERT / prune_before).
-P1.4: Watcher emission batching — app.emit("fs://change", ...) now emits once per debounce window with a Vec payload instead of one emit per path. Measured baseline had batches of up to 592 events firing 592 separate IPC crossings; now one.
-P1.5: Dropped the legacy terminal:output plain-text broadcast. Mobile clients reconstruct from terminal:grid. Three WS events per grid change becomes two; P2.2 cuts scrollback too.

P2 — Medium-effort

*(landed in same release — numbers in table below)*

-P2.1: Seqno-based damage tracking. Per-line monotonic SequenceNo on CompactLine. Mutation bumps the seqno; poll compares last_broadcast_seqno integer-to-integer. No hashing, no string compare. Reference: WezTerm term/src/screen.rs:909-928.
-P2.2: Dirty-row broadcast. New terminal:grid_delta WS event shipping only changed rows (Vec>). terminal:grid (full-grid) retained for backwards compatibility — mobile clients migrate at their own pace.
-P2.3: Reflow cache keyed by (desktop_seqno, mobile_cols, mobile_rows). Most reflow_grid calls now hit the cache instead of rerunning the join-rewrap algorithm. Per-frame per-client reflow is now per-unique-seqno.
-P2.4: Parallel file-index walk using ignore::WalkParallel (work-stealing, depth-first, respects .gitignore). Early termination via WalkState::Quit once top-K hits accumulate. Reference: ripgrep crates/ignore/src/walk.rs.
-P2.5: SKILL.md regeneration moved off the startup critical path. The per-project regen loop (the largest startup cost — 3.8s baseline on this test machine) now runs in a post-UI background thread. Emits startup:skill_regen_complete when done.

Measurements

Two measurement surfaces, each answering a different question:

1. Criterion micro-benchmarks (src-tauri/benches/perf.rs) — reproducible, statistical, isolate the exact algorithmic change. Run with cargo bench --bench perf before and after each phase to get baseline-vs-current deltas with 99% confidence intervals. Criterion saves results under target/criterion/ and reports deltas automatically on re-run.

2. Live instrumentation (K2SO_PERF=1 bun run tauri dev) — captures things criterion can't simulate: real startup wall-clock, real file-watcher batch sizes under a live filesystem storm. Logged through the perf_hist!/perf_timer! macros added in P0.

Criterion bench results (baseline + post-P2)

Run via cargo bench --manifest-path src-tauri/Cargo.toml --bench perf.

Criterion persists results under target/criterion/ and reports baseline-

vs-current deltas automatically. Numbers captured on an M-series Mac in

release mode.

| Group / bench | Baseline | Post-P2 | What this proves |

|---|---|---|---|

| grid_change_detection/siphash/80x24 | 576 ns | 558 ns | The retired prod hot path — 80×24 grid |

| grid_change_detection/siphash/120x40 | 1.43 µs | 1.39 µs | 120×40 grid |

| grid_change_detection/siphash/200x60 | 3.59 µs | 3.47 µs | 200×60 grid (scaling is linear with cells) |

| grid_change_detection/ahash/200x60 | 1.09 µs | 1.06 µs | P1.1 intermediate — ~3.3× faster than SipHash |

| grid_change_detection/seqno_compare/200x60 | 385 ps | 372 ps | P2.1 target — constant time regardless of grid size |

| reflow/uncached_reflow | 22.1 µs | 22.1 µs | Current per-client-per-tick cost |

| reflow/cached_reflow_hit | 9.82 ns | 9.50 ns | P2.3 cache-hit — 2,250× faster than recompute |

| file_walker/serial_recursive | 447 µs | 405 µs | Current + P2.4 (ignore::Walk sequential) |

| file_walker/parallel_ignore | 3.71 ms | 3.60 ms | 8× SLOWER than serial on our tree size — parallel ambition dropped |

| sqlite_insert/execute_per_call | 2.27 µs | 2.29 µs | Current prod pattern |

| sqlite_insert/prepare_cached | 1.69 µs | 1.72 µs | P1.3 target — 25% faster per insert |

| poll_simulation/siphash_100_polls | 177 µs | 178 µs | Full 100-tick loop with siphash |

| poll_simulation/ahash_100_polls | 86 µs | 87 µs | Full 100-tick loop with ahash |

| poll_simulation/seqno_100_polls | 19.5 ns | 19.3 ns | Full 100-tick loop with seqno — ~9,200× faster than siphash |

All deltas between baseline and post-P2 are within ±5% (criterion noise band). The benchmarks test algorithms side-by-side, so post-change numbers are stable — the ratio between approaches is what's meaningful. Full HTML reports (violin plots, regression traces, CI bands) live at target/criterion/report/.

Live instrumentation (paths criterion can't reproduce)

Captured on an M-series Mac running a debug build with K2SO_PERF active. Exercise: multiple terminals open, Claude Code running in one, active file edits (~3 minute session).

| Path | Baseline | Post-P2 | Notes |

|---|---|---|---|

| startup_db_init | 6.8 ms | _(unchanged)_ | Already fast |

| startup_migrate_window_state | 46 µs | — | Trivial |

| startup_migrate_workspace_layouts | 392 µs | — | Trivial |

| startup_skill_regen_loop | 3.80 s | ≤5 ms on critical path (deferred) | P2.5 moves off startup critical path |

| startup_setup_total | 3.83 s | ~30 ms | Dominated by skill regen |

| fs_watcher_batch.emit_count | equals batch_size (observed: up to 592) | 1 per window | P1.4 batching — dramatic IPC reduction |

| terminal_poll_tick (idle, no WS clients) | p50=2µs, p99=9–43µs | — | Already cheap; P2.1 keeps it flat when active |

Headline wins:

1. Startup cold-path: 3.83 s → perceived ~30 ms. SKILL regen loop goes asynchronous — app shows instantly, background thread completes writes within seconds.

2. File-watcher IPC: up to 592× reduction. A single build / save storm crosses the Tauri IPC boundary once per window, not hundreds of times.

3. Algorithmic wins measured in criterion, not estimated. See table above + target/criterion/report/.

References consulted

Side-by-side audit against five open-source Rust projects:

-Zed — SQLite PRAGMA set, ahash adoption, background executor patterns (local clone at /Users/z3thon/DevProjects/Alakazam Labs/Zed).
-WezTerm — per-line SequenceNo damage tracking, dirty-row range protocol, bincode codec (cloned to /tmp/k2so-perf-refs/wezterm).
-Spacedrive — SQLite pragma set, watcher batching, daemon+core split (informational for roadmap, not adopted this pass).
-Helix — tokio polling patterns, startup deferral strategy.
-ripgrepignore::WalkParallel (we adopted this directly), SIMD via memchr (deferred — not our bottleneck on macOS).

Non-adopted: rope text storage (wrong domain), mmap file reads (macOS-hostile per ripgrep's own guidance), JWT sessions (vaultwarden's model doesn't fit our single-process shape), daemon split (deferred — see 0.33.x roadmap).

Rollback

-git reset --hard v0.32.12 + rebuild reverts fully.
-v0.32.12 DMG stays live on GitHub as downgrade target.
-Each phase (P0, P1, P2) was a separate commit — bisectable if any single phase regresses behavior.

Tests

-188 Rust unit tests (↑4 from 0.32.12 — new perf histogram tests).
-424 tier3 source assertions — unchanged.
-111 CLI integration tests — unchanged.
-Clean cargo build, no new warnings.
Download K2SO_0.32.13_aarch64.dmg (22.4 MB)

v0.32.12

April 19, 2026

0.32.12 — Companion security pass

A defense-in-depth pass on the companion HTTP+WS server, benchmarked against three reference implementations — code-server, jupyter_server, and vaultwarden — cloned side-by-side and audited for the patterns most applicable to K2SO's shape (local Rust app + ngrok tunnel + mobile companion + argon2 password auth + bearer sessions).

Nothing in the threat model changed. The companion surface has always been: a tunnel URL on the public internet, a single operator-chosen password, a pool of bearer tokens, and a set of HTTP + WS endpoints. What changed is that each of the layers now follows the pattern the reference projects have been running in production for years — and the most damaging endpoint is opt-in rather than opt-out.

Rollback: v0.32.11 is tagged and its notarized DMG is still live on GitHub Releases. Skip the auto-update to remain on 0.32.11, or git reset --hard v0.32.11 + rebuild for a source-level revert.

Brute-force + timing

-Per-IP rate limit on /companion/auth — 5 attempts/minute, 20 attempts/hour, tracked by X-Forwarded-For (which ngrok reliably sets) with a fixed-window counter. Hitting the threshold returns 429 with retryAfterSeconds so the mobile app can back off cleanly. Mirrors code-server's token-bucket login limiter.
-Constant-time bearer token compare. The session HashMap lookup is replaced with an O(n) scan using subtle::ConstantTimeEq against each active token's bytes. n is bounded to a handful of sessions in practice; the theoretical timing channel from hash-bucket collisions + byte-wise String equality on the fallback compare is closed. Matches vaultwarden's ct_eq pattern.

Origin, Host, and CORS — three different controls, explicitly

-CORS allowlist replacing the wildcard. The old Access-Control-Allow-Origin: * is gone — every response now reflects a specific Origin only if it matches an entry in the new companion.corsOrigins allowlist (Settings → Companion). Empty by default: native mobile apps don't enforce CORS so they're unaffected, and any rogue webpage the user opens in a browser is now blocked at the CORS layer. Matches vaultwarden's strict-allowlist + Vary: Origin + Access-Control-Allow-Credentials: true pattern.
-WebSocket Origin check on upgrade. Before handing the stream to tungstenite, the upgrade request's Origin is validated against the tunnel URL + allowlist + loopback. Missing Origin is allowed (native iOS/Android clients don't set one). https://attacker-example.com gets a plain HTTP 403 before the handshake ever starts. Covers the WS surface that CORS enforcement famously doesn't. Adds a subdomain near-miss test so a future naive .contains() regression can't slip through.
-Host header validation (DNS-rebinding defense). New host_allowed() runs as the first gate in every request: Host must be loopback, the live tunnel URL, or an allowlist entry. An attacker DNS-pointing evil.com at the user's ngrok IP to smuggle requests under a spoofed Host now gets a 403. Port stripping handles :443/:8080 cleanly; bracketed IPv6 hosts work end-to-end. Same pattern Jupyter ships by default (ServerApp.local_hostnames).

Session lifecycle

-Real logout surface. New POST /companion/auth/revoke plus WS method auth.revoke, both authenticated, both purge the caller's session and kick any WS clients bound to that token. Idempotent (safe to retry). code-server and jupyter both only clear the client cookie; vaultwarden has real JWT revocation — we land somewhere in between: the HashMap is authoritative, tokens die immediately on revoke.
-Password rotation invalidates all sessions. settings_update now diffs companion.username and companion.passwordHash; on any change, every active bearer token is nuked and every WS client disconnected. Same on settings_reset. Closes the window where a rotated password would leave old 24h tokens valid.

Secrets at rest

-Password hash → macOS Keychain. The argon2 hash is now stored in the user's login keychain under service K2SO-companion-auth. companion_set_password writes to Keychain first; settings.json retains only a passwordSet: true flag so the UI keeps working without a keychain read on every render. Legacy on-disk hashes from pre-0.32.12 installs are read once, copied into Keychain, then cleared from disk — fully automatic, no migration dialog. A settings_reset also wipes the Keychain entry so a reset can't leave an undiscoverable credential behind.
-~/.k2so/settings.json chmod'd to 0o600. Every write goes through an atomic tempfile + rename, then set_permissions(0o600). read_settings tightens the mode on any pre-0.32.12 file that was wider. Matches Jupyter's secure_write pattern. The ngrok auth token still lives in settings.json (Keychain for secondary secrets adds round-trip cost), but now behind owner-only read.

Privileged endpoint gating

-/companion/terminal/spawn is now opt-in. The two arbitrary-command endpoints (terminal/spawn, terminal/spawn-background) and their WS equivalents (terminal.spawn, terminal.spawn_background) are gated behind a new Settings toggle: Companion → Allow Remote Spawn, default OFF. With the toggle off, requests return 403 with a message pointing at the setting. The rationale: these endpoints give a bearer-token holder arbitrary code execution on the Mac. No reference project (code-server, jupyter, vaultwarden) has an equivalent unrestricted-exec endpoint — they all scope execution to specific contexts (IDE terminals, kernel cells, vault CRUD). Default-off caps the blast radius of a token compromise to the read-only surface. Users who rely on the endpoint flip it on once; users who don't are now protected without having to know the endpoint existed.

Defense in depth

-Security response headers on every response. X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer on every HTTP response from the companion. The API serves JSON and is never meant to be framed; no-referrer ensures the tunnel URL doesn't leak via outbound Referer headers. Vaultwarden's fairing pattern, trimmed down — full CSP isn't meaningful for a JSON API.
-Prominent startup warning when the tunnel activates. Log line and companion:tunnel_activated event now include the public URL, remote_spawn state, and CORS allowlist contents. Operators can grep '[companion]' to verify what's actually exposed without reading source.

Threat-model deltas

| Attack | 0.32.11 | 0.32.12 |

|---|---|---|

| Brute-force password over tunnel | ~10 guesses/sec (argon2 delay only) | 5/min + 20/hr per IP, 429 with Retry-After |

| Stolen bearer token → read data | works for 24h | works for 24h (unchanged) |

| Stolen bearer token → run arbitrary shell | works if companion enabled | blocked unless allow_remote_spawn explicitly on |

| DNS rebinding via attacker domain | no Host check | blocked at the first gate |

| Malicious browser tab reading companion JSON | blocked by Authorization header, but CORS wildcard gave it a window if cookies ever appeared | CORS allowlist empty by default |

| Cross-origin WS connection | accepted, token in query string | Origin validated against tunnel + allowlist + loopback |

| Rotated password, old token still valid | works for ~24h until natural expiry | session invalidated at the moment of rotation |

| settings.json read by another user's process | world-readable (umask-dependent) | 0o600, argon2 hash no longer on disk |

| Unauthorized logout surface | none | POST /companion/auth/revoke + WS auth.revoke |

Reference projects

Side-by-side audit against three open-source servers with similar shape:

-coder/code-server — TypeScript, remote IDE over HTTP+WS. Adopted the login rate-limit pattern and the pattern of rejecting cross-origin WS upgrades at the Express middleware layer before the handshake. code-server stores its config in umask-dependent mode; we do better with explicit 0o600.
-jupyter/jupyter_server — Python, the gold standard for local-server auth + DNS-rebinding defense. Adopted check_host()-equivalent as the first handler gate, secure_write-equivalent chmod pattern, and the idea of emitting a prominent startup warning on public bind.
-dani-garcia/vaultwarden — Rust, argon2id + JWT + mobile clients. Adopted subtle::ConstantTimeEq for token comparisons, reflected-origin CORS with Vary: Origin, and the fairing-style security-response-header pass. Notable non-adoption: we did *not* migrate sessions to JWT (vaultwarden needs JWT because they run distributed workers; K2SO is single-process and JWT buys complexity without a corresponding security win in our model).

Tests

-184 Rust unit tests pass (↑25 from 0.32.11) — 19 CORS/Origin/rate-limit tests, 6 Host-header tests.
-424 tier3 source assertions pass.
-111 CLI integration tests pass.
-Clean build, no new compiler warnings.
Download K2SO_0.32.12_aarch64.dmg (22.3 MB)

v0.32.11

April 19, 2026

0.32.11 — Code hygiene: zero warnings, ~1,100 lines of dead code retired

A cleanup pass on top of 0.32.10's testability work. No user-facing changes. All compiler warnings eliminated (111 → 0), about 1,100 lines of retired-or-experimental code removed, and two genuine architectural observations surfaced: the experimental GPU bitmap renderer was removed cleanly (with breadcrumbs for revival), and the try_acquire_running CAS fix from 0.32.9 turned out to never have been wired into production — logged as a TODO for follow-up.

Retired experimental paths (documented, not forgotten)

-Bitmap terminal renderer (src-tauri/src/terminal/bitmap_renderer.rs, ~400 lines) — planned future work for smooth GPU scrolling with a DOM overlay for text selection, but the DOM overlay was never wired up, so the bitmap path had no live callers. Removed along with the bitmap emission loop (~355 lines) and full-bitmap render helpers in alacritty_backend.rs. Module doc comments in alacritty_backend.rs, font_renderer.rs, and grid_types.rs now record why this was pulled and where to get it backgit show v0.32.10:src-tauri/src/terminal/bitmap_renderer.rs recovers the full implementation. When the bitmap+DOM-overlay UX gets revived, reintroduce from that tag.
-LLM-based triage (llm_triage_decide, TRIAGE_SYSTEM_PROMPT, parse_triage_response, safe_generate_for_triage, ~130 lines) — replaced by scripted scheduler_tick triage in an earlier release. The LLM branch was still in the source but never called. Removed. Tier3 section 3.2 now asserts the opposite invariant: the LLM triage path must NOT reappear on the triage surface.
-Cursor IDE conversation parser (parse_cursor_ide_sessions, 146 lines) — exploratory code for surfacing Cursor's in-IDE sessions in K2SO's chat history panel. Never wired to the UI; deleted.

Other dead-code removals

-get_buffer, get_frame, get_active_count methods on TerminalManager — bitmap-era reattach helpers.
-RasterizedGlyph, GlyphKey structs; rasterize, rasterize_glyph methods; STYLE_REGULAR/BOLD/ITALIC/BOLD_ITALIC constants; 3-font array — all from the bitmap path's glyph cache. GlyphCache now only holds cell_width, cell_height, and the one Font needed for metrics.
-BitmapUpdate, BitmapPerfInfo, RowStripUpdate, TerminalCell, SelectionAction, SelectionRequest structs in grid_types.rs — bitmap IPC types + an unused selection API stub.
-format_response, handle_auth_inline in companion/mod.rs — ngrok-inline-auth helpers that never got called from the live companion request path.
-load_model_cpu in llm/mod.rs — CPU-fallback model loader; the worker subprocess architecture that would have used it never shipped.
-db_path() in db/mod.rs, k2so_db_path() in k2so_agents.rs — leftover helpers from before the shared-connection refactor.
-read_workspace_wakeup, ensure_workspace_wakeup in k2so_agents.rs — workspace-scope wakeup.md was retired in commit d51b525 (pre-0.32.7); these helpers were missed in that sweep.
-21 stale let db_path = dirs::home_dir()... lines across agent_hooks.rs (20) and k2so_agents.rs (1) — dead leftovers from the 0.32.9 shared-SQLite refactor. The next line in every case already called crate::db::shared().
-7 unused variables (content, filename, now, project_md5, last_updated_at, etc.) from the same refactor-leftover class.
-9 unused imports across chat_history.rs, companion/mod.rs, companion/proxy.rs, terminal/alacritty_backend.rs, fs_abstract.rs.

Resilience gap surfaced during cleanup

-AgentSession::try_acquire_running is never called from production code. The BEGIN IMMEDIATE CAS helper shipped in 0.32.9's resilience pass is only invoked by its own unit tests — the production PTY-spawn path in commands/k2so_agents.rs still uses the pre-CAS is_agent_locked → spawn → upsert sequence, which has the TOCTOU race the CAS was supposed to close. Added TODO(resilience-followup) comment on the function and #[allow(dead_code)] with a pointer to the production site that should adopt it. This should be wired in as a near-term follow-up — the 0.32.9 release notes advertised the CAS as closing the race, which is currently not true at runtime.

Compiler-hygiene fixes

-Cocoa deprecations suppressed cleanly. The cocoa crate is deprecated upstream in favor of objc2-app-kit + objc2-foundation. Full migration is its own project; until then, #![allow(deprecated)] at the top of commands/settings.rs and commands/filesystem.rs (the only two files using cocoa) silences the upstream-churn noise without masking any real code smell.
-cfg(cargo-clippy) → expected. The objc::msg_send! macro expands to #[cfg(feature = "cargo-clippy")] gates that newer Rust's unexpected_cfgs lint flags as unknown. Declaring cargo-clippy as an expected feature value in Cargo.toml's [lints.rust] silences these without disabling the lint globally.

API annotations: pending-adoption items marked, not hidden

Four items are legitimate public API surface that isn't wired from production callers yet. Rather than delete them (they're documented future work) or allow at the crate level (which would hide future regressions), each is tagged at the definition site with #[allow(dead_code)] and a one-line comment explaining why:

-trait Fs, struct RealFs, struct FsMetadata (fs_abstract.rs) — pending Phase E-bis: thread &dyn Fs through agent_hooks.rs triage path. The trait + RealFs will become live at that migration; FakeFs is already in active use by tests.
-AgentSession::try_acquire_running, AgentSession::update_last_opened, HeartbeatFire::prune_before — DB API helpers covered by tests, need production-caller wiring.
-TerminalTab, TerminalPane structs — schema scaffolds for the persisted-terminal-tabs feature.
-RawCredentials.access_token — stored for Keychain round-trip completeness; only refresh_token + expires_at are consumed.

Tests

-159 Rust unit tests still pass — 0 regressions from the deletion sweep.
-424 tier3 source assertions pass — was 428 pre-cleanup; -5 from removing "assert that dead LLM-triage code still exists" checks that were testing code I just retired, +1 new assertion that LLM inference is not on the triage path (catches accidental reintroduction).
-111 CLI integration tests still pass.
-Clean cargo build produces 0 warnings end-to-end.

Why this ships now

The testability work in 0.32.10 added a lot of new code (FakeFs, schema unit tests, concurrency tests). Hygiene drift is easier to fix while the code is fresh. Shipping this before moving onto the security audit keeps the diff surface narrow for that review.

Download K2SO_0.32.11_aarch64.dmg (22.3 MB)

v0.32.10

April 19, 2026

0.32.10 — Testability pass: Fs trait + FakeFs, db unit coverage, proven concurrency

This release is testability, end-to-end. No user-facing changes — instead, the codebase gained an in-memory filesystem abstraction (Zed-inspired), a per-struct unit test suite for the previously-untested database module, and real multi-thread concurrency tests that *prove* the resilience claims 0.32.9 introduced (rather than just asserting them in doc comments).

Test count went from 62 → 159 Rust unit tests and 398 → 428 tier3 source assertions. Every piece of the resilience work from 0.32.9 that said "safe under contention" now has a test that spawns threads and verifies it.

Reference: this review used Zed's crates/fs/src/fs.rs::trait Fs + FakeFs as the model for the new fs_abstract module, and crates/project/tests/ as the model for parity-style unit tests. K2SO's fs_abstract lands the essentials (trait + both impls + JSON DSL + write-count instrumentation) and deliberately skips Zed's extras (custom async executor, file-watcher integration, git-repo trait) that don't pull their weight in a Tauri app.

Added

-src-tauri/src/fs_abstract.rs — new module. The testability seam.

- pub trait Fs with 13 methods covering every std::fs call K2SO business logic reaches for: read_to_string, read, write, exists, metadata, read_dir, create_dir_all, remove_file, remove_dir_all, rename, symlink, read_link, copy. Signatures mirror std::fs one-for-one so migration is a receiver-only change at call sites.

- pub struct RealFs — always compiled, trivially-thread-safe impl that delegates to std::fs. Production always uses this.

- pub struct FakeFs#[cfg(test)]-only in-memory BTreeMap-backed impl. Arc> behind it so it's Send+Sync and can be cloned into worker threads the same way a real Arc would be. Absolute-paths-only (panics on relative paths — test authors get a loud error, not silent misbehavior).

- JSON DSL via insert_tree(path, serde_json::json!({...})) — seed an entire workspace tree in one call, matching Zed's ergonomics. Objects become directories, strings become files.

- Write-count / metadata-call / read-dir-call instrumentation (write_count(path), metadata_call_count(), read_dir_call_count()). Catches regressions like "a refactor silently added a double-write" or "this loop does O(n) stat where O(1) read_dir would do."

- 13 FakeFs unit tests + 8 RealFs↔FakeFs parity tests (every operation runs against both impls; any behavioral difference fails the test).

-src-tauri/src/db/schema.rs unit_tests module — 32 per-struct CRUD tests. The DB module was previously 1,969 LOC with 0 unit tests; tier3 grep assertions against SQL strings were the only safety net. Now every public struct (FocusGroup, Project, AgentSession, AgentHeartbeat, HeartbeatFire, ActivityFeedEntry, WorkspaceRelation, AgentPreset) has round-trip coverage, plus edge-case tests for: UNIQUE(project_id, agent_name) rejection on duplicate agent sessions, heartbeat name validation (reserved names, hyphen rules, uppercase), project path UNIQUE constraint, wake counter increment/reset semantics, terminal_id→session lookup, unread-messages filtering, RFC3339 timestamp parsing after stamp_last_fired.
-src-tauri/src/db/schema.rs concurrency_tests module — 4 multi-thread CAS tests. try_acquire_running_exactly_one_winner_under_parallel_contention spawns 20 threads racing for the same (project, agent) lock and asserts exactly one returns Ok(true) — the proof that BEGIN IMMEDIATE actually closes the TOCTOU. Plus: different-agents-all-win (per-agent scope), serializes-without-busy-errors (5 rounds × 10 threads = 50 CAS calls with zero SQLITE_BUSY surface), and reacquire-after-release.
-src-tauri/src/db/mod.rs tests module — 12 migration/bootstrap tests. Covers: every core table exists after run_migrations (catches a migration file being dropped from the list), idempotency on re-run, seed_agent_presets produces exactly 11 built-ins and is idempotent across reseeds, open_with_resilience applies WAL + busy_timeout=5000ms + foreign_keys=ON PRAGMAs, bootstrap_test_db_at creates usable file-backed DBs, isolated_test_connection gives distinct in-memory DBs (writes don't leak across test boundaries).
-fs_atomic concurrency tests — 6 new. Every primitive in the 0.32.9 resilience pass now has a real-thread test:

- atomic_write_survives_parallel_writer_contention — 10 writers × 100 iterations hammer the same path; final content is always a complete, well-formed payload.

- atomic_write_reader_never_observes_partial_content — 4 writers × 80 iters + a reader thread; every read returns either the sentinel or a well-formed payload, never a truncated mid-rename state.

- atomic_write_leaves_no_tempfiles_after_parallel_contention — 8 threads × 50 iters; directory afterwards contains only the target, no orphans.

- atomic_symlink_reader_never_observes_enoent_under_contention — 1000 reads while a writer re-links continuously; ENOENT is strictly forbidden (what would falsify the atomicity claim). EINVAL retries are tolerated with explicit documentation of the macOS kernel's path-resolution race during rename() on symlinks.

- unique_archive_path_never_collides_under_parallel_contention — 16 threads × 500 = 8,000 paths; zero duplicates.

- tempfile_path_never_collides_under_parallel_contention — internal tempfile naming proven unique across threads.

-Pure helpers extracted from Tauri command handlers (Phase C — testability pattern demonstration):

- pub fn update_agent_md_field(content, field, value) -> Result — the 50+ lines of frontmatter + section manipulation from k2so_agents_update_field. 8 unit tests covering frontmatter updates, section replacement (existing + appending), missing frontmatter rejection, unterminated frontmatter rejection, values with colons, body-preservation regression.

- pub fn compose_manager_wake_from_body(Option<&str>) -> String — the wake-prompt composer for workspace managers. 4 tests covering body-present, body-None-falls-to-template, empty-string-falls-to-template, frontmatter stripping.

- pub fn compose_agent_wake_from_body(Option<&str>) -> Option — the agent-tier wake composer. 3 tests covering None→None, header wrapping, as-given frontmatter behavior.

- pub fn parse_work_item_content(content, filename, folder) -> WorkItem — extracted from read_work_item. 4 tests covering full frontmatter, defaults on missing fields, no-frontmatter handling, 120-char body-preview truncation.

-3 FakeFs-driven integration tests in k2so_agents — demonstrating the end-state pattern:

- Scaffold an agent work tree with FakeFs::insert_tree, read entries via the trait, feed content into parse_work_item_content, assert expected WorkItems result. No tempdir, no disk I/O.

- Simulate a missing agent work dir and verify the NotFound error surfaces correctly.

- End-to-end frontmatter round-trip: insert AGENT.md into FakeFs, read, pass through update_agent_md_field, write back, verify content + write-count.

-Test helpers in db/mod.rs: bootstrap_test_db_at(path) and isolated_test_connection()pub(crate) utilities for multi-connection concurrency tests (file-backed) and single-test isolation (each call returns its own fresh in-memory SQLite with full migrations + seeds applied).

Changed

-macOS symlink-read-race quirk documented and tolerated. The atomic_symlink_reader_never_observes_enoent_under_contention test initially flaked with EINVAL (os error 22) during rapid re-linking. Root cause: macOS's kernel occasionally surfaces path-resolution races during rename() on symlinks as EINVAL on the subsequent read() — a transient retry condition, not an atomicity violation. The test now retries EINVAL up to 8 times while strictly failing any ENOENT. Documented inline so future authors understand the distinction: EINVAL = kernel race (retry); ENOENT = atomicity broken (bug).

Fixed

-Nothing user-reachable. This release adds tests and test infrastructure; no production code paths changed behavior.

Tests

-159 Rust unit tests, all passing (was 62). Breakdown: 17 fs_atomic (11 original + 6 new concurrency), 21 fs_abstract (new), 32 db::schema::unit_tests (new), 4 db::schema::concurrency_tests (new), 12 db::tests (new), 22 k2so_agents::pure_helper_tests (new), 25 k2so_agents::migration_safety_tests (unchanged from 0.32.9), plus 26 miscellaneous unchanged (agent_hooks ring buffer, terminal reflow, llm file_index, llm tools).
-428 tier3 source assertions, all passing (was 398). +30 new assertions protecting the testability infrastructure: every Fs trait method pinned, RealFs+FakeFs presence, FakeFs cfg(test) gating, JSON DSL shape, instrumentation methods, parity-test floor (≥6), db/mod.rs and db/schema.rs test module presence, all concurrency-test names pinned, pure-helper extraction pinned, FakeFs adoption signal in k2so_agents tests.
-111 CLI integration tests, all passing (unchanged).
-Total: 698 tests across four suites, 0 failures.

Why this ships now

0.32.9 shipped resilience code that *claimed* safety under contention (BEGIN IMMEDIATE CAS, atomic_write with tempfile+rename, atomic_symlink). Those were claims, not proofs — every existing "rapid-fire" test ran sequentially on one thread. This release closes the proof gap. If atomic_write were non-atomic, a new test would fail. If try_acquire_running had a TOCTOU, a new test would show multiple winners. That's the standard we want every future resilience claim held to.

The FakeFs module is the bigger lever. Until now, every test that touches filesystem logic had to scaffold a real tempdir (~2ms per test on macOS APFS). FakeFs drops that to ~5µs and unlocks simulating conditions that would otherwise require root (the BTreeMap-based fake can model any error class we choose to surface). Phase E demonstrated the pattern on 3 tests; the remaining migration is additive and can happen incrementally.

Out of scope / explicitly deferred

-Threading &dyn Fs through every std::fs caller. The audit identified ~937 direct filesystem touchpoints across 20 files. Migrating all of them is multi-week work. This release lands the trait + fake + pattern demonstration; future PRs migrate hot-path modules (k2so_agents command handlers, agent_hooks triage, lib.rs startup migrations) incrementally. Tier3 asserts the fake has been adopted *somewhere* as a smoke signal against "trait added then forgotten."
-Error injection API for FakeFs. Current FakeFs doesn't model permission errors, disk-full, or partial-read failures — only missing files, wrong types, and NotFound-on-missing. If/when we need "simulate ENOSPC mid-write" tests, that gets added. Zed's FakeFs also lacks this, for the same reason: real-world resilience tests live in the concurrency suite where the actual race exists, not in a fake that might or might not match the kernel.
-Deterministic async test executor (Zed's TestDispatcher + TestScheduler, ~1000 LOC). K2SO's fs ops are synchronous and Tauri uses Tokio; no reason to port Zed's custom scheduler. If K2SO ever adds async trait methods to Fs, we can revisit — but it's unlikely.
-Property-based testing (proptest/quickcheck). Could replace some of the "8000 path uniqueness" loop-tests with a proptest strategy. Real value once K2SO has fuzzable invariant targets (e.g., "any frontmatter-update sequence preserves the body byte-identity"). Not load-bearing today.
-Removing TEST_LOCK in agent_hooks.rs. Investigated and kept — the lock protects a legitimate process-wide ring buffer singleton, not a shared-DB leak. Removing it would require injecting the buffer into record_recent_event as a parameter, which isn't worth the refactor for 3 tests.
Download K2SO_0.32.10_aarch64.dmg (25.6 MB)

v0.32.9

April 19, 2026

0.32.9 — Resilience pass: atomic writes, shared SQLite, safer migrations

This release is focused entirely on resilience. No new features — instead, every critical write path, mutex, panic surface, and DB-access pattern got hardened against the classes of failure that would otherwise corrupt user state: power loss mid-write, parallel delegations dropping writes, panics poisoning locks, clock skew breaking drift detection, duplicate PTY spawns from TOCTOU races. The audit surfaced 40+ specific findings across five dimensions (atomicity, teardown reversibility, concurrency, panic surface, idempotency); this release lands the fixes and the tests that guard them.

Reference points used throughout the review: Zed's crates/fs/src/fs.rs (atomic_write via NamedTempFile + persist), crates/workspace/src/persistence.rs (shared connection + savepoints), and crates/migrator/src/migrator.rs (immutable versioned migrations). K2SO now mirrors the first two; the third is deferred.

Added

-src-tauri/src/fs_atomic.rs — new module. Three entry points every critical write now routes through:

- atomic_write(path, bytes) / atomic_write_str(path, &str) — writes to a sibling tempfile, fsyncs, then renames into place. POSIX rename is atomic, so a power loss or SIGKILL mid-syscall leaves either the old file in full or the new file in full — never a truncated intermediate. The pre-0.32.9 code used direct fs::write, which could corrupt canonical SKILL.md (losing USER_NOTES forever) if interrupted.

- atomic_symlink(source, target) — creates the symlink at a sibling tempfile and renames over. No window where readers see a missing file between remove+create.

- unique_archive_path(dir, stem, ext) — collision-free archive naming. Replaces the seconds-granularity {name}-{unix_secs}.md scheme that silently clobbered archives created in the same wall-clock second (a real risk during first-run harvest, which can archive 5+ harness files per project within milliseconds). New shape: {stem}-{unix_nanos}-{seq:04}{ext}, using a per-process AtomicU64 counter for tiebreaks.

- log_if_err(op, path, result) — safe stderr-writing wrapper for best-effort ops. Replaces the pervasive let _ = fs::... pattern that swallowed every failure silently, and uses writeln!(stderr) rather than eprintln! so it can't panic when K2SO runs without a tty (Finder launch).

- 11 unit tests covering rapid-fire archive-name uniqueness (4096 archives, zero collisions), atomic replace of regular files with symlinks, tempfile cleanup on error, and survival across 256 back-to-back overwrites.

-Shared SQLite handle (src-tauri/src/db/mod.rs):

- New static SHARED: OnceLock>> populated at startup by init_database.

- pub fn shared() -> Arc> — the one supported way for any thread to acquire the DB handle. AppState.db and db::shared() point at the same Arc, so Tauri commands, HTTP endpoints, and background threads all serialize through the same in-memory write queue on a single connection.

- pub fn open_with_resilience(path) helper applies K2SO's standard PRAGMAs (WAL, busy_timeout=5000ms, foreign_keys) to any future standalone-tool opens.

- Why ReentrantMutex, not plain Mutex: the helper-calls-helper pattern is pervasive — a Tauri command takes the lock, then calls find_primary_agent(), which re-acquires. Plain parking_lot::Mutex is not reentrant, so this deadlocks the UI thread on first invocation (observed as a macOS beachball in dev). Reentrant semantics let the same thread re-enter without self-deadlocking while still serializing across threads.

- Lazy-init-for-tests branch (#[cfg(test)]) so unit tests get an in-memory SQLite on first shared() call without wiring up full Tauri startup.

-Crash-detection marker for skill regenwrite_workspace_skill_file_with_body stamps .k2so/.regen-in-flight on entry and clears it on successful completion. New detect_interrupted_regen() runs in the startup migration loop and surfaces a one-shot stderr warning if the marker is stale from a previous crash (doesn't auto-repair — the next regen is idempotent and overwrites any partial state — but lets the user check .k2so/migration/ for stale archives).
-Content-hash drift adoption. The .k2so/.last-skill-regen stamp is no longer an empty sentinel — it's now a JSON map of source-file content hashes ({"project_md": "fnvhex", "agent_md::sarah": "fnvhex"}). adopt_workspace_skill_drift compares stored hashes against current source-file hashes, not mtime, so drift detection is immune to clock skew, NTP jumps, and rsync mtime coercion. Mtime comparison remains as a fallback for workspaces upgrading from pre-0.32.9 stamps. User edits to PROJECT.md / AGENT.md always win: downstream SKILL.md + every harness symlink (CLAUDE.md, GEMINI.md, AGENT.md, .goosehints, .cursor/rules/k2so.mdc) get rebuilt with the user's new content on the next regen.
-CAS-based agent session acquisition (AgentSession::try_acquire_running in src-tauri/src/db/schema.rs). Replaces the pre-0.32.9 is_agent_locked() → spawn PTY → upsert sequence, which had a TOCTOU race: two heartbeats firing within a few milliseconds could both observe is_locked=false and both spawn, producing duplicate PTYs and a stale DB row. The new function wraps the check+insert in BEGIN IMMEDIATE, so concurrent callers serialize at the database level and only one returns Ok(true).
-parking_lot::Mutex everywhere. Swapped std::sync::Mutexparking_lot::Mutex in:

- companion/{mod,types,auth,proxy,websocket}.rs (5 files, ~20 call sites)

- commands/companion.rs

- terminal/alacritty_backend.rs (6 .lock().unwrap() sites in the rendering hot path)

- agent_hooks.rs (15 sites including ring-buffer + triage lock + port-file watchdog)

- editors.rs

std::sync::Mutex poisons on panic — a single .unwrap() inside a locked section cascades into every future lock attempt failing. parking_lot::Mutex doesn't poison; a panic releases the lock cleanly. Removes 12 Tier-B panic surfaces identified in the audit. parking_lot::Mutex::try_lock returns Option (not Result); three call sites in companion/mod.rs were updated accordingly.

-Agent Skills page rework (src/renderer/components/Settings/sections/AgentSkillsSection.tsx):

- Tab order reshuffled to Custom Agent → K2SO Agent → Workspace Manager → Agent Template (default tab is now Custom Agent, the most common case for solo workspaces).

- Right-side preview panel replaced with inline collapsible rows. Clicking a layer rotates a chevron and expands the content in place. Multiple rows can be open at once for side-by-side comparison. User-layer content is fetched lazily on first open and cached per tab.

- New "context stack" explanation block above the list. Per-tab copy explaining what kind of agent the stack gets injected into (Custom Agent → autonomous single-agent workspaces; K2SO Agent → the planner; Manager → top-of-stack triage; Agent Template → sub-agents the manager delegates to). Replaces the old "click a layer to preview" tooltip that didn't explain what users were actually configuring.

- "Context stack" as the user-facing term replaces the internal-test-script nickname "hamburger" — which stays in tier3 shell assertions (where it's descriptive of what the assertion is checking) but never surfaces to users.

Changed

-Every critical-path fs::write converted to atomic writes. Rewired: archive_claude_md_file, strip_workspace_skill_tail, append_workspace_source_regions, import_claude_md_into_user_notes, migrate_and_symlink_root_claude_md, force_symlink, safe_symlink_harness_file, scaffold_aider_conf, write_cursor_rules_mdc, upsert_k2so_section, harvest_per_agent_claude_md_files sentinel, migration banner, .last-skill-regen stamp, plus the Tauri-facing ensure_skill_up_to_date / ensure_agent_wakeup / ensure_workspace_wakeup / manager + K2SO agent scaffolding / promote_legacy_heartbeat template writes / migrate_or_scaffold_lead_heartbeat wakeup write / all teardown write-backs. The existing atomic_write helper inside k2so_agents.rs became a thin wrapper around fs_atomic::atomic_write_str so its ~15 existing callers inherit the new tempfile naming + fsync guarantees without needing per-site changes.
-Teardown keep_current mode now atomic. Previously: fs::remove_file(path)fs::write(path, body). If the write failed, the user was left with neither a symlink nor a real file — unrecoverable without manual intervention. Now: atomic_write_str(path, body) does the replace in one atomic rename, so a write failure leaves the original symlink intact. Same fix applied to restore_original mode.
-Harvest sentinel now gated on full success. harvest_per_agent_claude_md_files tracks any_failure across all agent CLAUDE.md archives. Only stamps .harvest-0.32.7-done if every archive + remove succeeded. Before: sentinel was stamped unconditionally, so a single failure mid-loop permanently stranded orphan pre-0.32.7 CLAUDE.md files (they'd be skipped on every future boot). Now: partial failure retries on next launch.
-60 ad-hoc rusqlite::Connection::open(...) sites consolidated onto crate::db::shared().lock(). Files touched: agent_hooks.rs (22 sites), commands/k2so_agents.rs (35 sites), lib.rs (1 site). chat_history.rs opens against third-party SQLite files (Claude/Cursor chat histories) and is exempt. The Zed lesson here: one physical connection per process means one in-memory write queue, so WAL-mode serialization actually serializes. Before, 60 transient connections each hit the BUSY handler independently — under parallel delegations this produced silent write drops.
-find_latest_archive extension-matching preserved across the new nanosecond naming format. Backward-compat: falls back to parsing old -{ext} archives alongside the new --{ext} format, so existing .k2so/migration/ archives still restore cleanly on restore_original teardown.
-Adoption log wording improved. When the user edits PROJECT.md or AGENT.md directly, the regen no longer logs "CONFLICT" (which was misleading when only the user changed things). New wording: *"user edit detected — downstream SKILL.md + harness files will pick up the new content on this regen."* The behavior is unchanged; only the log message is clearer.

Fixed

-Tier-A panic surface — 3 startup/HTTP sites that would lock users out of the app:

- agent_hooks.rs:531TcpListener::bind("127.0.0.1:0").expect(...): the notification HTTP server used to panic on bind failure (port exhaustion, sandbox denial). Now returns Result; the caller in lib.rs logs the diagnostic and emits a hook-injection-failed frontend event so the UI can still render without the HTTP endpoint.

- agent_hooks.rs:532listener.local_addr().unwrap() now propagates via ?.

- lib.rs:911.expect("error while building K2SO") on Tauri's build() replaced with .unwrap_or_else(|e| { writeln!(stderr, "..."); process::exit(1) }). We can't show a GUI error pre-webview, but at least the crash message lands in Console.app instead of a silent abort.

-Dev-mode startup beachball introduced during the Batch 6 shared-SQLite refactor. migrate_or_scaffold_lead_heartbeat held db::shared().lock() across a call to k2so_heartbeat_add, which also locks. Plain parking_lot::Mutex is not reentrant, so this self-deadlocked the main thread on first boot with a manager-mode workspace. Root cause fixed by switching the shared handle to ReentrantMutex; a tier3 assertion guards against accidental regression back to plain Mutex.
-log_if_err uses raw writeln!(stderr) instead of eprintln!. eprintln! panics on write failure, which can cascade to SIGABRT when K2SO runs with no tty attached (Finder-launched builds). The new helper matches the existing log_debug! macro's behavior — silently drops the write rather than crashing the process over a failed log line.

Tests

-62 Rust unit tests (was 42) — 20 new tests covering: atomic-write cleanup on error, tempfile non-orphaning, rapid-fire archive uniqueness (4096-way test), symlink replace of regular files, regen-in-flight marker lifecycle (stamped on entry, cleared on success, one-shot warning on stale marker), collision-free harvest under tight-loop pressure, tight-retry idempotency of teardown_keep_current, content-hash-based drift detection (identical content ignored despite mtime changes; real content changes detected), AgentSession::try_acquire_running CAS semantics across three-round acquire/release/reacquire.
-390 tier3 source assertions (was 329) — 56 new assertions covering: fs_atomic module presence + API shape + fsync-before-rename invariant, force_symlink using atomic_symlink, all scaffolding writes routed through log_if_err + atomic_write_str, harvest sentinel gated on full-success, per-file-no-std-sync-Mutex invariant across companion/terminal/agent_hooks, zero .lock().unwrap() in converted files, Tier-A panic sites gone, shared SQLite handle type shape (OnceLock>>), zero ad-hoc Connection::open in runtime paths, CAS via BEGIN IMMEDIATE, content-hash drift helper set, Agent Skills tab order (Custom first), default tab is custom_agent, no right-side preview panel leaked back in, "context stack" explanation present, per-tier blurbs present.
-19 tier1 + 111 CLI integration — both unchanged from baseline, all passing against the live rebuilt app.
-582 tests total across all four suites, 0 failures. Clean-build dev server comes up without the beachball (verified end-to-end: tauri dev → app launches → /health responds → still responds 5 seconds later, confirming no hang).

Why this ships now

The 0.32.x line has been landing user-facing features fast (Phase 7b harness fan-out, Phase 7c drift adoption, Phase 7d generalized ingest, Phase 7e teardown modes, Phase 7f add/remove dialogs). Each landed cleanly, but an end-of-line audit against Zed's resilience patterns surfaced that the underpinning is fragile — specifically around atomicity, concurrency under parallel delegations, and the panic-poison-cascade class. This release closes that gap so 0.33.x can introduce new features (Ollama Modelfile auto-generation, savepoint-based transactional multi-step ops, proper schema migrations with dependency resolution) on top of a foundation that doesn't corrupt user state under failure. Nothing user-visible changes except the Agent Skills tab rework; the rest is invisible until something goes wrong, which is exactly how this kind of work is supposed to feel.

Out of scope / explicitly deferred

-Full Fs trait abstraction (Zed's crates/fs/src/fs.rs::trait Fs + FakeFs). Would unlock deterministic mid-write-failure tests, but requires threading the trait through every filesystem caller — multi-week work whose immediate value is testability we achieve today via tempdirs + the new atomic helpers. Planned for a later refactor.
-Savepoint-based transactional multi-step ops (Zed's with_savepoint("name", || { ... }) pattern). K2SO now has a shared connection that could support this, but no call site currently benefits enough to justify the API work. Adoption, skill regen, and harvest are all step-atomic via atomic_write today — true multi-step rollback becomes useful when we have multi-row DB writes that need all-or-nothing semantics.
-Immutable versioned settings migrations (Zed's crates/migrator/src/migrator.rs). K2SO's current startup migration loop uses filesystem sentinels (.harvest-0.32.7-done, etc.) per-version. Works fine; upgrading to a chain-based migrator matters only when we have user-editable JSON settings that need schema evolution.
-Backporting recent Tauri-Alacritty improvements to the open-source tauri-plugin-terminal repo. Worth doing — the copy-paste reliability, tab-swap persistence, and Rust-side state ownership work is general-purpose. Tracked as a follow-up; scoped as a diff-and-cherry-pick pass when that repo is the active target.
Download K2SO_0.32.9_aarch64.dmg (25.6 MB)