* fix(sidebar): keep created subtasks during hydration
* fix(sidebar): preserve placeholder subtasks during hydration
* fix(sidebar): mark websocket placeholder snapshots
* perf(kanban): memoize derived board data to stop re-render storms
The board rebuilt the `tasks` array (and `stepOptions`/`sharedProps`) on every
store change, producing fresh object identities that defeated downstream memos
and re-rendered every card on each WebSocket message. Wrap them in useMemo so
the board only re-renders when its inputs actually change.
* perf(session-runtime): bound runtime memory and dedupe git-status compare
Three runtime-store fixes for the crash a heavy rebase triggered:
- Cap shell + terminal output buffers at 2MB (matching the existing process
buffer) so a chatty shell can't grow the store without limit.
- Purge per-session runtime state (messages, turns, shell/process buffers, and
the bySessionId maps) when a session is removed; env-scoped maps are dropped
only once no other session shares the environment.
- Run setGitStatus's deep per-file diff comparison once per update and return
whether anything changed, instead of recomputing it 2-3x. Under a massive
rebase (thousands of files, frequent updates) that comparison was the
dominant CPU cost and drove the render storm.
* refactor(session-runtime): drop now-unused gitStatusWouldMutate
The git-status handler no longer pre-checks for changes (setGitStatus now
returns whether it changed), leaving gitStatusWouldMutate with no callers.
* fix(session-runtime): enforce terminal cap on first and oversized chunks
appendTerminalChunk only evicted when more than one chunk was buffered, so a
single chunk larger than the 2MB cap slipped through untrimmed; the new-terminal
branch also pushed the first chunk without going through the cap at all. Clamp
each chunk to the tail of the cap and route the new-terminal path through
appendTerminalChunk. Addresses CodeRabbit review.
* fix(acp): handle claude-acp async_launched subagents end-to-end
Claude Agent SDK's Task tool with run_in_background=true returns
session/prompt with stop_reason=end_turn while subagents stay alive
in the background, reporting `_meta.claudeCode.toolResponse.status =
"async_launched"` instead of a terminal top-level status. kandev
previously treated stop_reason as authoritative, cancelled the
subagent card on prompt return, and fired an "agent finished without
producing any output" notice — even though the dispatch did produce
work and the subagent's child events would arrive seconds later.
Backend:
- `SubagentTaskPayload` gains `IsAsync` / `OutputFile` /
`CanReadOutputFile` so the async-launched envelope is preserved.
- `convertToolCallResultUpdate` recognizes async_launched as terminal
for the Task tool itself (dispatch is the completion).
- `cancelActiveToolCalls` preserves subagent_task tool calls through
the prompt-return sweep so a late terminal tool_call_update from
the SDK still lands correctly (covers the in-turn-complete case).
Frontend:
- `subagentMetaChips` emits a "background" chip when `is_async` or
`status == "async_launched"` so the card is visually distinct.
- `computeEmptyTurnNotice` suppresses the notice when any subagent
task message exists for the turn (the dispatch IS output).
Reproduced via `acpdbg prompt` against `claude-acp` 0.31.1; captured
frames in acp-debug/claude-acp-prompt-20260602-110119.jsonl drive the
backend test fixtures verbatim.
* fix(acp): harden async_launched override against future SDK frames
Address PR review feedback (greptile #1244, claude review):
- Move the async_launched override inside the mutex lock so it can
inspect the stored payload kind before flipping the status.
- Drop the `status == ""` precondition. The earlier in_progress
synthesis (Title/RawInput/Content/Locations) was implicitly trumping
the override; a future SDK frame that combines async_launched with
any of those fields would have left the card stuck in_progress
forever. The override is now unconditional for async_launched
subagent_task frames.
- Gate the override on `payload.Kind() == ToolKindSubagentTask`. A
hypothetical future claude-acp tool that reuses the `async_launched`
literal in its own `_meta.claudeCode.toolResponse.status` would have
been incorrectly marked complete by the prior unscoped check.
Pinned by two new tests:
- TestConvertToolCallResultUpdate_AsyncLaunched_OverridesInProgress
- TestConvertToolCallResultUpdate_AsyncLaunched_NonSubagentNotTerminated
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Three areas were broken in the MCP add_branch_to_task flow:
1. Predictable branch names. autoBranchName counted up from existing
branch-N rows. Concurrent add_branch calls raced onto the same name
and the worktree directory leaked ordering. Now uses a random
branch-<3 random chars> suffix via worktree.SmallSuffix.
2. Sibling materialize did not flow to the UI. The materializer
published AgentctlReady with the NEW sibling worktree's path, which
the frontend handler wrote onto the session row — clobbering the
primary's worktree_id/branch and pointing the file browser at the
sibling instead of the task root. Added TaskWorkspacePath to the
payload, detected the sibling case on the frontend (existing
worktree_id != payload's), kept the primary's id/branch, repointed
worktree_path to the task root, bumped commits refetch, and dropped
the pre-multi-repo legacy git-status entry so the Changes panel
stops surfacing the frozen single-repo snapshot. File browser's
resetKey now includes the worktree count so the tree reloads when
the sibling lands.
3. workspace_path promotion + agentctl rescan refused to act on
historical envs. A few envs had workspace_path pointing at the
source repo's local_path (orchestrator computeWorkspacePath fallback
to req.RepositoryPath when resp.WorktreePath is empty) instead of
the primary worktree. The materializer's promotion gate compared
against env.WorkspacePath's parent, so those envs were never
promoted, and agentctl's resolveRescanPath only accepted
parent-of-current so the rescan to the real task root was rejected.
Trackers stayed on the wrong directory and the sibling's commits
never appeared in the Commits panel even after a full reload.
Anchored promotion on env.TaskDirName (basename cross-check) and
added a recovery branch to resolveRescanPath that accepts any
absolute directory which actually contains git-repo subdirs — the
bearer-token middleware already gates the endpoint and
scanRepositorySubdirs validates the children, so the relaxed check
stays safe.
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat: add voice mode for chat input
Adds a voice input button to the chat composer with three transcription engines:
in-browser Web Speech API, in-browser Whisper via @huggingface/transformers, and
a server-side OpenAI Whisper fallback gated on KANDEV_VOICE_OPENAI_API_KEY.
User-configurable from a new /settings/voice-mode page:
- Master on/off toggle (defaults to on; opt-out, not opt-in)
- Engine preference (auto / Web Speech / Whisper Web / Whisper Server)
- Language hint (auto-detect or BCP-47 pinned)
- Activation mode (click-to-toggle or hold-to-talk)
- Auto-send after transcription
- Whisper Web model size (tiny / base / small)
- Editable Cmd+Shift+M keyboard shortcut
Voice settings live under user_settings.voice_mode as a nested JSON object —
no schema migration. Setting changes broadcast via the existing
user.settings.updated WS event so any open chat tab updates live.
The mic button is rendered on the right of the composer alongside the send
button (round primary fill; destructive fill with pulse ring while recording).
On browsers without a usable engine (e.g. iOS Chrome over HTTP) the button
stays visible as a muted greyed icon — tapping it surfaces a toast with the
reason (typically a missing secure context).
Whisper Web runs in a Web Worker with fp16 decoder weights (avoids the
MatMulNBits scale-missing crash that q4/q8 hit on WASM). Models are pulled
from the onnx-community/* mirror and cached by transformers.js in the Cache
Storage on first use.
* fix: address voice mode PR review feedback and E2E selector clash
Bot-review fixes from #1159:
- Map http.MaxBytesError from c.FormFile to 413 instead of bare 400.
- Strip BCP-47 region suffix before passing language hint to Whisper Web.
- Recover from worker crash: terminate + null worker + reset ready flag so
next init/transcribe creates a fresh worker instead of hanging.
- Read live store state via storeApi.getState() in voice-mode settings
savers to avoid stale-closure clobbering concurrent settings updates.
- Detach Web Speech callbacks before abort() so trailing onend/onerror
events don't mutate hook state after cancel().
- Spread caller init before method/body in voice-api fetch so a caller-
supplied init can't override the multipart POST.
- Replace t.Fatalf with t.Errorf+return inside test HTTP handler goroutine
(FailNow from non-test goroutine is unsafe).
- Add Enabled/AutoSend zeroing assertions in partial-update test so the
bool-overwrite-on-PATCH behavior is locked in explicitly.
E2E fix:
- toolbar-overflow spec used button.rounded-full which now matches both
the send and voice buttons (strict-mode violation). Switched to the
submit-message-button testid for an unambiguous locator.
* fix: scope voice settings rollback + whisper worker error to changed keys
Round 2 of PR review feedback on #1159:
- voice-mode settings rollback now restores only the keys patched by the
failing request (not the whole snapshot), preserving concurrent edits
to unrelated voiceMode fields.
- Shortcut rollback diffs previous vs next and reverts only the changed
keys (restore prior value or delete if absent), preserving other
concurrent shortcut edits.
- Whisper worker error handler captures the worker reference at attach
time and only clears refs when this.worker still matches it — a stale
error from a previously replaced worker no longer nukes the active one.
- Update stale whisper-web-models JSDoc that still pointed at Xenova
mirrors.
* fix: voice mode progress scale, hold-mode race, race-aware rollback
Round 3 of PR review feedback on #1159:
- Normalise Whisper progress to 0–1 in ensureWhisperClient (transformers.js
emits 0–100, the button's display + the `ready: 1` convention expect
0–1 — fixes the 5000% mid-download display).
- Claim driverRef synchronously at the top of finishCapture so concurrent
pointerup + pointerleave invocations early-return instead of racing and
clobbering a freshly-started recording's ref.
- Race-aware rollback: voice-mode and shortcut catch branches now skip
reverting a key when latest[key] !== next[key]. A failed earlier request
no longer overwrites a later successful save to the same key.
- Update the Worker wire-protocol comment to show onnx-community/whisper-base
instead of the stale Xenova/* example.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* feat(orchestrator): persist N-entry FIFO message queue
Replaces the single-slot, latest-wins, in-memory queue with a SQLite-backed
FIFO queue (default cap 10 per session, configurable via
KANDEV_QUEUE_MAX_PER_SESSION). Drain stays one-per-turn so each entry gets its
own user message and `on_turn_*` workflow cycle. Inter-task `message_task_kandev`
returns a structured `queue_full` error with sender attribution when the
target session is saturated, so the calling LLM can decide whether to retry.
Surfaces a new WS action `message.queue.remove` for single-entry deletion;
`message.queue.cancel` now means clear-all. Edit/remove are keyed on the
entry's UUID, returning `entry_not_found` when atomic-take won the drain race.
* feat(web): N-entry queue state, API, and optimistic hook
Replaces the single-slot QueueStatus with an ordered `entries[]` per session
plus capacity meta (`count`, `max`). API exposes typed `QueueFullError` and
`QueueEntryNotFoundError`; the hook surfaces optimistic edit/remove with
drain-race rollback (refetch on `entry_not_found`).
* feat(web): inline ghost-message queue UI with sender chips
Replaces the floating QueuedMessageIndicator overlay with a stack of ghost
messages rendered above the chat input. Each ghost shows a sender chip
(user / agent) and per-entry edit/remove. A small "n/max queued · clear all"
pill summarizes capacity. Inter-task entries are visually distinct and edit
is gated to the original sender.
* fix(queue): address review — auth guard, TOCTOU, error UX
- Add `messagequeue.QueuedByUser` / `QueuedByAgent` constants and centralize
the `queue_full` error code so the WS and MCP handlers stay in sync.
- WS `message.queue.{add,append,update}` default empty user_id to QueuedByUser
so the UpdateContent ownership guard always runs against a non-empty owner;
inter-task entries (`queued_by="agent"`) can no longer be overwritten by
user-initiated edits even when `user_id` is omitted from the payload.
- memoryRepository: hold the mutex through the whole append-or-insert path
(prior split released the lock between tail-check and Insert, allowing two
same-sender callers to race past coalescing). TransferSession now shifts
source positions past the destination's max so transferred entries always
drain after pre-existing destination entries, matching SQLite semantics.
- Frontend: surface edit/clear/remove failures via `sonner` toasts (drain race
→ "message already sent"); exit edit mode on failure instead of leaving the
textarea silently stuck. Hide the remove button on read-only (agent) ghosts.
- `useQueue.clearAll` no longer derives `max` from pre-clear entry count;
resets to `metaMax ?? 0` so the next status_changed event lands authoritative
capacity.
- `senderKindOf` drops the unreachable metadata fallback; route on `queued_by`
alone since `dispatchTaskMessage` always sets it.
- Test loggers now write to stderr per CLAUDE.md "stderr for logs" guideline.
* test(web): align queue e2e selectors with new ghost-message UI
The ghost-message rewrite renamed button titles ("Cancel queued message"
→ "Remove queued message", "Edit message" → "Edit queued message") and
introduced a `data-testid="queued-ghost-list"` container. The
`message-queue.spec.ts` Playwright tests still pointed at the old titles.
* fix(queue): address second review pass — auth, drain race, tests
- WS handlers (`add` / `append` / `update`) now reject `user_id="agent"`
outright, closing the gap a hostile WS client could exploit by
impersonating the inter-task sender identity to overwrite agent
entries (greptile P1).
- WS `update` / `remove` now require `session_id` so the post-mutation
`MessageQueueStatusChanged` broadcast can never be silently skipped.
- `sqliteRepository.TakeHead` checks `RowsAffected()` after DELETE;
when two concurrent take callers race past the busy-timeout the
loser sees 0 affected rows and now returns `nil, nil` instead of
the already-drained message (would otherwise dispatch the same
prompt twice).
- `Service.GetStatus` returns `Entries: []` instead of `nil` on the
error path so the wire shape stays consistent (frontend already
guards but strict consumers wouldn't tolerate the type mismatch).
- New tests: `queue-api.test.ts` covers `rethrowQueueError` mappings
(queue_full / entry_not_found / generic / Error pass-through);
`session-slice.upsert.test.ts` covers `setQueueEntries`,
`removeQueueEntry`, and `clearQueueStatus`. Backend gains an
agent-impersonation handler test.
- `rethrowQueueError` now surfaces `WSError.message` for non-queue
errors instead of stringifying the whole object.
- Cleanup: replace unused `a, c := ...` discards in service_test
with `_, _ = ...`.
* fix(queue): scope update/remove by session_id (cross-session deletion guard)
The MCP `queue_full` error payload deliberately discloses sibling-task
entry IDs (so the calling agent's LLM can reason about the wedge state).
Without a session_id filter on `DeleteByID` / `UpdateContent`, a hostile
agent that received that payload could call `wsRemoveEntry` /
`wsUpdateMessage` against another task's entries and delete or rewrite
them — only the per-entry UUID was being checked.
- `Repository.UpdateContent` and `Repository.DeleteByID` now require
`sessionID` and add `AND session_id = ?` to the SQL guard. The memory
repo mirrors the scope (lookup keyed on session before the entry scan).
- `Service.UpdateMessage` / `Service.RemoveEntry` take `sessionID`; the
WS handlers thread `req.SessionID` through (already validated as
non-empty in the previous fixup).
- TypeScript: `session_id` is now required on `updateQueuedMessage` /
`removeQueuedEntry` params so callers fail at compile time, matching
the backend contract.
- Tests cover the cross-session attack: `RemoveEntry`/`UpdateMessage`
with an attacker-supplied session_id but a real entry id from another
session must return `ErrEntryNotFound` and leave the victim entry
untouched. SQLite repo gets parallel coverage.
* fix(queue): propagate transfer error + tighten WSError narrowing
- `Service.TransferSession` now returns an error so callers can fail
closed instead of silently leaving entries orphaned on the previous
session. The two existing call sites pick the right policy:
`switchSessionForStep` (reusing an existing session) propagates the
error so the workflow switch aborts; the new-session path logs and
continues, since the new session is already committed and rolling
back here would leave a half-switched workflow.
- `asWSError` now also narrows on `message` / `details`, so WS payloads
without a `code` field are still mapped to a meaningful `Error.message`
via `rethrowQueueError` (instead of "[object Object]"). Native
`Error` instances explicitly skip the WS-error branch so they pass
through untouched, preserving the existing pass-through guarantee.
- `queue-api.test.ts`: `expect.assertions(N)` on the catch-only tests
so a missing throw fails the test instead of silently passing.
* fix(queue): check TransferSession error in service tests
The TransferSession signature change in 83abe6d7 broke errcheck on two
test sites that hadn't been updated. Wrap them in require.NoError.
* fix(web): preserve primarySessionId across kanban.update broadcasts
The kanban.update WS handler rebuilt kanban.tasks without
primarySessionId / primarySessionState, wiping values set by
task.updated. After any board refresh the primary star vanished and
selectTaskWithLayout fell into the slower async fallback path. Move
task building inside setState so existing values are preserved in both
single and multi snapshots.
* fix(web): prevent deleted session panel from being re-created
handleDelete called removeTaskSession before containerApi.removePanel,
which let useAutoSessionTab re-run with effectiveSessionId still
pointing at the deleted session and re-create its panel. The
setupChatPanelSafetyNet had a matching gap — it re-added a panel for
activeSessionId without checking the session still existed.
Switch the active session (or clear it) before removeTaskSession so
effectiveSessionId updates first. Add guards to useAutoSessionTab and
the safety net so neither creates a panel for a session that is no
longer in the store.
* test(web): add session tab management regression suite
Cover the three bugs fixed in this branch end-to-end: deleting a
non-active session keeps it gone, deleting the active session switches
focus to the remainder, deleting then immediately switching tasks does
not resurrect the tab, and the primary star survives both a
kanban.update broadcast (triggered via apiClient.moveTask) and a
task-switch round trip.
* fix(web): hand off deletion to newest remaining session, document multi-snapshot fallback
- session-tab.tsx: sort remaining sessions by started_at DESC and
pick the newest, instead of relying on the unspecified store ordering.
- kanban.ts: comment the multi-snapshot primarySessionId fallback so
the double lookup does not look like redundancy.
Both per Claude review feedback on #800.
* fix(web): preserve explicit-null primary, abort delete on WS failure
- kanban.ts: switch the multi-snapshot fallback from `??` to an
explicit `=== undefined` check. An explicitly-cleared null primary
must NOT be replaced by a stale snapshot value, only an absent key.
- session-tab.tsx: wsAction now returns a boolean; handleDelete bails
out before mutating the store / dockview when the backend
session.delete request fails. Prevents client/server desync from a
transient WS failure.
- kanban.test.ts: add regression covering the null-vs-undefined merge.
Per CodeRabbit and Greptile review on #800.
* style(web): drop redundant .slice() in session deletion sort chain
filter() already returns a fresh array, so the subsequent sort() does
not mutate shared store state. Per Greptile review on #800.