* 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>
* feat(tasks): multi-branch tasks — N (repo, branch) pairs per task
Extends multi-repo into multi-branch so a single task can hold multiple
worktrees on the same repo (one per branch). Sibling worktrees layout at
<task>/<repo>/ + <task>/<repo>-<slug>/, dedicated PRWatch per branch,
live agentctl tracker rescan + frontend store updates so the UI splits
PRs / files / commits per branch without a session restart. MCP
add_branch_to_task_kandev tool is shielded to the worktree executor.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): unify multi-branch repo label as <repo> · <branch> across panels
PR Changes and Commits panels rendered repo labels differently for multi-branch
tasks — Commits showed "kandev · feature/x" while PR Changes leaked the raw
"kandev-feature-x" agentctl tracker tag. Reformat tracker-tagged repo names via
formatRepoLabel using the longest known-repo prefix so kandev-cli-* doesn't
collapse to kandev · cli-*.
* fix(tasks): address multi-branch PR review — data races, slug collisions, MCP error codes
Backend
- agentctl process.Manager: add repoTrackersMu RWMutex guarding repoTrackers
+ workspaceTracker. Reads snapshot under RLock; RescanRepositories builds
trackers first, takes Lock for swap/append. Validates newWorkDir via
os.Stat before publishing cfg.WorkDir.
- worktree.Manager: reject invalid explicit BranchSlug up-front; cache key
uses sanitized wt.BranchSlug so read/write paths are symmetric.
- worktree.store: narrow isDuplicateColumnError to "duplicate column name".
- task service AddBranchToTask: slug-collision guard rejects feat/a vs feat-a.
- orchestrator resolvePushRepo: two-pass exact-name match before subdir
prefix fallback so "backend" doesn't swallow pushes for "backend-admin".
- executor buildRepoSpecs: duplicate repos always get a non-empty BranchSlug
(falls back to branch-N when both branch fields sanitize to empty).
- MCP handler: classifyAddBranchError maps service errors to NOT_FOUND /
CONFLICT / VALIDATION_ERROR; add ErrorCodeConflict constant.
- MCP server: extract registerAddBranchToTaskTool, register in task mode
only (external mode contract: config tools + create_task_kandev).
- workspace_rescan handler: reject malformed JSON with 400 instead of
silently rescanning the current root.
- branch_materializer: split MaterializeBranch into prepare + finalize
helpers to satisfy the 80-line cap.
Frontend
- existingPrUrl returns undefined when a task has >1 PR so per-repo View PR
buttons take over (no silent linking to a sibling).
- formatRepoLabel checks exact known-repo match before prefix splitting so
"kandev-cli" isn't mislabeled as "kandev · cli".
- Repo chip key now includes base_branch so siblings differing only on
base render as distinct chips.
Docs
- multi-branch spec uniqueness wording aligned with the canonical 4-column
key (task_id, repository_id, base_branch, checkout_branch).
- worktree.CreateRequest.BranchSlug doc describes the sibling layout
(<repo>-<slug>) instead of the obsolete nested form.
* fix(tasks): scope slug-collision check to repo only + use errors.Is for ErrTaskNotFound
- service_branches: drop base_branch from the slug-collision check. Worktree
path is (repo, slug); two rows on the same repo cut from different bases
still collide on disk.
- service_branches: prepareBranchAdd wraps taskrepo.ErrTaskNotFound via
fmt.Errorf with %w so classifiers don't have to substring-match the
formatted task UUID.
- mcp handlers: classifyAddBranchError uses errors.Is(err, ErrTaskNotFound).
* fix(agentctl): sanitize rescan work_dir before os.Stat to clear CodeQL path-injection
CodeQL flagged the rescan endpoint as a path-injection sink (the
HTTP-supplied work_dir flowed straight into os.Stat and downstream
scanRepositorySubdirs). Add validateRescanPath which:
- requires the input to be a clean absolute path with no ".." segments
- rejects anything that isn't equal to or an ancestor of the current
cfg.WorkDir — the only legitimate use case is the branch materializer
promoting <task>/<repo>/ to <task>/
Defense in depth: agentctl binds to localhost, but any colocated process
could otherwise direct it at /etc or the user's home by POSTing
/api/v1/workspace/rescan.
* fix(agentctl): tighten validateRescanPath, close TOCTOU on existingTrackers, add tests
- Drop the strings.Contains(clean, "..") guard. filepath.Clean already
resolves traversal; the substring check rejects legitimate names like
/home/u/..hidden without adding defense (the ancestor check via
filepath.Rel is the real boundary).
- Drop the rel == "." branch in the ancestor check — early return for
clean == currentClean already covers identity.
- Read existingTrackers inside the write-lock section (not under RLock
then re-Lock) so two concurrent rescans can't both see 0 trackers and
double-bootstrap the multi-repo set.
- Add table-driven TestValidateRescanPath covering rejection paths
(relative, descendant, sibling, traversal-cleaned, empty input),
legitimate ancestor promotion, exact match, no-anchor first-launch,
and a /home/u/..hidden dotted-segment case.
* fix(agentctl): break taint flow into os.Stat — derive rescan path from cfg, not HTTP body
CodeQL's go/path-injection trace still flagged manager_rescan.go after the
prior sanitizer because the user-supplied newWorkDir flowed (after
validation) into os.Stat and then into cfg.WorkDir → tracker paths →
workspace_monitor os.Stat sinks.
Replace validateRescanPath with resolveRescanPath: it returns a path
DERIVED from currentWorkDir (no-op = currentClean, promotion =
filepath.Dir(currentClean)) and uses newPath only for comparison, never
as a source for the returned string. os.Stat now receives a path whose
provenance is trusted manager config.
Functionally the legitimate caller (branch materializer promoting
<task>/<repo>/ to <task>/) is unchanged.
* test(agentctl): make TestResolveRescanPath windows-portable
The Linux Backend Tests job passed but the Backend (windows) job rejected
every "absolute" case because filepath.IsAbs("/task/repo") is false on
Windows (no volume letter).
Build inputs via filepath.Join against absRoot() (filepath.Abs of "/" /
"\\") so the same table covers POSIX (`/...`) and Windows (`C:\...`)
without per-OS branches. Keep the traversal-after-Clean case POSIX-only
since filepath.Clean is the shared primitive and that input form needs a
volume to be considered absolute on Windows anyway.
* fix(agentctl): serialize RescanRepositories + re-check trackers under write lock
Two concurrent RescanRepositories calls could both observe an empty
tracker set (between the snapshot read and the bootstrap branch) and both
enter transitionToMultiRepoMode, leaking a duplicate bare-root tracker
and duplicate per-repo trackers. Same race in appendNewRepoTrackers would
double-append the same new child across both callers.
Fix:
- Add rescanMu sync.Mutex on Manager. Acquired for the full body of
RescanRepositories so the snapshot → branch → mutate sequence is
serialized. repoTrackersMu still guards individual field reads from
unrelated goroutines (subscribe/unsubscribe/poll-mode).
- appendNewRepoTrackers re-checks membership inside the write-lock
before appending and drops already-present trackers (Stop + detach
outside the lock). Defense in depth.
- Test: add `dotted segment is legal as promotion` case so the
..hidden-segment promotion path stays exercised.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): wire permission approval UI for Kandev MCP tools
KandevToolMessage adapter ignored the permissionMessage prop, so MCP tools
that block on a permission request rendered the result row but no Approve/
Deny buttons — users could not unblock the agent. Extract the shared
permission handlers into use-permission-handlers.ts and reuse them from
both ToolCallMessage and KandevToolMessage. KandevRow now also shows an
amber pending clock icon and hides the result-count summary while waiting
for approval so the row reads correctly before the result arrives.
Adds a mock-agent scenarioKandevMCPPermission emitting
mcp__kandev__list_workspaces_kandev plus a Playwright e2e and unit tests
covering the pending UI and the absence of "0 workspaces" while pending.
* fix(web): collapse tool + permission status into resolved Kandev row
After approve/reject the orchestrator stamps the decision onto the
permission_request message immediately, but the tool_call's own
meta.status stays "pending" until the next tool_call update lands.
KandevToolMessage previously passed meta.status straight to the renderer,
so the row kept the amber pending clock and the hidden-summary state for
the brief window between the two events. resolveKandevStatus now prefers
the permission decision once present, mapping approved -> complete and
rejected -> error.
Drops a redundant Playwright `.first()` after a toHaveCount(1) assertion
on the same locator. Adds unit coverage for the resolved-decision state.
* fix(web): keep Kandev tool status independent of permission decision
The previous fix collapsed an approved permission into tool status
"complete" and a rejected permission into "error". That was wrong on two
counts: an approved tool that's still running rendered as already done
(green check + visible result summary built from an empty payload), and
any later tool_call error was silently masked because the status had
already been forced to "complete".
Replace the status override with a separate display overlay. KandevRow
now reads a `KandevPermissionUIContext` ("pending" | "rejected" |
undefined) and uses it only to drive the waiting-on-user affordances —
amber clock, hidden summary — and a rejection mark that wins over the
tool's own success state. The renderer always receives the real
tool_call status, so a tool that's approved-but-pending still reads as
pending and a tool that errors after approval still renders the red X.
Adds explicit regression tests for the two race-window failure modes
plus the new rejected overlay.
* test(web): tolerate duplicate-render backend race in Kandev MCP e2e
CI shard 1 sometimes renders two approve buttons for the same Kandev MCP
permission because the orchestrator can create a second generic tool_call
message for the same tool_call_id alongside the kandev-routed one. Both
buttons resolve the same pending_id, so clicking the first unblocks the
agent either way.
The bug the test guards against is "no approve button at all" — the
strict toHaveCount(1) assertion was tying the test to that separate
backend issue. Relax to at-least-one and click the first.
* fix: persist Deny clicks as rejected, not approved
Backend RespondToPermission previously derived status purely from cancelled:
cancelled=true -> "rejected", otherwise "approved". Deny clicks sent
cancelled=false with a reject_* option_id, so they were recorded as
"approved". Add an explicit rejected flag from the WS request so the
backend can record "rejected" without taking the cancellation path (which
races against permission_cancelled -> "expired").
Also refactor permission-request-message.tsx to consume the shared
usePermissionResponseHandlers hook (the duplicated inline handler had the
same bug) and introduce a typed models.PermissionStatus enum on the
backend so status strings are not scattered literals.
* test(web): scope Kandev MCP permission e2e to the Kandev row
Previously the test clicked the first matching permission-approve button,
which could be a duplicate generic ToolCallMessage fallback rendered for
the same pending_id by a backend race -- a green test no longer proved the
custom Kandev approval UI rendered.
Add a `data-testid="kandev-tool-permission"` wrapper around the Kandev
action row and a `kandevPermissionApproveButtons` page-object locator that
scopes through it, then assert `toHaveCount(1)` so the test only passes
when the Kandev custom row actually mounts.
* chore(orchestrator): log rejected flag in RespondToPermission
Debug log was missing the new rejected param, making it impossible to
distinguish explicit Deny from dialog cancellation when triaging
permission-flow issues.
* feat(web): structured renderers for Kandev MCP tool calls
Replaces the raw-JSON dump in the chat for every Kandev MCP tool with a
per-tool structured view. Adds 20 renderers — list (workspaces, workflows,
workflow-steps, tasks, related, agents, executor-profiles, task-documents),
task actions (create/update/move/message), plan + document (get/create/update/
delete plan, get/write document, conversation), and ask_user_question.
Dispatcher in message-renderer.tsx scans tool name on metadata.title,
comment.content and normalized.generic.name (the ACP normalizer's "name"
field is the *category*, often "other", not the tool name).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): address review feedback on kandev tool renderers
- ask_user_question: accept both keyed-object and positional-array
responses shapes (replaces pickObject which silently discarded arrays).
- get_task_conversation: derive the "more not shown" footer from the
backend's absolute total so paginated responses don't hide pages, and
guard the leading SummaryDot when both taskId and sessionId are absent.
- IdChip: drop unused label prop and the stale label="..." callsites.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): consistency follow-ups on kandev renderers
- Guard the leading SummaryDot in GetTaskPlanRenderer and
UpdateTaskPlanRenderer the same way GetTaskConversationRenderer does.
- ListTaskDocumentsRenderer reads `total` from the result like the other
list renderers, so a paginated document list doesn't understate.
- Add a regression test for the positional-array `responses` branch in
matchAnswerForQuestion.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): guard MoveTaskRenderer SummaryDot
Same orphan-dot fix as the other renderers — only render the dot when
both flanking IdChips are present.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): final review nits on kandev renderers
- ask_user_question: use shortId() for the pending_id hint instead of
hand-rolled slice (handles short ids correctly).
- list-renderers: add a stable index fallback to every map() key when
id/name/title are all absent, so React reconciliation stays stable on
malformed payloads.
- MoveTaskRenderer: extend hasExpandableContent to cover workflow/step
rows so they remain accessible without task/prompt data.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(web): prettify Kandev MCP tool titles in chat
Render `create_task_kandev` as "Kandev: Create Task" instead of the
raw underscored tool name. Frontend-only display transform via a
regex-gated helper so non-Kandev tool names (Read, Bash, other MCP
servers, Claude-supplied human titles) pass through unchanged.
* fix(web): update e2e assertions for prettified Kandev tool titles + trim pass-through
- E2E `config-management.spec.ts` asserted raw `*_kandev` names in chat;
update to the new "Kandev: ..." display so Shard 6/10 passes.
- `prettifyToolTitle` now returns `trimmed` instead of `raw` on the
non-match path so whitespace handling is symmetric across both
branches (Greptile review).
* fix(web): expand collapsed tool-call groups in e2e config-management assertions
Once a turn completes, consecutive tool-call messages collapse into a
"{N} tool call(s)" group header, hiding the individual prettified titles
the test asserts against. The previous tests passed by lucky substring
match against the script directives in the user prompt; once
prettification removed `*_kandev` from the chat surface that
coincidence broke. Add an `expandToolCallGroups` helper that clicks
each group header before running the title visibility assertions.
* refactor(web): trim ACRONYMS to only entries used by current Kandev tool catalog
Per "no speculative code" guideline (Claude review): only `mcp` is
produced by the current 29-tool catalog. Remove the unused
id/url/api/ui/vs/ide/cli entries; add new ones when a catalog tool
first needs them.
* fix(web): strip MCP namespace prefix when prettifying Kandev tool titles
Codex passes the tool name as `<server>/<tool>` (e.g.
`kandev/get_task_plan_kandev`), so the bare-suffix regex didn't match
and the raw underscored name leaked through to the chat. Split on
`/` or `__` and prettify the trailing segment, covering Codex's
`server/tool` and Claude's `mcp__server__tool` naming alike.
* feat(orchestrator): carry sender_task metadata through user message paths
Add WithSenderTask builder to UserMessageMeta and a metadata-aware
QueueMessageWithMetadata variant on the message queue. The drain path
in event_handlers_agent merges queued metadata into the recorded user
message so attribution survives the queue → DB transition.
* feat(mcp): attribute message_task_kandev calls to the sending agent
The MCP server inside agentctl now injects sender_task_id and
sender_session_id from its own server context. The backend handler
validates the sender (rejecting missing IDs, self-messages, and unknown
tasks), looks up the sender title, wraps the prompt in a <kandev-system>
attribution block, and propagates structured metadata across all three
delivery paths (queue, created-session, record+prompt). The wrapped
content lets the receiving agent identify the peer both live and on ACP
session resume; the existing sysprompt strip pipeline hides the wrapper
from the UI.
* feat(web): render sender-task badge on cross-task agent messages
User messages whose metadata carries sender_task_id now render a
clickable purple badge above the bubble, linking to the source task.
The badge live-resolves the sender's title from the kanban store and
falls back to a greyed snapshot when the task isn't loaded
(cross-workspace, deleted, etc.). Adds a non-subscribing useTaskById
selector so badges don't fan out WS subscriptions per message.
* test(e2e): cover cross-task agent message attribution
Eight scenarios driven through the mock-agent's MCP script directive:
queue path, prompt path, self-message rejection, unknown-target error,
badge link target, live title re-resolution after rename, kandev-system
wrapper preservation in raw_content, and prompt bodies containing their
own kandev-system tags. Adds an updateTaskTitle helper to ApiClient.
* fix: address CodeRabbit + Greptile P1 findings on PR #819
- requeueMessage now forwards queuedMsg.Metadata via QueueMessageWithMetadata
so sender attribution survives transient delivery failures and retries.
- CREATED-session branch in handleMessageTask records the user message AFTER
StartCreatedSession succeeds, so a launch failure no longer leaves an
orphaned attributed chat row.
- SenderTaskBadge: collapse identical displayTitle/fullTitle locals.
- E2E: extract waitForTargetIdle helper; replace the 1.5s raw sleep in the
collision-check test with the same poll the rest of the suite uses.
* fix: address Claude review suggestions on PR #819
- wrapAgentMessage strips </kandev-system> from the embedded sender title
before formatting. Without this, a title containing the closing tag
would short-circuit the non-greedy strip regex and leak the attribution
tail into the visible UI bubble. Metadata snapshot keeps the original
title so the badge still displays it.
- Add a regression test covering the malicious-title case.
- Document the deliberate cross-workspace messaging policy on the sender
lookup so future readers don't add a workspace-scope check (Q13 of the
feature plan locked this as intentional).
* fix(web): exclude permission_request from turn-complete safety net
The session.turn.completed handler iterated over messages and forced
metadata.status="complete" on anything carrying tool_call_id. Permission
prompts also carry tool_call_id (for FE pairing with their tool_call),
so this loop overwrote the user's "approved" decision on every prompt
in the turn — and the FE component re-rendered the Approve/Deny buttons.
* fix(backend): protect permission_request rows from tool_call sweeps
Three permission_request invariants the prior code violated, mirroring
the FE bug fix:
- CompletePendingToolCallsForTurn: SQL UPDATE filtered by tool_call_id
presence, also catching permission_request rows. Add type filter so
the safety net only touches actual tool messages.
- GetMessageByToolCallID: returned the oldest row carrying that
tool_call_id, which races to the permission_request when timing
shifts. Exclude permission_request type so a tool_update can never
land on the user's approve/reject row.
- applyToolCallMessageUpdate: defensive guard that refuses to overwrite
a permission_request, making the invariant explicit at the layer that
does the writing.
Adds a regression test for CompletePendingToolCallsForTurn covering
approved/rejected/pending permission rows.
* test(e2e): cover approval-reappear regression with mock-agent scenario
Adds a multi-permission mock-agent scenario (three sequential bash
tools, each requiring approval) and an E2E test that approves all
three and asserts no permission action row remains visible after
the agent's turn ends, and that the resolved state survives a reload.
Supporting test infrastructure:
- data-testid hooks on PermissionActionRow.
- permissionActionRows() / permissionApproveButtons() on SessionPage.
- backend fixture force-disables AGENTCTL_AUTO_APPROVE_PERMISSIONS=false
so a developer's local AUTO_APPROVE=true env doesn't silently bypass
the permission UI in tests that exercise the approve/reject flow.
* test(backend): cover GetMessageByToolCallID permission_request exclusion
Addresses claude-review feedback on the approvals fix:
- Adds a regression test that seeds both a permission_request and a
tool_call with the same tool_call_id (permission_request created
first, so an ORDER BY created_at ASC sweep without the type filter
would race to it) and asserts the lookup returns the tool_call row.
- Bumps the applyToolCallMessageUpdate invariant guard from Warn to
Error: reaching that branch means a caller bug, not a transient
condition, and Warn would silently swallow the violation.
* fix(e2e): scope auto-approve override to permission-approval test only
The previous fixture force-disabled AGENTCTL_AUTO_APPROVE_PERMISSIONS
globally, which broke session-isolation.spec.ts and other tests that
use scenarios with permission-required tools (e.g. /e2e:read-and-edit)
and rely on auto-approve to let the agent finish without UI clicks.
Default the env to "true" in the fixture; let permission-approval.spec
flip it to "false" via a backend.restart() override in beforeAll, then
restore in afterAll. Adds an envOverrides parameter to backend.restart.