Commit Graph

259 Commits

Author SHA1 Message Date
João Salavisa d83c1dd982 feat(diff): use stored base_branch for diff stats + per-task compare picker (#1273)
* feat(diff): use stored base_branch for diff stats + per-task compare picker

Task-card additions/deletions and the Changes panel were computed against a
hardcoded origin/main → master priority list inside agentctl. For tasks
branched off a release/staging branch this inflated the counts by every
commit since the actual fork point, and the Changes panel grouped unrelated
files alongside the task's real work.

This wires the per-repo task_repositories.base_branch end-to-end into the
agentctl WorkspaceTracker so each repo's diff stats resolve against the
recorded base instead of the integration-branch fallback. A picker on the
Changes panel branch hover card lets the user change the base mid-task; the
new value is persisted via PATCH /tasks/:id/repositories/:repo_id, pushed
live to the running agentctl instance, and the session's cached
base_commit_sha is cleared so the commits panel + cumulative diff refetch
against the new base without a session restart.

Falls back to the existing origin/main → master list for legacy tasks
(empty base_branch) and to the branch tip when no merge-base exists with
HEAD (unrelated histories — local rebase backups, etc.) so the stats and
commits panels stay anchored on the same SHA in every failure mode.

* fix(agentctl): sanitize user-controlled base_branch refs before git exec

CodeQL flagged the picker's user-controlled base_branch payload reaching
exec.Command("git", ..., baseBranch) inside workspace_git_cmd.go as a
command-built-from-user-controlled-source vulnerability. A malicious caller
could smuggle a leading-"-" argument like "-upload-pack=..." that git
itself reinterprets as a flag, even though exec.Command keeps args
separate from the shell.

Adds an IsSafeGitRef sanitizer at every boundary the value crosses on its
way to git: the service write path, the agentctl HTTP handlers
(/workspace/base-branches, /git/log, /git/cumulative-diff), and
WorkspaceTracker.SetBaseBranch itself. Unsafe values are rejected at the
service tier and silently downgraded to the no-override fallback inside
agentctl so a misbehaving caller can't half-break the tracker.

* fix(diff): address PR review blockers and CI lint

- Split UpdateRepositoryBaseBranch into validate/load/apply helpers to drop
  cyclomatic complexity below the cyclop limit that gated Run Backend Tests.
- Replace direct m.cfg.BaseBranches reads/writes with getBaseBranches /
  setBaseBranches under a dedicated RWMutex. UpdateBaseBranches used to
  write under repoTrackersMu while rescan + lazy-subpath callers read under
  workspaceTrackersMu, leaving an unsynchronized data race on the map.
- Drop the false 'PR target is auto-wired' claim from the MCP tool
  description — GitOperator.CreatePR takes baseBranch as an explicit arg
  and never reads the tracker's value.
- Picker no-op guard now compares against currentBase instead of storedBase
  so selecting the displayed fallback (legacy task before any picker
  change) is treated as no-op instead of triggering a spurious save.
- HTTP handler now maps non-validation service errors to 500 instead of
  400, classifying via the existing isValidationError helper plus the new
  unsafe-git-ref message.

* fix(diff): regex-based ref sanitiser at every git subprocess sink

CodeQL kept flagging workspace_git_cmd.go:43 even after SetBaseBranch
rejected unsafe values — the taint tracker doesn't follow sanitised stores
through struct fields, so it still saw user input flowing into
exec.Command("git", ..., baseBranch).

Switches IsSafeGitRef to a single regex check (a pattern CodeQL recognises
as a sanitiser barrier) and adds SanitizeGitRef calls inline at every
agentctl call site that splices a user-controlled ref into a git arg:

  - workspace_git_status.resolveStoredRef (before the rev-parse probes)
  - workspace_git_status.computeBaseCommit (before merge-base/rev-parse)
  - workspace_git_status.getAheadBehindCounts (before rev-list)
  - agentctl/server/api/git.go runGitLogForRepo + handleGitCumulativeDiff
    (before computeMergeBase/GetRevParse on the HTTP TargetBranch)

Behaviour is unchanged for valid refs — only narrows the allowlist (now
[A-Za-z0-9_./-]) so the sanitiser barrier is recognisable to static
analysis. SetBaseBranch's field-store sanitiser stays as defence in depth.

* fix(diff): transform sanitiser at every map read for CodeQL taint flow

CodeQL still flagged workspace_git_cmd.go:43 because:
- handleSetBaseBranches used an IsSafeGitRef bool guard before a verbatim
  map[k] = v copy. CodeQL recognises regex-transforming sanitisers but not
  bool-guarded passthroughs.
- lookupBaseBranch returned m.cfg.BaseBranches[k] verbatim. Even though
  the map was sanitised on write, taint flow across map stores + field
  stores loses the sanitised state in the analyser.

Switches both sites to the transforming SanitizeGitRef helper so every
read produces a freshly-sanitised value before it flows to a downstream
git argument. Behaviour is unchanged for valid refs.

* fix(diff): regex transformer sanitiser + guard live push from empty map

CodeQL still flagged the previous SanitizeGitRef because it returned the
input string unchanged on success — an identity passthrough that the taint
tracker doesn't recognise as a sanitiser barrier.

Switches to safeGitRefPattern.FindString so the output comes from the
regex match (not the parameter). IsSafeGitRef now wraps SanitizeGitRef so
the predicate and the transformer share one allowlist.

Also addresses two Claude review blockers:

- collectTaskBaseBranches returns a nil/empty map when the task has no
  recorded base_branches; pushing that to agentctl wipes every tracker's
  override. Now skipped — the DB row is the source of truth for the next
  session launch.

- applyBaseBranchSideEffects now runs on context.WithoutCancel so an HTTP
  / WS request cancel after the task_repositories UPDATE commits can't
  leave session.base_commit_sha stale + the live agentctl tracker map
  un-pushed, drifting from the persisted row.

* fix(diff): share securityutil.IsValidBranchName + harden error classification

CodeQL still flagged the picker's regex sanitiser as taint passthrough.
Refactors SanitizeGitRef / isSafeBaseBranchRef to delegate to the existing
securityutil.IsValidBranchName helper that agentctl's Rebase / Merge /
RenameBranch already use without being flagged. The shared allowlist also
fixes a Claude review blocker: 'feature/@2024' used to pass the service
validator but get dropped by the agentctl-side check at push time,
silently wiping every tracker's override.

Plus the two error-classification blockers Claude raised on the previous
HEAD:

- WS handler (task_ws_handlers.go wsUpdateTaskRepository) now distinguishes
  validation errors (surfaced verbatim) from internal faults (opaque
  500-style message). HTTP path already did this via the existing
  isValidationError helper.
- MCP handler (handlers.handleUpdateRepositoryBaseBranch) gets the same
  treatment via a new isValidationError local to the mcp/handlers
  package so raw service error strings stop leaking across the MCP
  boundary.

* fix(diff): inline securityutil.IsValidBranchName at every git subprocess sink

CodeQL's interprocedural taint tracking wasn't recognising SanitizeGitRef
as a sanitiser even after it delegated to securityutil.IsValidBranchName
— the wrapper helper + map/field hops between source and sink kept the
flow tainted in the analyser.

Inlines the IsValidBranchName allowlist check (already used inline by
Rebase/Merge in git.go without being flagged) at every call site that
splices a user-controlled ref into a git subprocess argument:

- workspace_git_status.resolveStoredRef (before rev-parse --verify)
- workspace_git_status.computeBaseCommit (before merge-base / rev-parse)
- workspace_git_status.getAheadBehindCounts (before rev-list)
- agentctl/server/api/git.go runGitLogForRepo + handleGitCumulativeDiff
- agentctl/server/api/workspace_base_branches.go handleSetBaseBranches

Behaviour unchanged: same allowlist as Rebase / Merge / RenameBranch
already use. SanitizeGitRef / IsSafeGitRef survive as defence-in-depth
helpers for callers that want a one-line check; they delegate to the same
securityutil helper so the rules stay aligned.

* fix(diff): inline regexp.MatchString at every git subprocess sink

CodeQL still flagged workspace_git_cmd.go:43 even with the inline
securityutil.IsValidBranchName helper — the taint tracker apparently does
not follow into helper functions for the go/command-injection rule on
this path.

Hoists the regex to package-scope vars (safeBranchRefPattern in both
process and api packages) and calls regexp.MatchString directly at each
sink site. Behaviour is identical to securityutil.IsValidBranchName; only
the call form changes.

* fix(diff): SHA-shape allowlist on BaseCommit before downstream git diff

CodeQL still flagged workspace_git_cmd.go:43 because update.BaseCommit
is the OUTPUT of an earlier merge-base / rev-parse call whose argument
list contained a user-controlled branch ref — the analyser treats that
command's output as still tainted even when the input ref was sanitised.

Adds an inline 40-hex SHA-shape allowlist check in enrichWithBranchDiff
right before `git diff --numstat <update.BaseCommit>` so the sanitiser
barrier sits in the same function as the next subprocess call. Behaviour
is unchanged: merge-base / rev-parse already produce SHA-shaped output.

* fix(diff): async tracker refresh + tighten init spread order

Two CodeRabbit/Claude review fixes:

- UpdateBaseBranches stamps each tracker's new baseBranch synchronously
  then runs RefreshGitStatus on a background goroutine instead of inside
  the picker-save HTTP handler. Per-tracker git subprocesses can each
  take ~3-5 calls; serialising them on the request goroutine made the
  PATCH response wait for every refresh to finish. The picker UI re-fetches
  via the existing WS stream once Refresh emits, so the handler doesn't
  need to block.

- updateTaskRepositoryBaseBranch now spreads options.init BEFORE the
  required method/body fields so an upstream caller's init overrides
  can't replace the PATCH method or the base_branch payload. Matches
  the convention deleteTask / moveTask already follow.

* fix(diff): narrow mcp isValidationError + opaque 500 + post-sanitize log count

Three final Claude review threads on the latest commit:

- mcp/handlers isValidationError drops the broad 'invalid' substring so
  DB / IO errors whose messages happen to contain 'invalid' don't
  misclassify as ErrorCodeValidation.

- task_http_handlers httpUpdateTaskRepository now logs the raw service
  error server-side and returns an opaque 'failed to update task
  repository' on the 500 path. Stops potential DB / IO detail leakage
  through the HTTP boundary.

- workspace_base_branches debug log reports accepted + rejected counts
  (post-sanitisation) instead of the pre-filter total so the metric
  actually reflects what hit UpdateBaseBranches.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-06-05 15:53:39 +01:00
Carlos Florêncio df9a68a103 fix(agentctl): unblock next prompt when agent does not acknowledge cancel (#1259) 2026-06-03 22:36:24 +01:00
Carlos Florêncio 7917c95496 feat(backend): inject MCP servers in CLI passthrough mode per agent (#1158) 2026-06-03 00:09:05 +01:00
Carlos Florêncio 3e2aba45ed fix(agentctl): sticky-max context window size with reset on model switch (#1254) 2026-06-02 22:58:31 +01:00
João Salavisa 6a684211fc fix(agentctl): reap disconnected ACP sessions + MCP child trees (#1249)
* fix(agentctl): reap disconnected ACP sessions + MCP child trees (#1247)

Two independent leaks let opencode acp sessions accumulate indefinitely:

1. instance.Manager never reaped disconnected sessions. StopInstance
   only ran via the kandev DELETE route, so any session whose backend
   went away (UI closed mid-run, WS dropped, opencode failed to emit
   complete) was orphaned forever.
2. Even when DELETE fired, the ACP adapter unconditionally returned
   RequiresProcessKill=false. opencode acp keeps an HTTP server + MCP
   child tree alive after stdin EOF, so closing stdin was a no-op and
   the children re-parented to init.

Fix both:

- Plumb RuntimeConfig.RequiresProcessKill from agents.OpenCodeACP
  through the ExecutorCreateRequest -> CreateInstanceRequest ->
  InstanceConfig -> adapter.Config -> shared.Config chain. acp.Adapter
  now reads cfg.RequiresProcessKill instead of hardcoding false.
- Belt-and-braces: process.Manager.waitForProcessExit timeout fallback
  now killProcessGroup first (only falls back to single-process kill
  if that errors), so any future ACP CLI that ignores stdin EOF still
  gets reaped.
- Add an idle-instance reaper to instance.Manager. Per-instance
  activity middleware tracks inflight HTTP requests + lastActivity;
  a background goroutine sweeps every minute and StopInstance's any
  instance idle past KANDEV_ACP_IDLE_TIMEOUT (default 1h, set to 0
  to disable). Long-lived /agent/stream WS connections keep the
  inflight counter above zero so active sessions are never reaped.
- Validate stdio MCP commands at CreateInstance time via
  exec.LookPath; missing entries are dropped with a warn instead of
  spawning a permanently broken child every session (fixes the
  stale /snap/bin/brave MCP repro in the issue).

Regression tests: process-group kill (parent+child fixture), idle
reaper end-to-end, MCP validation, opencode-acp opt-in vs other
ACP agents' default.

* fix: address PR review feedback on #1247 fix

- idle_reaper: extract reapSingleIdleInstance helper with defer cancel(),
  add reaperStop polling between sweep iterations so Shutdown drains in
  one StopInstance window instead of N * timeout (cubic, claude, greptile).
- instance.Manager: use sync.Once instead of m.mu to gate reaper-stop
  channel close (claude).
- mcpStdioValidationError: tolerate compound Command strings by
  resolving only the first whitespace-delimited token; reject entries
  with neither URL nor Command outright; document agentctl-vs-agent
  PATH skew in Docker/SSH executor modes (claude, greptile, cubic).
- manager_killgroup_test: treat zombie state as dead via /proc/<pid>/stat
  on Linux + bump Eventually window to 15s — CI containers' PID 1 can
  delay harvesting orphans long enough to flake signal(0) probes.
- instance package: add goleak TestMain for the new reaper goroutine
  (coderabbit).
- AGENTS.md: correct SIGKILL vs SIGTERM wording; clarify the timeout
  fallback only fires on ctx.Done (coderabbit, cubic).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-06-02 21:46:49 +01:00
Carlos Florêncio fac2ad5039 fix(backend): stabilize ACP session resume and workspace git poll under contention (#1242) 2026-06-02 21:22:45 +01:00
João Salavisa a1143aa525 fix(acp): handle claude-acp async_launched subagents end-to-end (#1244)
* 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>
2026-06-02 14:30:36 +01:00
João Salavisa cb7b5429d5 fix(tasks): harden multi-branch add_branch and surface it in UI (#1239)
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>
2026-06-02 00:14:00 +01:00
Carlos Florêncio a8db7478ac fix(backend): cap gh/git fork concurrency via shared subproc throttles (#1216) 2026-06-01 20:35:43 +01:00
João Salavisa fd79d6170f feat(tasks): multi-branch tasks — N (repo, branch) pairs per task (#1226)
* 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>
2026-06-01 19:36:20 +01:00
Carlos Florêncio 0f87f7a56b fix(acp): enrich tool call metadata per agent with structured ACP fields (#1212) 2026-06-01 19:12:32 +01:00
Carlos Florêncio 13e6e2d89b fix(acp): serialize wakeup prompts to prevent turn misalignment (#1202) 2026-05-31 21:12:36 +01:00
Carlos Florêncio d1c73d2f2c feat: surface subagent task tool calls as cards in task chat (#1132) 2026-05-31 17:53:21 +01:00
Carlos Florêncio 700ff4199c fix(agentctl): write ACP debug logs under KANDEV_HOME_DIR (#1196) 2026-05-31 17:10:08 +01:00
Carlos Florêncio af3b26b6d9 fix: track OS PIDs and clean up shells on task archive/delete (#1191) 2026-05-31 14:38:52 +01:00
Carlos Florêncio a10e81c0d7 fix: preserve whitespace in acp message chunks (#1190) 2026-05-31 12:31:56 +01:00
Carlos Florêncio 4da076c43e feat(backend): per-session ACP debug logs with rotation and retention (#1172) 2026-05-30 19:10:09 +01:00
João Salavisa cab7b28ed9 chore: round 4 dead-code cleanup (transport helpers, detect, orphan methods) (#1156)
* chore(agentctl): delete duplicate procattr files in adapter package

setProcGroup and killProcessGroup are defined in both
agentctl/server/adapter/ and agentctl/server/process/. The process/
copies are the live ones (called by process/runner.go, manager.go,
vscode.go); the adapter/ copies have zero callers and are removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(agentctl): remove orphan helpers from transport/shared

Left over from the ACP refactor. All had zero callers:
- DebugEnabled(): internal debugMode var is checked directly by
  LogRawEvent/LogNormalizedEvent — the exported wrapper was unused.
- GetBool, GetMap, GetSlice: only GetString/GetInt are called in acp/normalize.go.
- NormalizeShellResult, NormalizeReadResult, NormalizeCodeSearchResult,
  NormalizeModifyResult: normalization moved to acp/normalize.go.
- TruncateIfNeeded, MaxContentLength, MaxFileCount: only used by the
  four Normalize* helpers above; removed transitively.
- TraceControlMessage: zero callers in the adapter transport layer.

GenerateUnifiedDiff, SplitLines, DetectLanguage, Tracer, ShutdownTracing,
TraceProtocolEvent, TraceProtocolRequest are alive and kept.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(agentctl): remove unused tool_payload constructors

NewHttpRequest, NewCreateTask, NewSubagentTask, NewShowPlan,
NewManageTodos, and NewMisc have zero callers — ACP normalization
builds NormalizedPayload via struct literals and JSON unmarshaling,
not these factory functions. NewReadFile, NewModifyFile, NewShellExec,
NewCodeSearch, and NewGeneric are kept (live callers in acp/normalize.go).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(agents/detect): remove dead fluent options and OSPaths type

WithFileExists, WithCommandOutput, WithEnvVar, expandHomePath (their
transitive helper), and OSPaths (struct + Resolve + Expanded) have
zero callers. Only WithCommand and WithNpxRunnable are used by agent
IsInstalled implementations. Imports trimmed to context and os/exec.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(office): remove dead channel methods superseded by service/

ListComments, DeleteComment, and UpdateChannelStatus on ChannelService
have zero callers — the live implementations live in
internal/office/service/comments.go and service/channels.go.
CreateComment is kept: it is called by HandleChannelInbound.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove scattered orphan helpers

Remove dead single-symbol exports verified to have zero callers:
- ParseCronExpression (office/shared/cron.go): NextCronTime covers
  all live usages; the validator wrapper was unused.
- IsValidPathComponent, MustJSON (office/shared/helpers.go): zero
  callers; GenerateSlug is kept.
- DiscoverUserSkillsAt (office/skills/userhome_discovery.go): exported
  wrapper over the private discoverUserSkillsAt; only the internal
  DiscoverUserSkills method is wired up.
- Manager.Get (agent/loginpty/manager.go): zero callers; GetByID and
  Stop are kept (Stop is tested in manager_test.go).
- DetachedWithValues (common/appctx/appctx.go): zero callers; Detached
  is kept.
- Repository.Close no-ops (task/share, terminal/repository): both are
  documented no-ops with zero callers.
- TaskBranchName (worktree/config.go): zero callers; the deterministic
  TaskBranchNameWithSuffix is kept.

Note: dialect.Now was initially targeted but is tested in
dialect_test.go (same-package call — missed by package-qualified grep).
It remains unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(share): remove stale Close reference from provider.go comment

Repository.Close() was removed as a no-op in the round-4 cleanup.
The comment in provider.go still referenced it by name; updated to
reflect that the repo simply doesn't own its DB connections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(office/shared): delete helpers.go — GenerateSlug had zero callers

The triage step mistakenly identified skills/service.go as a caller,
but that package defines its own local GenerateSlug (different regex,
adds TrimSpace and a 'skill' fallback default). shared.GenerateSlug
was never called anywhere. helpers.go is now empty; delete the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(worktree): fix stale TaskBranchName reference in TaskBranchNameWithSuffix doc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:10 +01:00
José Almeida b109c84e4e fix(backend): pass HTTP MCP servers to ACP agents via AssumeMcpHttp (#1152)
* fix(backend): pass HTTP MCP servers to ACP agents via AssumeMcpHttp

* test(backend): cover AssumeMcpHttp override path with effectiveMcpCapabilities helper
2026-05-29 12:58:51 +01:00
João Salavisa de92557f46 test(backend): wire goleak goroutine-leak detection across 8 packages (#1139)
* chore(backend): add go.uber.org/goleak test dependency

Promotes the previously-indirect goleak v1.3.0 to a direct require so
per-package TestMain calls can use it for goroutine leak detection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(backend/lifecycle): drain workspace-stream backoff on Manager.Stop

StreamManager.connectWorkspaceStream slept the full retry backoff in
time.Sleep between failed attempts. SessionTraceContext is deliberately
uncancellable (it carries a long-lived span), so if the Manager began
shutting down mid-backoff the goroutine outlived Stop() and stranded
until the timer fired.

Thread Manager.stopCh through the StreamManager constructor and add a
sleepOrStop helper that selects between the timer and the shutdown
signal. ReconnectAll's startup sleep gets the same treatment.

TestConnectWorkspaceStream_BackoffDrainsOnStop closes stopCh while a
failing-port retry is in flight and asserts the goroutine exits within
500ms — fails without the fix, passes with it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(backend/github): close sqlite DB in newTestStore to drain connectionOpener

newTestStore opened *sqlx.DB via db.OpenSQLite but never registered a
Close on cleanup, leaving database/sql.(*DB).connectionOpener goroutines
parked across test boundaries. Add t.Cleanup(sqlxDB.Close); goleak's
per-package TestMain (added in a follow-up commit) is the regression
check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(backend): wire goleak.VerifyTestMain across leak-prone packages

Per-package TestMain in:
- internal/integrations/healthpoll (auth-health ticker loop)
- internal/jira, internal/linear (auth + issue-watch loops)
- internal/github (PR / review / issue poller loops)
- internal/gateway/websocket (per-conn pumps + terminal bridge)
- internal/orchestrator (detached event-handler goroutines)
- internal/agent/runtime/lifecycle (remote-status loop, StreamManager
  helpers; ignores OTel batch-span processor and OTLP retry helper —
  initialised early in the process and only exit on shutdown)
- internal/agentctl/server/process (extends the existing fixture-binary
  TestMain so subprocess + PTY pump leaks surface there)

Catches regressions where a Start/Stop forgets to drain on WaitGroup,
or a detached goroutine forgets to honour ctx/stopCh cancellation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(backend): document goroutine ownership and leak-testing convention

New "Goroutine ownership and leak testing" subsection under Conventions
in apps/backend/AGENTS.md (CLAUDE.md symlinks to it). Covers:

- Lifecycle: Start/Stop + WaitGroup, both idempotent.
- Cancellation: every long wait selects on ctx.Done() or stopCh; never
  time.Sleep in retry loops (reference lifecycle.StreamManager.sleepOrStop).
- Detached helpers: must respect the owning type's shutdown signal.
- Leak testing: new packages spawning goroutines add goleak.VerifyTestMain;
  IgnoreTopFunction is reserved for genuine third-party background
  goroutines and must carry a comment explaining the suppression.

Lists the packages currently instrumented so future contributors can
mirror the pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore(backend/lifecycle): drop dead OTel IgnoreTopFunction from goleak setup

Review follow-up. agentctl/tracing.initTracing only spawns the OTel SDK
provider when OTEL_EXPORTER_OTLP_ENDPOINT is set; in tests it isn't, so
the noop provider is used and no batchSpanProcessor / OTLP retry
goroutines ever exist. The IgnoreTopFunction calls were dead
suppressions and the comment ("called by NewManager via the agent
execution trace path") misdescribed when the SDK init runs. Drop the
ignores and correct AGENTS.md to stop citing OTel as the canonical
suppression example.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(backend/lifecycle): drop unneeded 50ms sleep from backoff-drain test

QA follow-up. The 50ms time.Sleep before close(stopCh) in
TestConnectWorkspaceStream_BackoffDrainsOnStop was meant to ensure the
goroutine had entered the backoff sleep before shutdown — but that
ordering doesn't affect the test outcome:

  - With the fix: sleepOrStop sees stopCh already closed and returns
    false on the first call, so the goroutine drains within one attempt
    regardless of when stopCh closes.
  - Without the fix: time.Sleep ignores stopCh, the goroutine burns
    its full 5-retry backoff (~31s), and the 500ms timeout fires.

Removing the sleep makes the test ~12x faster (15ms vs 200ms total) and
removes the only time.Sleep-based wait from the new tests, aligning
with the codebase's "channel-based sync, no sleep waits" convention.
Verified by 50x -race and 5x revert-and-fail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(backend/github): drain HTTP idle conns before goleak inspects

CI surfaced a leaked goroutine on the Linux runner that local runs
missed: a single net/http.(*http2ClientConn).readLoop parked on a
TLS read, created by net/http.(*http2Transport).newClientConn. The
package's anonymousHTTPClient and PATClient's rewriteTransport both
route through http.DefaultTransport's connection pool, so an idle
HTTP/2 connection in that pool keeps the reader goroutine alive past
m.Run() — invisible on sandboxed local runs where outbound network
is blocked.

Replace github/goleak_test.go's plain VerifyTestMain with an
expanded TestMain that drains http.DefaultTransport's idle
connections before goleak.Find runs. This is the stdlib's
documented teardown for the connection pool, keeps the assertion
strict (no IgnoreTopFunction), and doesn't leak into production
code. Other in-scope packages don't need this — they don't use
http.DefaultTransport for HTTPS calls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(backend): drain ws.Done on stopCh; always run goleak in github tests

- lifecycle/streams.go: replace bare <-ws.Done() with a select that also
  watches sm.stopCh so a connected goroutine drains when Manager.Stop()
  fires before the remote WS closes. A nil stopCh (isolated tests) blocks
  the nil channel case forever, degrading to plain <-ws.Done() semantics.
- github/goleak_test.go: remove the exitCode==0 guard around goleak.Find
  so leaks are always surfaced even when a test has already failed; original
  exit code is preserved (leaks only promote it to 1).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(backend/lifecycle): close ws on stopCh so agentctl WS loops drain

cubic flagged that the previous shutdown path observed stopCh but never
called ws.Close(), so the underlying WebSocket and its agentctl-side
read/write goroutines (workspace_stream.go:67-68) kept running past
Manager.Stop(). ClearWorkspaceStream only removes the WS from the
execution's tracking — it doesn't close the connection.

Call ws.Close() on the stopCh path. ws.Close is idempotent via
closeOnce, so it's safe even if the remote end already closed.

TestConnectWorkspaceStream_ClosesWSOnStop connects to a mock workspace
server that stays open until the client closes its side; after stopCh
fires, the test asserts ws.Done() closes within 500ms — which is only
true if connectWorkspaceStream invoked ws.Close(). Verified to fail
without the fix and pass with it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(backend/lifecycle): use channel sync instead of sleep polling in ws-close test

cubic flagged that TestConnectWorkspaceStream_ClosesWSOnStop polled
execution.GetWorkspaceStream() in a 500ms sleep loop, which is brittle
under CI load. connectWorkspaceStream already accepts a ready
channel that closes after SetWorkspaceStream — pass it in and wait on
the channel instead.

synctest isn't viable here (real network I/O), so channel-based sync is
the testing convention per AGENTS.md.

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>
2026-05-29 10:23:18 +01:00
João Salavisa fa4a48d5e7 chore: remove orphan helpers, setters, and deprecated wrappers (#1085)
* chore: remove orphan executor tracing file and deprecated ResolveScript wrappers

- Delete internal/agentctl/tracing/executor.go (7 TraceExecutor* funcs)
- Delete internal/orchestrator/executor/script_template.go (deprecated wrapper)
- Delete scriptengine.ResolveScript convenience function

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove orphan setter methods across healthpoll, jira, linear, slack, updates

- Delete Poller.SetInterval from integrations/healthpoll (no callers)
- Delete Poller.SetIssueTickHook from jira and linear (no callers)
- Delete Trigger.SetInterval and Trigger.SetMatchHook from slack (no callers)
- Delete Service.SetPollInterval from system/updates (no callers)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove orphan wrapper functions and accessor method

- Delete updates.FetchLatestRelease shim (wraps FetchLatestReleaseFrom, no callers)
- Delete usage.NewClaudeUsageClient and NewCodexUsageClient shims (no callers)
- Delete database.Service.Path() accessor (no callers)
- Update ErrGitHubRateLimited godoc to reference FetchLatestReleaseFrom

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove unwired handleListExecutors MCP handler

The sibling profile handlers (list/create/update/delete) are registered
via RegisterFunc in handlers.go. handleListExecutors never was — dead flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove stale hook-field comments after setter deletion

SetIssueTickHook (jira, linear) and SetMatchHook (slack) were removed
in the deadcode cleanup. Their struct fields still claimed "tests use
this" — no test or caller can set these fields now.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: remove orphan hook fields and fire methods after setter deletion

issueTickHook (jira/linear) and matchHook (slack) are unexported and
never set after their public setters were removed; fireIssueTickHook /
fireHook were permanent no-ops.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 09:32:52 +01:00
Carlos Florêncio b38dc21fb0 fix: stop changes panel from showing stale or flickering content (#1128) 2026-05-28 23:35:32 +01:00
João Salavisa 7b75d4c39d refactor(acp): split adapter.go into per-concern files (#1054)
* refactor(acp): split adapter.go into per-concern files

Pure file split of the 2,083-line transport/acp/adapter.go into sibling
files inside the same package — no behavior change, no new types, no
signature changes. The struct, constructor, lifecycle, and locking-
sensitive helpers (sendUpdate, trace ctx, Close) stay co-located in
adapter.go (422 lines); session/mcp/auth, prompt/cancel/wakeup, update
dispatch, tool-call conversion, permission flow, and cross-file helpers
each move to their own file, all under the 800-line lint cap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(acp): restore struct doc wording on availableAuthMethods

independent-review fix: the file split should have been byte-identical for
comments. Restore "surface a picker" (matches origin/main) — accidentally
rewritten as "drive a picker" during the adapter.go rewrite in ccfad56d.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ci(acp): extract toolStatusInProgress / toolStatusCancelled constants

CI golangci-lint flagged 'in_progress' (9 occurrences) and 'cancelled'
(4 occurrences) as goconst violations after the file split surfaced them
as "new" lines. Extend the existing toolStatus* constant set in
normalize.go and use the new constants at every production call site
(adapter_tools.go, adapter_prompt.go, monitor.go). Behavior unchanged.

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>
2026-05-28 23:22:21 +01:00
Carlos Florêncio 21db57bdfd fix(orchestrator): repair worktree state and prompt persistence on resume (#1121) 2026-05-28 21:48:12 +01:00
Carlos Florêncio a068c88b99 fix: show human-readable title and details in permission prompts (#1101) 2026-05-28 16:24:26 +01:00
João Salavisa 991a6315c3 fix(backend): force fresh git status on session subscribe (#1092)
* fix(backend): force fresh git status on session subscribe

Every new WS subscriber to a session now triggers a fresh `git status
--porcelain` in agentctl instead of replaying the cached snapshot.
This makes the cache an optimization for already-subscribed observers
only — a fresh observer revalidates ground truth on every connect, so
a stale cache (e.g. left over when the poll loop missed a HEAD change
in paused mode or dropped a tick) self-heals on the next subscribe.

- `?fresh=true` on `/api/v1/git/status/multi` propagates through to
  `WorkspaceTracker.GetGitStatus(ctx, true)`.
- New `GetGitStatusMultiFresh` client method; `tryGetLiveGitStatus`
  (the session-subscribe hydration path) uses it.
- Regression test reproduces the bug: dirty file → cache primed →
  commit without refresh → cached read still shows the file as
  modified; fresh=true returns the clean truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR review feedback

- Reframe regression-test precondition comment so it reads as scenario
  setup (Claude review).
- Document GetGitStatusMultiFresh's read-only-vs-cache contract in its
  godoc so a future reader doesn't try to write the result back into
  the tracker's currentStatus (Claude review).
- Trim multi-line block comment in helpers.go to a single line per the
  backend AGENTS.md convention (Claude review).
- Parallelize handleGitStatusMulti's per-repo fan-out via sync.WaitGroup
  to keep the fresh-path wall-clock close to the slowest single repo
  regardless of repo count, so multi-repo workspaces don't blow past
  the 2s subscribe-path timeout (Greptile review). Pre-sized result
  slice means each goroutine writes its own slot — no shared-slice
  contention. collectStatusForRepo now takes context.Context directly
  instead of *gin.Context.
- Add a third assertion in the regression test that locks in the
  documented cache-unchanged contract — after fresh=true, a subsequent
  GetGitStatus(ctx, false) call must still return the stale cached
  value, so any future change that silently writes through fresh would
  fail the test (Greptile review).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address round 2 review feedback

- Trim the parallelization rationale in handleGitStatusMulti to a
  single line per AGENTS.md convention (Claude review).
- Drop unused GetGitStatusMulti client method (cached variant) and its
  internal getGitStatusMulti helper — only GetGitStatusMultiFresh has
  a caller after this PR, and AGENTS.md says to delete unused code
  outright (Claude review).

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>
2026-05-26 17:44:44 +01:00
Carlos Florêncio f1dbad1dbd feat(cli-mode): auto-inject task prompt + dynamic submit sequence + passthrough UX fixes (#923) 2026-05-25 19:14:36 +01:00
Carlos Florêncio 8a7b7a0b60 feat(agentctl): azure Repos PR follow-ups after #1066 (#1071) 2026-05-25 18:15:52 +01:00
Zaybrah ec21c71775 feat(agentctl): add Azure Repos PR creation (#1066) 2026-05-25 09:03:05 +01:00
Carlos Florêncio adc13c6b5a feat: copy gitignored files from repo into new worktrees (#946) (#950) 2026-05-24 20:12:26 +01:00
Carlos Florêncio e2483650d4 fix(agentctl): allow native ACP CLIs in probe allowlist (#1059) 2026-05-24 14:45:42 +01:00
José Almeida 3bc092bf20 feat(terminal): first-class user terminals — stable seq, rename, park/resume (#1009)
* feat(terminal): persist user terminals with stable seq + park state

Ordinary user shells now have a DB-backed `user_terminals` row keyed by
the agentctl PTY id, carrying a per-task monotonic seq (gaps preserved on
delete), custom_name override, and open/parked state. The terminal
service owns the create/list/rename/park/resume/destroy surface and
guards reject `bottom-panel` (cmd+J) + `script-*` ids so passthrough
flows are untouched. Closing a tab now parks (PTY keeps running);
destroy kills the PTY and removes the row.

* feat(web): expose terminal seq badge, rename, and parked-terminals menu

Wires the right-panel strip and SSR page state to the new task-keyed
terminals endpoint and discriminated UserShell union, adds the `#N`
badge for ordinary terminals, plumbs context-menu / double-click rename,
and renders a "Parked" submenu so closed-but-running terminals can be
resumed. Closing a managed tab now parks instead of destroying so the
PTY (and its buffer) survives accidental closes.

* test(terminal): e2e coverage for first-class persistent entities

Covers the persistence guarantees the user explicitly called out:
monotonic seqs, refresh-survives-rename-and-park, gap-preserving destroy,
park+resume cycle, and bottom-panel/script guards. Exercises the new
user_shell.{create,list,rename,park,resume,destroy} WS actions through
the shared ApiClient.wsRequest helper.

* fix(terminal): atomic seq allocation + propagate destroy errors

- Repository.Create folds MAX(seq) read + INSERT into a single
  `INSERT … SELECT` statement so concurrent creates can no longer pick
  the same seq. Locked in with TestCreate_ConcurrentSeqAllocation (25
  parallel creates, all unique).
- wsUserShellStop now propagates the service-Destroy error so the
  frontend stops optimistically removing a row the backend failed to
  delete.
- Service.Destroy / CleanupTask log PTY-stop failures at Warn (instead
  of silently swallowing) while keeping the proceed-on-failure semantics
  intentional — the DB row is the source of truth for "user expects
  this terminal gone".
- IsUserShellAlive copies ProcessID under the read lock before calling
  r.Get(), removing the post-unlock pointer-deref race.
- Get-not-found test asserts errors.Is(err, ErrNotFound) so it locks to
  the repository contract.

* fix(web): close-tab waits for backend ack + className undefined guard

- handleCloseTab defers `removeTerminal` into the `.then()` of the
  park/destroy promise. A failed backend call leaves the tab visible
  rather than vanishing it optimistically; the next `user_shell.list`
  poll reconciles state.
- session-tabs guards `tab.className` with `?? ""` so an undefined value
  no longer produces "undefined group relative..." in the rendered
  class string.
- user-shells.test simplification: `createSessionRuntimeSlice` only
  consumes `set`, so the test passes the slice creator straight to
  `immer()` instead of wrapping it in an adapter with unused args.

* fix(terminal): task-scoped mutations + legacy-shell list + UI hardening

Round-2 review feedback (cubic P1/P2, claude, coderabbit):

Backend:
- Rename/Park/Resume/Destroy now require a task_id and verify the
  terminal belongs to that task via a shared requireOwnership helper.
  Cross-task mutations return ErrTaskMismatch instead of silently
  succeeding by raw terminal id.
- wsUserShellList no longer drops managed-shaped agentctl shells when
  the terminal service is disabled or task_id is missing — legacy
  passthrough callers see their existing shells again. `managedCovered`
  flag scopes the orphan filter to the DB-backed path.
- service_test.go uses the shared-cache in-memory SQLite + MaxOpenConns(1)
  setup, matching repository_test.go and removing the flake-one-race-away
  warning.
- New cross-task guard tests: TestRename_RejectsCrossTask,
  TestPark_RejectsCrossTask, TestDestroy_RejectsCrossTask, plus a
  TestRename_EmptyTaskIDSkipsOwnership for internal callers.

Frontend:
- updateUserShell patch type narrowed to Partial<Omit<UserShellInfo,
  "terminalId">> so the row key can't be silently mutated.
- destroyUserShell now throws on a missing WS client instead of silently
  resolving — handleCloseTab's .then() no longer fires prematurely.
- rename/park/resume/destroy API client + useTerminals hook plumb the
  active task id through every mutating call.

E2E:
- New "cross-task mutating ops are rejected" spec creates a terminal
  under task A and verifies task B can't rename/park/destroy it by raw
  id.
- Existing specs updated to send task_id with their mutating calls.

* fix(terminal): require task_id on all mutators + wire taskID into hooks

Round-3 review feedback (cubic P1, claude blocker, coderabbit):

Backend:
- requireOwnership no longer skips the ownership check when taskID is
  empty. The previous carve-out was reachable from the WS layer (where
  task_id is user-controlled), so an attacker who guessed a terminal id
  could omit task_id and mutate any row. CleanupTask walks the repo
  directly and never touched the bypass.
- TestRename_EmptyTaskIDRejected pins the new contract.

Frontend (the real bug claude caught):
- useUserShells now accepts a taskId parameter and forwards it on the
  user_shell.list WS call. Without it the backend's DB-backed ordinary-
  shell path was skipped and the Zustand store wiped the SSR-hydrated
  panel strip on the first WS fetch.
- Response mapping switched to a discriminated union: id (new field)
  falls back to terminal_id (legacy), and kind/seq/state/pty_status/
  display_name/custom_name flow into the store so the parked-terminals
  submenu actually populates.
- useTerminals + useMobileTerminals read the active task id from the
  store and pass it through to useUserShells.
- destroyUserShell now retries with user_shell.stop when the server
  returns an unknown-action error — keeps the rollout-window
  compatibility intact for older backends that only know the legacy
  action name.

* fix(terminal): list parked, rollout-safe kind default, helper extraction

Round-4 review feedback (claude + greptile P1):

- useUserShells now sends include_parked: true so the "Parked terminals"
  submenu populates after a page reload. Previously the WS list returned
  only state=open rows; parked terminals were invisible until the user
  parked something in the same session. The main strip still hides
  parked entries via buildTerminalsFromShells.
- useAddTerminal stops blindly defaulting kind to "ordinary" — that
  misclassified legacy passthrough payloads (terminal_id without seq)
  during a rollout where the frontend is newer than the backend. The
  managed-tab path then called parkUserShell on a server that didn't
  know the action. Now only tags as ordinary when kind is explicit or
  the row carries a seq.
- handleCloseTab extracted into a standalone useCloseTab + helpers
  hoisted into use-terminals-build.ts / use-terminals-types.ts. Brings
  use-terminals.ts back under the 600-line file limit and the
  useTerminalActions wrapper inside the 100-line function limit.

* fix(terminal): scope-keyed shell cache + null-task guard + tighter rollout default

Round-5 review feedback (cubic P1, coderabbit x4):

- useUserShells now keys its fetch cache on env+task (`${env}:${task}`),
  not just env. An earlier load without a task id (e.g. before the
  active task hydrated) no longer pins the store on a legacy-only shell
  list and blocks the task-scoped refetch.
- useMobileTerminals delays auto-create until a real taskID is
  available. Previously a null taskID could bootstrap a legacy
  non-persistent shell, mark the env as auto-created, and starve the
  real task of its first-class terminal. Guard key is now env:task, and
  releaseAutoCreatedEnvironment clears every scope on that env.
- useAddTerminal tightens the rollout check: a row is "ordinary" only
  when `seq !== undefined`. A bare `kind: "ordinary"` without seq from
  an older backend stays legacy so close → destroy on the first click.
- useRemoveTerminal reads the current activeTab from a ref instead of
  closing over the value captured when handleCloseTab first ran. Async
  park/destroy resolves after a user tab-switch no longer clobber the
  newer selection with the stale fallback shift.

Tests:
- New "waits for taskID before auto-creating — null taskID is a no-op"
  case in use-mobile-terminals.test.ts confirms the null-task guard.
- All existing mobile-terminal + slice + E2E tests pass.

* fix(terminal): live activeTab lookup + selectable-tab guard

Round-6 review feedback (cubic P2 x2):

- getActiveTab now reads the active tab from the Zustand store via
  storeApi.getState() instead of a useRef/useEffect pair. Closes the
  post-render race where an async close handler could resolve between
  the new render's commit and the effect-driven ref sync.
- computeTerminalTabValue requires activeTab to be selectable
  (present in `terminals` or === "commands") before honoring it. A
  stale id left over from a parked/destroyed terminal no longer pins
  the panel on a nonexistent tab and blocks the fallback shift to a
  real shell.

* chore(terminal): inline savedTabFromStorage to drop file under line cap

Round-7 review feedback (claude suggestion): the useMemo around the
synchronous getSessionStorage call was zero-value memoisation that
pushed use-terminals.ts three lines over the 600-line max-lines limit.
Inlined; file now at 600 lines.

* chore(terminal): drop savedTabExists local + comment to land under 600 lines

Round-8 follow-up (claude): the previous commit's two-line comment block
held the file at 603 lines (still over the 600-line cap). Inline
savedTabExists at the only call site and remove the redundant comment —
the round-7 PR thread already explains why the useMemo was removed. File
now at 599 lines.

* fix(terminal): plumb task_id through dockview + tolerate legacy destroys

Manual-test feedback: clicking X on a dockview terminal panel surfaced
"destroy terminal: terminal does not belong to the supplied task" because
the dockview path was creating + destroying terminals without sending
task_id at all. The new requireOwnership guard then correctly rejected
the empty task_id against a non-empty term.TaskID.

Frontend:
- addTerminalPanel grows task_id + title parameters; stamps both into
  panel params at creation time so the destroy cleanup can pass them
  back to user_shell.destroy / .stop.
- Every dockview-side createUserShell call site now passes the active
  task_id, so the backend creates a DB-backed ordinary terminal (with
  seq + displayName) instead of a legacy passthrough shell.
- destroyUserShell + stopUserShell already accepted an optional task_id;
  dockview-layout-setup.onDidRemovePanel now reads the stamped task id
  from panel params and forwards it.

Backend:
- wsUserShellStop now requires both terminalSvc and a non-empty
  req.TaskID before routing through the managed service. An empty
  task_id falls through to the legacy passthrough StopUserShell so older
  clients (and dockview panels persisted before task_id was stamped)
  keep working.

Known follow-ups (separate task): the #N badge, rename context menu,
and parked-terminals dropdown still only show on the mobile/tablet
right-panel strip. Desktop dockview shows the seq inside the panel
title text but doesn't expose the badge/rename/parked-list UI yet.

* feat(terminal): dockview tab badges + rename context menu + existing-terminals list

Manual-test feedback round 2:

- Custom dockview tab component (TerminalTab) renders the #N pill
  only when the active env has more than one ordinary terminal —
  mirrors the session-tab badge semantics. Right-click on any ordinary
  terminal tab now exposes Rename / Park / Destroy.
- Dockview + menu picks up a new "Terminals" section listing every
  ordinary terminal for the active task (open and parked). Clicking a
  row focuses the existing dockview panel if one is mounted, otherwise
  re-opens it; parked rows are auto-resumed via user_shell.resume
  before the panel is added. The base "Terminal" action was renamed
  to "New Terminal" so the existing-terminals list is distinguishable
  in the menu.
- Reopen menu uses a stable EMPTY_SHELLS sentinel for its store
  selector to avoid Zustand infinite re-renders when the env has no
  shells.

E2E:
- terminal-new-tab.spec uses getByRole("menuitem", { name: "New
  Terminal", exact: true }) to disambiguate from the new "Terminals"
  submenu label.
- Full terminal suite still passes (26/26).

* fix(terminal): tab title shows "Terminal" with seq as badge, populate store on session page

Re-reading the user feedback: the title text was rendering as
"Terminal 1" (backend's displayName), and the dockview "+" menu's
Terminals section was empty because nothing on desktop was calling
useUserShells to populate the store.

- TerminalTab.pickDisplayName now returns the literal "Terminal" for
  ordinary terminals (custom names still override). The seq lives only
  in the sibling badge — same split as session-tab where the agent
  name is the title and the seq is a pill before it. Reading "Terminal
  1" out of "[1] Terminal" instead of "[1] Terminal 1".
- LeftHeaderActions (dockview "+") now calls useUserShells on mount.
  Without this the desktop layout never triggered the WS list fetch,
  so the Terminals submenu and the badge "count > 1" check both saw
  an empty store and silently disappeared.
- TerminalReopenMenuItems also calls useUserShells defensively (cheap
  thanks to scope-keyed cache) so it works when mounted in isolation.

* feat(terminal): "New X" rows live inside their section labels

User feedback: move "New Agent" under the Agents section header and
"New Terminal" under the Terminals section header so the dockview "+"
menu reads top-to-bottom as Agents → New Agent → existing sessions →
Terminals → New Terminal → existing terminals → other panels.

- SessionReopenMenuItems accepts an optional onNewSession prop and
  renders the "New Agent" row as the first item under the "Agents"
  label. Render gate now keeps the section visible whenever the create
  handler is supplied, even with zero existing sessions.
- TerminalReopenMenuItems gets a matching onNewTerminal prop with
  the same behaviour ("New Terminal" row under the "Terminals" label).
- dockview-add-panel-items drops its standalone "New Agent" and "New
  Terminal" rows since both now live inside their sections.

* feat(terminal): auto-migrate default panel to DB-backed ordinary terminal

The legacy `terminal-default` dockview panel was hard-wired to a
non-managed `shell-default` PTY so it never picked up an ordinary
seq/customName/parked state — the new "Terminal N" badge UX could
only show a badge once the user had explicitly created a second
terminal. With useEnsureDefaultTerminalOrdinary the default panel is
upgraded into a real `shell-<uuid>` row on session-page mount, so
every visible tab is now a first-class ordinary terminal.

Plus a UI-level e2e spec asserting the multi-terminal title + badge
contract, and an expectation bump on the existing rename+park spec
since visiting the session page now mints 1 extra ordinary terminal.

* fix(terminal): park ordinary terminals on tab close so they survive refresh

Dockview's tab X close button routed straight to stopUserShell (an
alias for destroyUserShell), wiping the DB row on close — after a
refresh the user had no way to resume a still-running PTY they had
just tucked away.

The X is now a "hide for now" affordance: ordinary terminals get
parked (PTY survives, row stays in DB, reappears in the "+" →
Terminals menu with a `parked` pill), while scripts / bottom-panel /
legacy passthrough shells stay on the destroy path because they have
no resume story.

Permanent delete still lives on the right-click Destroy menu item
and (in the next commit) on each reopen-menu row.

* fix(terminal): preserve DB-backed terminal panel ids across layout restore

The serializer's normalizePanel rewrote every user-created terminal
panel id to `terminal-saved-N` and dropped environmentId/taskID from
params, so after a reload that panel couldn't be matched back to its
live PTY or its store record — the tab rendered with no badge and a
stale fallback title.

Treat `shell-<uuid>` panel ids the same way we already treat
`terminal-default`: preserve id and params on restore. Legacy
ephemeral `terminal-saved-*` ids still mint a fresh PTY on restore.
Also force tabComponent=terminalTab and title="Terminal" for any
restored terminal panel so layouts persisted before the badge UX
land on the new presentation.

* feat(terminal): destroy button on reopen-menu rows for permanent delete

Destroy used to live only on the tab's right-click menu — useless once
the tab was already closed. Each ordinary terminal row in the dockview
"+" → Terminals submenu now ends with an × button that confirms-then
calls destroyUserShell, killing the PTY and dropping the DB row so the
terminal does not reappear after reload.

Pairs with the previous close-to-park change: closing the tab parks
(soft, resumable), the × button on the menu row destroys (hard,
permanent).

* fix(terminal): reopen-menu focuses existing tab, drops parked pill, says Terminate

Three small fixes to the dockview "+" → Terminals submenu:

1. Clicking a row whose terminal is already mounted now FOCUSES the
   existing panel instead of duplicating it. The default-migrated
   panel keeps its registry id `terminal-default` even after the
   migration to a `shell-<uuid>` PTY, so a direct getPanel(uuid) miss
   would fall through to addTerminalPanel and create a second tab for
   the same PTY. Fall back to scanning `params.terminalId` so the
   lookup catches that case.

2. Drop the "parked" pill from the row — parking is now an internal
   detail (close = park) that the user doesn't need to read off the
   menu. Clicking the row brings the terminal back into view either
   way, so distinguishing it visually is noise.

3. Rename the row × tooltip + confirm copy from "Destroy" / "Permanently
   delete" to "Terminate" to match the new tab-context-menu label.

Spec: add a test that asserts no duplicate tab when reopening an
already-open terminal, and drop the parked-pill assertion from the
existing close+reload test.

* feat(terminal): empty-group watermark uses the rich + menu

When a user splits a group (split-right / split-down) the new half is
empty and dockview renders the watermark. Before this, the watermark
showed a hard-coded Agent/Plan/Terminal/Browser row and clicking
"Terminal" called createUserShell without a taskId — so it spawned a
legacy passthrough shell that never appeared in the user-shell list,
and clicking "Agent" only ever focused the singleton chat panel
(invisible if the task had multiple sessions).

The watermark now renders the same AddPanelMenuItems content the
header "+" uses, so the user gets:
- The Agents list (existing sessions, greyed when already open)
- The Terminals list (existing terminals, greyed when already open)
- The Browser/Plan/VS Code/Changes/Files/PR options

…served behind a single "Add panel" dropdown trigger centered on the
empty group.

* feat(terminal): inline rename on the tab; drop Park, rename Destroy → Terminate

The tab title is now editable in place — picking Rename from the
right-click context menu swaps the title for an input. Enter commits,
Escape / blur-with-no-change cancels, and the new name is pushed
through renameUserShell + the user-shell store so the API and badge
list stay in sync. Replaces the previous window.prompt which felt
out of place in the dockview chrome.

While here:
- Drop the "Park (hide, keep PTY)" context-menu item. Tab close
  already parks ordinary terminals, so the explicit Park action was
  redundant and broke the "one obvious affordance per outcome" rule.
- Rename "Destroy" → "Terminate" in the context menu. Matches the
  reopen-menu × tooltip and reads more like a system action than a
  destructive UI primitive.

Spec adds an inline-rename test asserting the input appears, accepts
typed text, and commits the new title to the tab.

* fix(terminal): right-click Terminate closes the tab; drop displayName DOM warning

Two unrelated fixes both landing in terminal-tab.tsx:

- Terminate used to fire the WS destroy and update the store, but left
  the dockview panel mounted with a dead PTY underneath. Now it
  optimistically drops the shell row from the store, closes the panel
  via api.close(), then sends the destroy — same effect as the X close
  but routed to the hard-destroy path (the onDidRemovePanel cleanup
  sees no shell entry and skips its park branch).

- TerminalTabBody typed `displayName` as a prop but never destructured
  it, so {...props} spread it into DockviewDefaultTab and on into a DOM
  div, triggering "React does not recognize the `displayName` prop on
  a DOM element". Drop it from the spread.

* feat(terminal): polish + menu — badge-style indices, flush-left New rows, terminate closes panel

Polish pass on the dockview "+" → Agents / Terminals submenus:

- Replace the plain `#N` text with the same pill-style badge the tabs
  use (small bg, rounded, 11px). Agents and terminals both pick this
  up — so the menu row reads "[N] label" matching the tab style.
- Drop the empty index-column placeholder span on the "New X" rows so
  the icon + label sit flush-left under the section header instead of
  awkwardly indented.
- Strip the backend "Terminal {seq}" suffix from row labels — the
  badge already carries the number, so showing "Terminal 1" next to
  the "1" pill duplicated it. Custom-renamed terminals still show
  their name.
- The row × button now also closes the dockview panel if the terminal
  is currently open, mirroring the right-click → Terminate flow.

Spec: switch the row-by-seq lookups from `hasText: "#N"` (now stale
since the `#` prefix is gone) to a `has:` filter against the new
`reopen-terminal-seq-N` badge testid, and add a regression test for
right-click → Terminate closing the tab and removing the row in one
gesture.

* fix: address terminal review feedback

* fix: resolve terminal review suggestions

* fix: catch terminal close stop failures

* fix: harden terminal terminate failures

* fix: use pinned pnpm docker arg

* fix: satisfy backend lint after main merge

* fix: stabilize e2e after layout merge
2026-05-24 12:59:30 +01:00
Carlos Florêncio 372779ad32 feat(preview): inspect mode with pin and area annotations (#917) 2026-05-24 11:21:50 +01:00
Corentin AZAÏS 6729789d58 feat(agent): add Oh My Pi ACP agent (#971) 2026-05-22 22:48:38 +01:00
João Salavisa f98395f000 fix(agentctl): start per-repo workspace trackers in passthrough mode (#1002)
* fix(agentctl): start per-repo workspace trackers in passthrough mode

CLI passthrough mode starts the agent CLI via the PTY-based
InteractiveRunner instead of agentctl's startAgent / startOneShot, so the
per-repo workspace trackers created for multi-repo task roots were never
started. The bare root tracker exits immediately because it has no git
index, leaving polling completely idle — file edits made by the agent
never produced workspace-stream notifications and the Files panel stayed
stale. Move the tracker startup to instance.Manager.CreateInstance via a
new StartAllWorkspaceTrackers helper so polling fires for every repo
regardless of whether the agent runs via ACP or passthrough.

Fixes #982

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(lifecycle): serialize workspace poll-mode pushes to prevent slow-stuck mode

When a session subscribes and then immediately focuses (the normal page-load
sequence), the workspace poll aggregator used to spawn two independent
goroutines — one pushing slow, one pushing fast — whose HTTP arrival order
at agentctl was undefined. With the slow request landing after the fast
request the tracker ended up in slow mode (~30s polling) while the
aggregator's lastPushed claimed fast, and the suppress-duplicate guard then
blocked any future focus event from reconciling the divergence. Multi-repo
passthrough sessions hit this every page load: the Files panel saw
~30s-stale state even with the per-repo trackers running.

Replace the fire-and-forget goroutine per push with a single in-flight
pusher per workspace plus a last-write-wins pending slot — concurrent
callers all converge on the same pusher, which drains the queue in order
so the final queued mode is what reaches the wire last.

Regression test in manager_subscription_test.go simulates the race with an
httptest server that adds artificial latency on slow pushes; the test
fails on the old parallel-goroutine implementation and passes on the
serialized one.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(agentctl): address PR review feedback for passthrough tracker fix

- Goroutine leak: extract stopWorkspaceTrackers() and call it
  unconditionally at the top of Manager.Stop(). Previously the early
  return when status==Stopped skipped tracker teardown in passthrough
  mode (where Start() never runs), leaking 2 goroutines per per-repo
  tracker per stopped instance. stopShellAndProcesses delegates to the
  same helper.
- Trim multi-line doc comments to one-liners per CLAUDE.md (the
  StartAllWorkspaceTrackers docstring and the CreateInstance call-site
  comment).
- Replace the fixed time.Sleep in the race regression test with a poll
  on len(calls), exiting as soon as the pusher has drained, plus a
  brief settle window after to keep the test sensitive to the late
  racing slow push the fix guards against.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): refresh expanded subtrees on polling-detected file changes

The agentctl workspace tracker emits a generic `refresh` event with empty
path when its poll cycle detects file changes (it knows something changed
but not which file). The Files panel previously only refreshed the root
folder on these events, so files added under an expanded subdir never
became visible — the merge preserved the stale subtree, and the user had
to manually collapse + re-expand to see new files.

This is the user-visible symptom of #982 (Files panel does not refresh
after agent makes changes in CLI passthrough mode): the backend fixes in
this PR get the events flowing, but the frontend needs to act on them
correctly. On a `refresh` event we now refresh every currently-expanded
folder in addition to the root; for multi-repo task roots we scope to
the affected repo's branch using `repository_name`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address iteration-2 review feedback

- Trim doc + inline comment blocks per CLAUDE.md (manager.go,
  file-browser-hooks.ts, workspace-files.ts).
- Replace the hand-rolled fmtSscanf/numErr port parser with strconv.Atoi
  in the race regression test.
- Assert on slowCalls in the race test to lock in the queue-collapse
  guarantee (at most one slow push lands).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address iteration-3 review feedback

- Remove redundant stopWorkspaceTrackers call from stopShellAndProcesses
  (it's already invoked at the top of Manager.Stop), and drop the now-
  misleading "stopping workspace tracker" debug log from the regular
  shutdown path.
- Trim three multi-line comment blocks in manager_subscription.go to
  one-liners per CLAUDE.md (workspacePushTarget, pushAsync, pushLoop).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: trim multi-line comment block on pendingPush field

* fix: trim test docstring on race regression test

* fix(web): preserve sibling-repo subtrees on scoped refresh

The setTree reducer ran patchNode(updated) at the root, which re-matched
folderUpdates.has("") and overwrote the already-merged root children with
the raw depth-1 server response — wiping the loaded children of every
top-level repo that wasn't part of the refresh scope.

Recurse over updated.children instead of updated itself: the root has
already been merged in the block above, and each top-level repo either
gets its targeted update (if folderUpdates.has(path)) or is left alone
(preserving its loaded subtree).

Includes a regression test that fails on the old code: a refresh scoped
to "thm" used to discard "kandev"'s loaded README.md child.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): trim three more comment blocks to one-liners

* fix: trim five more comment blocks to one-liners

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 01:41:56 +01:00
João Salavisa 76143f8159 fix(acp): restore ScheduleWakeup wire string broken by Wakeup→Run rename (#998)
PR #914 globally renamed Wakeup→Run in the ACP transport, including the
wire-level tool name check (`name != "ScheduleRun"`). That string is
hardcoded by the Claude Code SDK binary as `ScheduleWakeup`, so the
bridge's tool-call frames stopped matching and every Claude wakeup
silently never fired: kandev never armed the timer, never sent the
synthetic session/prompt, and the SDK's buffered wakeup turn only
drained when the user typed a new message.

Commit 0b2c59a2's lifecycle fix (publish AgentReady on wakeup turns)
relied on the wakeup turn actually firing — which it wasn't — so the
fix appeared not to work in production.

Restore the wire string to `"ScheduleWakeup"` and rename the surrounding
Go symbols back to wakeup-naming (`wakeupScheduler`, `pendingWakeup`,
`handleWakeupEvent`, `fireWakeup`, `wakeupPromptTimeout`) so the next
contributor can't repeat the rename. Add a comment at the match site
warning that the string is externally hardcoded, plus a negative
regression test asserting `"ScheduleRun"` is NOT recognised as a wakeup.

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-21 21:29:30 +01:00
João Salavisa 0b2c59a2dd fix(lifecycle): publish AgentReady on wakeup-driven turns (#875)
* fix(lifecycle): publish AgentReady on wakeup-driven turns

Wakeup turns flow Adapter.Prompt → bridge → events, bypassing
SessionManager.SendPrompt. The execution status stayed Ready across
the wakeup turn, so MarkReady's duplicate-suppression guard dropped
the second AgentReady — the orchestrator never closed the turn,
never evaluated on_turn_complete, and never dispatched queued
messages. Detect this in recordActivity and flip Ready → Running on
the first non-terminal event, re-emitting AgentRunning so the rest
of the pipeline behaves like any other turn.

* fix(lifecycle): gate wakeup flip on turn-content events and handle empty turns

Three follow-ups on the wakeup AgentReady fix:

1. Restrict the Ready → Running flip in `recordActivity` to events that
   unambiguously indicate turn-in-progress activity (message_chunk,
   reasoning, tool_call/update, plan, agent_plan, permission_request).
   The original whole-event-type flip caused a regression: claude-agent-acp
   emits available_commands_update asynchronously ~50ms after session/new,
   well after dispatchInitialPrompt fires MarkBootReady for a no-prompt
   task. The Ready → Running flip on that delayed metadata event was
   putting newly-created tasks back into Running and breaking the chat-
   input UI (changes-panel-focus E2E tests).

2. Handle the empty-wakeup edge case: a wakeup turn that emits only a
   `complete` event (empty model response) is still excluded from the
   flip whitelist, so the Ready guard in MarkReady would suppress
   AgentReady. `handleCompleteEventMarkState` now publishes AgentReady
   directly when Status is already Ready on a successful complete —
   the orchestrator still closes the turn for empty wakeup responses.

3. Fold the `executionStore != nil` check into the early-return so the
   AgentRunning publish only fires when the status flip actually succeeded.

Test-file cleanup from review feedback: dead loop, redundant
`var _ = io.MultiReader`, and `teeReader.src any` → `io.Reader`.

New regression tests:
- TestWakeup_PostBootMetadataEventsDoNotFlipToRunning — locks in the
  whitelist behavior so future events can't accidentally re-arm a
  boot-ready session.
- TestWakeup_EmptyTurnStillPublishesAgentReady — covers the
  empty-wakeup-complete fallback.

* fix(lifecycle): publish AgentRunning before AgentReady on empty wakeup turns

Follow-up on the previous wakeup fixes — addresses three issues raised in
review:

1. **Empty-wakeup AgentReady was ignored by the orchestrator.** The fallback
   in `handleCompleteEventMarkState` published AgentReady when Status was
   already Ready, but `handleAgentReady` at the orchestrator early-returns
   when `session.State != Running/Starting`. After the previous turn ended
   the session is in WaitingForInput, so the AgentReady got silently
   dropped — same suppression the wakeup fix was meant to prevent. Now the
   fallback flips Ready → Running + publishes AgentRunning first (mirroring
   `recordActivity` for the non-empty case), then falls through to MarkReady.

2. **firstActivityOnce could still publish AgentRunning on a late
   metadata event.** Gate it on `Status != Ready` so a delayed
   `available_commands_update` arriving after MarkBootReady (the original
   regression scenario) can't slip through via the once-per-execution path.
   In practice the adapter always emits `agent_capabilities` during
   Initialize before MarkBootReady, so firstActivityOnce still fires there
   with Status==Running — this is defensive hardening.

3. **Tighter test assertions.** The post-boot-metadata test now primes
   firstActivityOnce in setup and asserts strict `runningCount == 0`.
   The second-turn test verifies AgentRunning precedes each AgentReady in
   bus order so the orchestrator's session-state guard isn't violated.
   Same ordering check added to the empty-wakeup test.

* test(lifecycle): rename exec to agentExec to avoid os/exec shadow

The test variable `exec` in wakeup_e2e_test.go shadowed the imported
`os/exec` package — harmless today because all `exec.Command`/`LookPath`
calls happen before the local binding, but a future call added below
would silently fail. Rename to `agentExec` to remove the shadow.

* ci: retrigger CodeQL after transient Ruby analyze flake

The default-setup CodeQL run on PR #875 produced a flaky 'CodeQL could
not process any code written in Ruby' failure on the Ruby analyze job
(github.com/kdlbs/kandev/actions/runs/26124163769). The repo has no
Ruby code and main's CodeQL only analyzes javascript-typescript +
actions; the Ruby job was added dynamically and failed on the empty
diff. Sibling run 26124163815 on the same SHA passed. Cannot rerun
default-setup workflows via the API (403 'cannot be retried'), so
push an empty commit to retrigger the analysis.

* test(lifecycle): tighten wakeup test assertions

- wakeup_simulated_test.go: assert exact readyCount==2 instead of <2 so
  any over-emission (e.g. duplicate AgentReady on the wakeup turn) is
  also caught.
- wakeup_e2e_test.go: fail with t.Fatalf when the adapter surfaces no
  WAKEUP text instead of logging a warning. Otherwise the bus-level
  assertions below pass vacuously if the wakeup never fires.

Both fixes address review comments from cubic-dev-ai / claude[bot] on
PR #875.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(lifecycle): remove unsynchronised SessionID write in forwarder goroutine

The forwarding goroutine wrote to agentExec.SessionID without holding
the executionStore mutex, racing with the main goroutine's authoritative
assignment after NewSession returns. The forwarder's write is redundant
— line 264 sets the canonical value — and benign in practice, but
`go test -race` would flag it. Drop the write to keep the race detector
clean.

Addresses review comment from claude[bot] on PR #875.

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>
2026-05-21 01:37:03 +01:00
José Almeida 7115ee08d5 fix(acp): stop inventing currentModelId from AvailableModels[0] (#947)
* fix(acp): stop inventing currentModelId from AvailableModels[0]

When session/new returned an empty currentModelId, the adapter fell back
to AvailableModels[0]. Auggie returns an alphabetically-sorted list whose
[0] is a pseudo-agent ("Build Analyzer"), so the UI showed the wrong
model on every fresh load and session resume.

Drop that fallback (the configOption-based fallback stays — that one is
legitimate). When neither CurrentModelId nor a model-shaped configOption
provides a value, emit empty and let the frontend resolve to the profile
or snapshot model.

Also emit a session_models convergence event after SetModel succeeds,
so the UI updates after the lifecycle manager applies the profile model
at session init and after user-driven /session/set_model calls. Cache
configOptions alongside availableModels for inclusion in that event.

* fix(acp): tighten SetModel convergence event semantics

Address PR #947 review feedback:

- Bind the convergence event to the session captured under the same
  RLock used to read the connection. `SetModel` now snapshots
  `sessionID`, `available`, and `cachedConfig` together and passes
  them into `emitSetModelEvent`, so a concurrent session switch can
  no longer route the event to the wrong target.

- Rewrite the model-shaped configOption's `CurrentValue` to the new
  `modelID` before emitting. Without this the event carried
  `CurrentModelID = <new>` alongside `ConfigOptions[model].CurrentValue
  = <old>` — a latent inconsistency for codex-style consumers that
  read the option value. The cached slice is copied first so the
  caller's snapshot is not mutated.

- Refresh `availableConfigOptions` under `Lock` from inbound
  `ConfigOptionUpdate` notifications, so a subsequent `SetModel`
  convergence event uses the latest options instead of the
  session/new snapshot.

- Log the convergence event with `session_id` and `model_id`, matching
  the logging already on `emitSessionModels`.

- Extract a `configOptionIDModel` constant for the well-known "model"
  ID (silences goconst on the new helper).

Tests: extend `TestEmitSetModelEvent_EmitsSessionModelsWithCachedState`
to assert the `CurrentValue` rewrite and the caller-slice
no-mutation guarantee; add `TestConfigOptionUpdate_RefreshesCachedConfig`
to pin the cache refresh.

* test(acp): tighten ConfigOptionUpdate cache-refresh assertions

Address two minor follow-up suggestions from Claude on PR #947:

- Add a comment to emitSetModelEvent noting that the cachedConfig
  copy is shallow (Options shares the caller's backing array) — safe
  today because only CurrentValue is mutated, but worth flagging in
  case a future caller mutates Options in place.

- Have TestConfigOptionUpdate_RefreshesCachedConfig assert that the
  returned event's CurrentModelID resolves to the fresh value from
  the refreshed configOption, so the test pins both the cache update
  and the event payload in one place.

* test(acp): cover Category-based model option rewrite path

Address Claude's follow-up suggestion on PR #947: the previous test
only exercised the ID == "model" branch of emitSetModelEvent's
CurrentValue rewrite, so an accidental drop of the
Category == "model" branch would have passed. Add a third entry
with Category: "model" (and a non-matching ID) and assert both
matched options are rewritten while the unmatched one is untouched.
2026-05-19 14:18:51 +01:00
Carlos Florêncio f6bd83da33 refactor: tighten type system across backend + frontend (#920) 2026-05-17 20:16:15 +01:00
Carlos Florêncio e346572371 chore(deps): migrate ACP SDK from agentcooper fork to upstream v0.13.0 (#921) 2026-05-17 19:51:24 +01:00
Carlos Florêncio 0b2085688b feat(office): autonomous agent management layer (#914) 2026-05-16 20:07:57 +01:00
Carmelo Campos aea16e166d fix: ignore pi version banner in utility inference output (#915) 2026-05-16 14:27:48 +01:00
Carlos Florêncio a5108de926 fix(windows): make conpty Close idempotent and kill orphan agentctl (#900) 2026-05-15 16:04:35 +01:00
José Almeida f3397b4ad7 feat: docker and sprites executor improvements (#738)
* feat(orchestrator): generalize task environment reuse across executor types

Worktree, container, and sandbox IDs now flow uniformly from a persisted
TaskEnvironment back into LaunchAgentRequest through a single helper. This
gives Sprites session-to-session reuse the same first-class treatment that
Docker already gained from the GitHub-token-injection PR, and lets a fallback
launch refresh the environment row instead of leaving stale IDs in the DB.

- applyExistingEnvironment propagates WorktreeID, ContainerID (via metadata),
  and SandboxID (as sprite_name metadata) onto the launch request
- persistTaskEnvironment refreshes those IDs when the executor ends up
  creating new resources (e.g. the previous container was pruned)
- extractSandboxID lifts the sprite identifier out of executor response
  metadata so it can be persisted on the TaskEnvironment row

* feat(backend): add task-level reset environment action

Users sometimes need a clean slate — broken packages, stale caches, a
container that drifted into a bad state. Reset Environment is the explicit
escape hatch: tear down the recorded container, sandbox, and/or worktree
for a task and delete the TaskEnvironment row so the next session starts
fresh.

- service.ResetTaskEnvironment(taskID, ResetOptions) is the entry point.
  ResetOptions.PushBranch optionally git-pushes the workspace branch
  before teardown so committed work isn't dropped on the floor; a failed
  push aborts the reset and leaves the environment intact.
- A new EnvironmentDestroyer interface in the task service hides the
  per-resource teardown behind a single seam, implemented in cmd/kandev
  by delegating to lifecycle.Manager (containers, sprites) and
  worktree.Manager (worktrees, branch preserved).
- lifecycle.Manager.DestroyContainer / DestroySandbox dispatch to the
  Docker and Sprites executor backends without leaking model types into
  the lifecycle package.
- Reset is blocked while any session on the task still has an
  ExecutorRunning row (returns ErrSessionRunning → HTTP 409). Per-resource
  teardown is best-effort but the DB row is only removed once everything
  is confirmed gone or already missing, so a partial failure is retryable
  by clicking Reset again.
- Endpoint: POST /api/v1/tasks/:id/environment/reset.

* feat(web): add executor settings popover with reset environment action

A new button on the task top bar opens a popover showing the active
TaskEnvironment — executor type, status, container/sprite/worktree
identifiers, and a copy-ready 'docker exec' command for local Docker.
The popover hosts the destructive Reset Environment action so users have
one obvious place to inspect and manage the environment without
cluttering the New Session dialog.

The reset confirmation dialog requires explicit acknowledgement that
uncommitted changes will be lost. When the environment has a host-side
worktree it offers an opt-in 'push branch before resetting' checkbox so
committed work can be pushed first; the backend aborts the reset on
push failure.

- task-environment-api.ts: fetchTaskEnvironment + resetTaskEnvironment
- executor-settings-button.tsx: popover trigger + identity panel
- task-reset-env-confirm-dialog.tsx: destructive confirm with push opt-in
- task-top-bar.tsx: wire the new button into the right-side controls
- e2e: replace fresh-env-toggle test with reset-from-popover flow

* fix(docker): expand {workspace} placeholder in container WorkingDir

The agent runtime config carries `WorkingDir: "{workspace}"` for most ACP
agents. Mount sources were already expanded via expandMountSource, but
WorkingDir was passed through verbatim, so Docker rejected new containers
with:

  Error response from daemon: the working directory '{workspace}' is
  invalid, it needs to be an absolute path

Run the same expansion on WorkingDir, defaulting to /workspace when no
host WorkspacePath is set (the clone-inside-container case introduced by
PR #654).

* fix(docker-profile): persist Dockerfile/image_tag and apply tag override at runtime

Two bugs:

1. The Dockerfile editor and Image Tag input on the executor profile page
   stored their values in component-local useState inside DockerSections,
   not in the form state. Saving the profile never wrote them back, so
   "Use defaults" / typing in the editor / changing the tag never
   persisted — and the Build button was the only thing that consumed
   them. The same gap existed in the new-executor flow's
   buildProfileConfig.

   Lift `dockerfile` and `image_tag` into useProfileFormState (edit) and
   useCreateProfileFormState (new), and emit them in buildSaveConfig /
   buildProfileConfig so they round-trip through profile.config.

2. Even when an image_tag is set on the profile, the container builder
   ignored it. imageName was always derived from the agent runtime's
   hardcoded `kandev/multi-agent`, so a user who built a custom image
   like `kandev/agent:latest` would still see Docker fail with
   "No such image: kandev/multi-agent:latest".

   Propagate `profile.config.image_tag` → metadata
   (`image_tag_override`) → ExecutorCreateRequest → ContainerConfig,
   and let it override `rt.Image` in buildContainerConfig when set.

Also align the frontend default image tag (kandev/multi-agent:latest)
with the backend's hardcoded runtime default so users who don't override
end up with a working build.

* test(docker): cover container config build + Docker profile persistence

Adds two regression tests for the recent Docker fixes:

- container_config_test.go (Go unit): asserts buildContainerConfig
  expands `{workspace}` in WorkingDir (defaulting to /workspace when no
  host WorkspacePath is set) and that ContainerConfig.ImageTagOverride
  takes precedence over the agent runtime's hardcoded `rt.Image`. No
  Docker daemon required.

- docker-profile-persistence.spec.ts (Playwright E2E): creates a
  local_docker executor + profile, opens the profile edit page, clicks
  "Use defaults" to populate the Image Tag input + Dockerfile editor,
  hits Save, then refetches the profile via API and reloads the page to
  verify both fields round-trip through `profile.config`. Catches the
  prior regression where DockerSections owned the form state locally
  and never wrote it back. Runs against the existing E2E backend that
  has KANDEV_DOCKER_ENABLED=false — we only test the persistence path,
  not container creation.

Adds an api-client helper (`getExecutorProfile`) for the round-trip
assertion.

* feat(docker): self-contained Dockerfile + mount agentctl + capabilities adapter

Make the Docker executor work with a Dockerfile users can actually build,
without depending on a maintainer-shipped image:

- The container now launches a small bootstrap (`sh -c "$KANDEV_PREPARE_SCRIPT
  ; exec /usr/local/bin/agentctl"`) regardless of the image's ENTRYPOINT.
  The agent CLI is no longer wired through container Cmd; agentctl receives
  it later via its CreateInstance HTTP API.
- Host agentctl-linux binary is mounted into the container at
  /usr/local/bin/agentctl via the existing AgentctlResolver. User-built
  images don't have to bake it in.
- Default Dockerfile in the executor profile UI replaced with a working
  node:22-slim + git + curl image — runs npx-based ACP agents (Claude,
  Codex, OpenCode, Auggie) out of the box.
- lifecycleAdapter now exposes RequiresCloneURL / ShouldApplyPreferredShell
  so the orchestrator can correctly identify containerized executors and
  resolve the clone URL. Without this, applyRepositoryConfig short-circuits
  on `e.capabilities == nil`, the cloneURL is never resolved, and the
  prepare script fails with `repository '{{repository.clone_url}}' does
  not exist`.
- applyRepositoryConfig also surfaces the resolved clone URL to script
  metadata so the prepare script can interpolate {{repository.clone_url}}.
- docker.ContainerConfig gains an Entrypoint field, plumbed to the docker
  client.

Verified end-to-end on a local stack with the new minimal image: the
container starts, bootstrap clones the GitHub repo, agentctl launches and
hands off to claude-acp inside the container.

* fix(docker): patient health check + resilient bootstrap

Two follow-ups to the Docker bootstrap work:

- The 30 retries × 500ms = 15s budget for agentctl health was too tight.
  In the previous flow agentctl was a separate exec from the prepare
  script, so it came up immediately. Now it runs after the prepare
  script (which does git clone, optional installs), so first launch can
  easily exceed 15s and produce a misleading "agentctl not healthy after
  30 retries" error. Bumped to 240 retries (120s) and surfaced the last
  underlying error in the message so failures point to the real cause.

- Prepare scripts commonly start with `set -e`/`set -eu`; running them
  via `eval` propagated the option to the outer bootstrap shell, so any
  failure killed the bootstrap before `exec /usr/local/bin/agentctl`.
  Run prepare in a subshell so its `set -e` is contained, and start
  agentctl regardless. If prepare fails, agentctl still comes up — the
  host can connect, surface the failure, and let the user debug from
  the Executor Settings popover.

* fix(executor-profile): persist remote auth secrets/credentials for Docker profiles

The Remote Credentials card (per-agent secret picker, plus the GitHub
credential checkbox) is rendered for any remote executor — Sprites,
local_docker, or remote_docker — but the save path in both the create
and edit pages only emitted `remote_auth_secrets` and `remote_credentials`
when `isSprites` was true. Selecting a secret for the Claude / Codex /
Auggie ANTHROPIC_API_KEY-style entry on a Docker profile updated form
state, the dropdown showed it as picked, but Save Changes silently
dropped the value before posting to the API. On reload the agent showed
as "Not configured", and any session start failed with
`{"code":-32000,"message":"Authentication required"}`.

Switch the guards to `isRemote` so the same data persists for all remote
profile types. The backend orchestrator already propagates
`profile.config.remote_auth_secrets` and `remote_credentials` through to
metadata for any containerized executor and resolves them via
`applyContainerCredentials` → `resolveAuthSecrets`, so no backend change
is needed once the values actually land in the saved profile.

* fix(executor-profile): live Configured badge + session/load resilience + live container status

Three independent fixes that all surface together while iterating on the
Docker executor flow:

- Remote Credentials card: the per-agent "Configured" / "Not Configured"
  badge was driven by useState that only seeded once on mount. Picking a
  secret in the dropdown updated the parent's `agentEnvVars` map but the
  local `choice` never moved to `"env"`, so the badge stayed grey until
  the page was reloaded. Derive `choice` straight from props every render
  so the badge tracks live state.

- ACP session/load: when the agent process restarted between turns
  (common with claude-agent-acp because its session map is in-memory),
  the host's session/load call failed with JSON-RPC -32002 "Resource not
  found" and the entire launch errored out. Treat that as the same kind
  of "session is gone" signal as -32601 / capability=false and fall back
  to session/new. Worst case the agent loses prior context; best case
  the user just keeps typing.

- Executor Settings popover: dropped Execution ID and Environment ID
  rows (debug-only noise users don't need), kept Container ID + the
  copyable `docker exec -it … bash` line, added a live status badge
  with tones for running / exited / paused / dead / error / missing,
  and a 3-second polling interval so the badge follows the real
  container state. New `GET /api/v1/tasks/:id/environment/live`
  endpoint plumbs Docker `inspect` data through the lifecycle manager
  → task service → handler. ContainerLiveStatus mirror type at the
  service layer keeps lifecycle types out of the public surface.

* feat(web): gate agent profiles by executor profile credentials

Filter the agent profile dropdown in both the Create Task dialog and the
New Session dialog to only show agents that are actually configured for
the selected executor profile.

For local/worktree executors, no gating (agents need no extra creds).
For Docker/Sprites profiles, an agent is "configured" iff one of its
remote-auth methods has a value in `remote_credentials` or a non-null
secret in `remote_auth_secrets`. Agents without an entry in the remote
auth catalog (Copilot, Amp, OpenCode, TUI, mock) cannot carry creds on
remote executors and are blocked.

Spec IDs match `AgentProfileOption.agent_name` (the registry-type string
like "claude-acp"); `agent_id` is a DB UUID and unrelated to the catalog.

The Create Task footer surfaces a "No agent profile is configured for X"
tooltip and disables submit when the selected executor profile has no
compatible agents. The New Session dialog auto-swaps to the first
compatible agent and shows the same banner inline.

`useRemoteAuthSpecs` returns a `loaded` flag so callers don't filter
during the initial fetch (would otherwise hide everything).

* feat(docker): make mock-agent runnable inside Docker for E2E

Lay the backend groundwork so the existing mock-agent can run inside
local_docker containers — needed for real-Docker E2E tests that don't
require Anthropic/OpenAI API keys.

Container builder bind-mounts a host mock-agent binary at
/usr/local/bin/mock-agent when KANDEV_MOCK_AGENT_LINUX_BINARY is set
(or build/mock-agent-linux-amd64 sits next to the kandev binary), the
same pattern already used for agentctl. MockAgent.BuildCommand always
resolves via PATH so the same command works on host (E2E PATH includes
bin/) and in containers (mounted at /usr/local/bin/mock-agent).

Mock now declares non-nil empty RemoteAuth so it counts as a
remote-capable agent. The remoteauth catalog includes specs even with
zero methods, and the frontend gating helper treats an empty-method
spec as "no credentials needed" — letting mock-agent pass the
configure-on-executor check that hides uncatalogued agents on remote
executors.

repositoryCloneURL falls back to the local origin remote URL when the
repo has no provider info, so local-only repos with a real remote
(including file:// remotes used in E2E) work on remote executors that
clone inside the container.

Makefile: build-mock-agent-linux target mirrors build-agentctl-linux.

* test(docker-e2e): add real-Docker Playwright project + first spec

A new Playwright project — \`docker\` — runs container-bound E2E specs
against a backend with KANDEV_E2E_DOCKER=1. The fixture probes for a
Docker daemon and skips the worker entirely when none is reachable, so
contributors without Docker can still run the rest of the suite.

The fixture builds a tiny kandev-agent:e2e image once per machine
(node:22-slim + git/curl), pre-seeds a local_docker executor profile,
and seeds a local repo with a file:// origin so the executor's
clone-inside-container path resolves without network access. A small
WS-based session.launch helper is added to the api-client for tests
that need to launch additional sessions on an existing task.

First spec — docker-launch.spec.ts — covers the smoke path: a session
launches in a real container, the task environment records its
container_id, and \`docker inspect\` confirms the container exists.
Two follow-up specs (multi-session env reuse + recovery from external
container removal) are wired up but marked test.fixme with comments
pinpointing the backend gaps that block them: persistTaskEnvironment
isn't called for workflow-prepared sessions (so task_environment_id
stays empty on those rows), and DockerExecutor.reconnectToContainer
loses the agentctl handshake auth token across launches (so reconnect
401s and falls back to a fresh container every time).

Run locally with: \`pnpm --filter @kandev/web exec playwright test
--config e2e/playwright.config.ts --project=docker\`.

* ci(e2e): add dedicated Docker E2E job

The new \`docker\` Playwright project needs a Docker daemon and the
linux/amd64 agentctl + mock-agent binaries bind-mounted into containers.
Pin the existing sharded job to chromium + mobile-chrome so the docker
project no longer leaks into every shard (where it would fail seeding
without KANDEV_DOCKER_ENABLED), and add an \`e2e-docker\` job that:

- Verifies the Docker daemon is reachable on the runner.
- Downloads the same backend artifact (now including the linux helper
  binaries, built via \`make build-backend-linux-helpers\`).
- Runs \`playwright test --project=docker\` with KANDEV_E2E_DOCKER=1.
- Cleans up any kandev-managed containers at teardown so leaks don't
  poison the next run.

* ci(e2e): include docker job in merged HTML report

Upload the docker job's blob report under the \`blob-report-*\` glob the
merge job already consumes, and add e2e-docker to e2e-report's needs so
the final HTML report covers both the sharded chromium runs and the
docker run.

* ci(e2e): add workflow_dispatch for ad-hoc runs and draft-PR verification

* fix(build): always produce linux/amd64 agentctl as part of \`make build\`

Remote executors (local Docker, remote Docker, Sprites) bind-mount the
linux/amd64 agentctl binary into the container. On non-Linux hosts (e.g.
macOS dev installs) running plain \`make build\` produced only the host
binary, so the first Docker session would fail with:

  failed to build container config: agentctl linux binary not found

The top-level \`make dev\` already special-cased non-Linux hosts to chain
\`build-agentctl-linux\`, but \`make build\` and \`make start\` skipped it.
Add it as a dependency of the standard backend build so every install
ships a usable linux variant. Cross-compiling agentctl is fast (~1-2s)
and the binary is ~20 MB, so the cost is negligible.

Users who hit the error can run \`make build-backend\` (or \`make build\`
in apps/backend) to regenerate; setting KANDEV_AGENTCTL_LINUX_BINARY
remains a manual override.

* fix(release): ship linux/amd64 agentctl in every release artifact

Remote executors (local Docker, remote Docker, Sprites) bind-mount the
linux/amd64 agentctl into the container. The release pipeline only
shipped a host-arch agentctl, so users who installed via the bundled
binaries (npm/Homebrew/etc.) hit the same "agentctl linux binary not
found" error the first time they tried a Docker executor — even on
Linux, since the resolver expects the -linux-amd64 suffix.

Changes:

- .github/workflows/release.yml: cross-compile agentctl-linux-amd64 in
  the build-bundles matrix step (CGO off so it works from every host).
  Bundle ships kandev + host agentctl + agentctl-linux-amd64 in
  dist/kandev/bin/. The AgentctlResolver finds it next to the kandev
  binary automatically.
- Dockerfile: same idea for the container image — produce both variants
  in the Go builder stage and copy the linux-amd64 helper into
  /app/apps/backend/bin/ so the resolver picks it up inside the image.

A previous commit added the same dependency to \`make build\` for local
dev installs; this commit closes the gap for end users.

* fix: address PR review feedback (CI, CodeRabbit, Greptile, Claude)

Backend:
- service_task_environments: fail closed when checkAnySessionRunning
  errors so a transient DB hiccup can't bypass the running-session
  guard and tear down a live container/sandbox; teardown is now truly
  best-effort with errors.Join across container/sandbox/worktree, so a
  failed Docker call doesn't permanently orphan the worktree on retry.
  Adds tests for the running-check error path and the multi-resource
  best-effort path.
- executor_execute: propagate GetTaskEnvironmentByTaskID errors instead
  of silently launching a fresh environment that orphans existing
  containers/worktrees; gate environment reuse on matching ExecutorType
  so a profile switch doesn't pass stale PreviousExecutionID/container
  IDs to the wrong backend.
- task_environment_handlers: reject malformed JSON on POST
  /tasks/:id/environment/reset (still allow empty body) so a destructive
  endpoint doesn't silently proceed with a bad payload.
- worktree (PushEnvironmentBranch): bound git push with a 30s context
  timeout and disable interactive prompts via GIT_TERMINAL_PROMPT=0
  so a stalled remote can't hang the request thread.
- manager_environment: explicitly call ensureClient() before
  ContainerMgr() in DestroyContainer/GetContainerLiveStatus so a missing
  Docker daemon surfaces as "docker unavailable" rather than the
  generic "container manager not initialized".
- container: WorkingDir always uses the in-container /workspace path
  rather than the host workspace path (Docker would happily start in an
  unrelated directory in host bind-mount mode); bootstrap now logs the
  prepare-script exit code so failures show up in container logs.
- docker/client: CreateContainerInteractive honors ContainerConfig.Entrypoint
  so the same config struct produces consistent behavior on both creation
  paths.

Frontend:
- executor-settings-button: extract useTaskEnvironment hook (component
  shrinks below the 100-line limit); only treat 404 as empty env (other
  errors no longer wipe a valid environment); track "loaded once" via a
  ref so loadEnv doesn't depend on env state — that dep was creating a
  new function identity on every successful poll, cancelling the
  3-second interval and turning the popover into a tight loop hammering
  /environment/live; copy-shell command uses sh instead of bash so it
  works on user-built minimal images.
- task-reset-env-confirm-dialog: AlertDialogAction onClick calls
  e.preventDefault() so Radix's auto-close doesn't dismiss the dialog
  before the async reset completes (loading spinner now visible; close
  is parent-controlled via onOpenChange).
- remote-credentials-card: track env method in selectedIds the same way
  files/gh_cli_token are tracked, and let initialChoice match either
  selectedIds OR envSecretId. First-time env-credential setup for an
  agent that exposes both files+env was broken: clicking the env radio
  never updated state, choice re-derived to "none", and the secret
  picker disappeared before the user could pick anything.
- e2e/executor-reuse: select the acknowledge checkbox by label, not by
  positional last(), so adding another checkbox to the dialog can't
  silently target the wrong control.

Format pass on prettier-flagged files; gofmt fixed two whitespace
nits in lifecycle files.

* test(executor): cover applyExistingEnvironment executor-type mismatch guard

* fix(docker): connect agentctl through published ports

* fix(web): keep remote auth selection stable

* fix(docker): reuse task containers across sessions

* fix(docker): resume task environment after refresh

* fix(docker): track executor containers for cleanup

* fix(docker): explain missing agent credentials

* fix(docker): surface container launch progress

* fix(docker): stabilize container reuse and feedback

* fix(docker): show task titles in container lists

* fix(docker): use container icon for docker tasks

* fix(orchestrator): align environment reuse after rebase

* fix(sprites): reconnect sessions on existing sandboxes

* fix(docker): use container icon for local docker executor too

Local docker tasks were rendering the Docker brand "chip" mark while
remote docker uses the container glyph. Map both variants to
IconContainer so the executor surfaces look consistent.

* feat(sprites): provision a fresh sandbox when the persisted one is gone

Resuming a Sprites task whose sandbox was destroyed upstream returned
"sprite not found" and dead-ended the user. Detect the upstream 404 via
a typed ErrSpriteNotFound sentinel, mark the reconnect step skipped with
a user-facing warning explaining what happened, swap the prepare plan
in place to the fresh-provision shape, and continue the launch under a
new sandbox name on the same branch.

The progress plan is now mutable so the reporter can stream the fresh
steps live after the reconnect-then-fresh transition. Only the explicit
not-found case triggers fallback; other reconnect failures still
surface as before, and the fallback fires at most once per launch.

* feat(web): friendlier header for sandbox-recovered prepare runs

When the only warning on a completed prepare is the missing-sandbox
fallback notice, render "Environment prepared on a fresh sandbox" in
the header instead of the generic "Environment prepared with warnings".
The fallback row still carries its own amber warning callout so the
user understands why a fresh sandbox was provisioned.

* feat(web): unify executor surfaces in the right-side popover

The desktop task top bar carried a small cloud icon (RemoteCloudTooltip)
in the breadcrumb cluster while the right-side Executor popover showed
the same kind of info — two surfaces for one concept. Remove the cloud
icon from the desktop top bar; kanban cards, task list rows, and the
mobile top bar keep theirs since they have no popover.

The popover button now picks its icon by executor type (cloud for
sprites, container for docker, server for local PC) using a shared
getExecutorStatusIcon helper. While the prepare progress is preparing
the icon swaps to a spinner so the user can see at a glance that work
is in flight.

The popover content gains a Preparing-environment section that streams
"Step N of M: <step>", an amber inline note when the missing-sandbox
fallback is in progress, an Environment-ready row with duration on
success, and a failed-step summary on failure. Polling promotes to the
active 3s cadence while preparing so the icon flips to ready without
the user opening the popover.

* fix(docker): swap container glyph for a plain box icon

IconContainer's vertical ribbing reads as a microchip on small sizes.
IconBox is an unambiguous outlined cube. Use it for both docker
variants so the executor surfaces look like a box, not a chip.

* fix(orchestrator): preserve base branch on resume for clone-based executors

Sprites and Docker resumes were sending the launch request with an empty
BaseBranch, so the in-sandbox prepare script's `git clone --branch <X>`
shifted positional args and failed with "repository '/workspace' does
not exist". applyResumeRepoConfig only set BaseBranch inside the
worktree-specific block; widen that to all executors that require a
clone URL.

Local executors are intentionally left untouched: LocalPreparer reads
BaseBranch and forces a `git fetch && git checkout` against the user's
own workspace, which would clobber the "use current state" path.

* feat(sprites,docker): generate a kandev feature branch for clone-based executors

Sprites tasks committed straight onto the chosen base branch because
SpritesPreparer didn't populate WorktreeBranch and the orchestrator's
"forward env.WorktreeBranch on reuse" check only fired for Docker.

Mirror the Docker preparer's TaskBranchName generation in Sprites,
extract a shared `nonWorktreeTaskBranch` helper that uses a
deterministic suffix derived from the task ID (so legacy env rows that
predate this change resolve to the same branch on every resume), and
let the orchestrator forward env.WorktreeBranch for any clone-based
executor — Docker or Sprites.

* fix(sprites,docker): prefer existing remote feature branch on resume

When a sandbox is destroyed and we provision a fresh one for the same
task, the agent may have previously committed and pushed onto the
feature branch. The previous prepare scripts only ran `git checkout -b
{{worktree.branch}}` which would overwrite that progress with a fresh
branch off the chosen base.

Try `git fetch origin <feature>` first; on success, `git checkout -B`
against the remote tip so the agent picks up where it left off. Falls
back to a fresh local branch when origin has nothing.

Also add the same checkout block to the Sprites prepare script — it was
missing entirely, so every Sprites session was committing onto main.

* test(sprites): lock in informative branch name in fallback warning

Now that BaseBranch is preserved on resume for clone-based executors,
the missing-sandbox fallback warning detail names the actual branch
("Cloning … on the same branch main") instead of the "(unknown)"
placeholder. Add a regression test so a future change to the resume
metadata propagation can't silently regress that copy.

* fix(sprites): preserve sandbox on plain agent stop so resume is fast

StopInstance was calling sprite.Destroy() unconditionally, so every
session stop tore down the cloud sandbox. Resume then always fell into
the missing-sandbox fallback, re-cloned the repo, re-ran the prepare
script, and lost the agent's working tree. That's a 30s+ round-trip
for what should be "stop the process, then re-attach later".

Limit Destroy to terminal lifecycle reasons (task/session
deleted/archived), the same set runTerminalCleanupScript already uses.
Plain stops just close the proxy and leave the sandbox alive.

Also update the existing Docker e2e regex: the feature-branch suffix is
now a deterministic 6-hex-char prefix of the task UUID rather than a
random 3-char tail, so resume on legacy env rows lands on the same
branch.

* test(docker): cover stop + resume → same container, branch preserved

Adds a Docker e2e test that exercises the user-visible stop-then-resume
flow: launch session, stop it via WS session.stop, launch a fresh
session on the same task, and assert the same task environment +
container ID are reused and the kandev feature branch is preserved.

Also nudge the executor popover padding: when the prepare-status
section is rendered above the Sprites/Docker info block, the cumulative
top+bottom paddings looked too generous around "Sprites sandbox" /
"Local Docker". Tighten both sections vertically without affecting the
horizontal rhythm or the bottom action row.

* feat(web): show "Resuming session" feedback in popover during reconnect

The bottom Resume button on the chat panel was leaving users staring at
nothing for the 5-30s reconnect window: prepare-progress events are
intentionally suppressed on resume (the workspace is already there), so
the popover sat in the idle phase even while session.state was STARTING.

Synthesize a "resuming" phase from session state when no prepare events
are flowing, rendered as a spinner row in the popover with copy
"Resuming session — Reconnecting to the existing environment…". The
button icon also flips to the spinner so the toolbar advertises the
in-flight work without the user having to open the popover.

Extract the prepare-status reducer + section component into their own
file so the main popover module stays under the 600-line ESLint cap.

* fix(codex): align with the standard SessionDirTemplate pattern

Codex was the only agent that bind-mounted its host home into the
container via an explicit `Mounts: [{home}/.codex → /root/.codex]`
entry, while every other ACP agent uses `SessionConfig.SessionDirTemplate`
+ `SessionDirTarget`. Move codex onto the same surface so the
host-state isolation work in the next commit covers it uniformly.

This change alone does not stop the host-state leak — the bind mount
still resolves to the host's ~/.codex for now — but it removes the
last agent-specific branch in the runtime config so the docker
container manager can apply one rule for all agents.

* fix(docker): isolate per-container agent state from the host home

Every ACP agent's session dir was bind-mounted from ~/.<agent> on the
host into /root/.<agent> in the container. That exposed host state DBs,
session caches, and lock files to the container — codex's rollout DB
caches absolute /Users/... paths, which don't resolve inside Linux and
the agent silently loses prior thread context on resume.

Replace the host bind mount with a kandev-managed per-container session
dir under <kandev-home>/agent-sessions/<instance_id>/. Before the
container starts, selectively copy the auth-relevant files (the same
files Sprites already copies via RemoteAuth.SourceFiles) into that
dir; everything else — state DBs, sessions/, lock files — stays on the
host and the agent rebuilds them inside the container with
container-local Linux paths.

The change is agent-agnostic: any agent that has SessionDirTemplate
+ SessionDirTarget gets the new behavior automatically. Cleanup runs
on destructive stops only (task/session deleted/archived), mirroring
the Sprites preserve-on-stop rule so plain stops let resume re-attach
to the same agent state.

* fix(executor): make the kandev feature-branch checkout an invariant postlude

Profiles created in the UI snapshot the *then-current* DefaultPrepareScript
into their stored prepare_script field. Updates to the default — like
shipping the worktree-branch checkout block — never reach those older
profiles, so users keep landing the agent on the chosen base branch
(main) even though the env row carries a feature branch name.

Append a kandev-managed postlude to whatever prepare_script ran (custom
or default), wrapped in a subshell + `|| true` so it's idempotent and
never blocks agentctl from starting. The postlude prefers the remote tip
when origin already has the branch (resume after a destroyed sandbox),
falls back to checking out the existing local branch, and finally
creates one off the current HEAD.

Sprites needed the WorktreeProvider in resolvePrepareScript so the
postlude's `{{worktree.branch}}` actually expands; Docker already had
it.

The Docker e2e gains a regression case that explicitly stores an older
default-style script (clone, no checkout) and asserts the agent still
ends up on the feature branch — locking the contract in regardless of
which default the user happens to have stored.

* fix(web): tighten executor popover padding under the env header

The Sprites/Docker info block under the Environment-ready row had too
much vertical breathing room — the header-to-fields gap and the
inter-row spacing piled up and the popover felt airy. Trim the section
to match the snug rhythm of the rest of the popover (px-3 py-2,
1.5-unit gap before the field list, 1-unit gap between rows). Also
unify the Environment-ready row from p-3 to px-3 py-2 so the dividers
between sections sit at the same vertical cadence.

* fix(web): tighten executor popover bottom padding too

The previous tighten only fixed the top half — pb under the field rows
plus the Reset-row's full p-2 still left a generous gap below the last
"Sprite ..." line. Same fix applies whether the popover is showing
Docker or Sprites.

EnvironmentInfo: pt-2 pb-1.5 (was py-2) so the bottom field hugs the
divider, and the Reset-row goes from p-2 to px-2 py-1.5 so the button
sits closer to the text above.

* fix(web): drop the redundant Environment-ready row + tighten popover top

The READY badge next to the executor name already conveys ready-state;
the dedicated "Environment ready · 12s" row above the executor info
just added vertical noise. Skip rendering for the ready phase (other
phases that surface non-redundant info — preparing, resuming, failed —
still render). Drop top padding on the executor info section now that
nothing sits above it on the happy path.

* fix(web): move executor info to the left top bar, hover-to-open, no border

The executor section is informational, not a button — readiness state +
sandbox/container/branch fields. Move it to the left top bar (next to
the integrations menu) where it sits alongside the other contextual
info, drop the outline-button styling, and switch from click-to-open to
hover-to-open via HoverCard with 150ms open / 250ms close so the cursor
can travel to the destructive Reset button without flickering.

The trigger is a borderless icon-with-status-dot span; tabIndex keeps
it keyboard-accessible. Reset still works — it opens the existing
confirmation dialog independently of the hover card.

Tests: pointerEnter instead of click for the hover-to-open path.

* refactor(cleanup): drop dead code, dedupe predicates, unify token injection

Tier 1 of the post-review cleanup. Each item is independently safe; bundling
because they're all small, mechanical, and lint-passing.

- fix(clone): unify injectTokenIntoURL across Docker and Sprites — Sprites
  used to ignore GH_TOKEN entirely (a user with only GH_TOKEN set got 401
  on Sprites clone but worked on Docker). Promote one
  `injectGitHubTokenIntoCloneURL` helper, accept both env vars
  (GITHUB_TOKEN wins, matching Docker), and use the documented
  `https://x-access-token:T@github.com/` form on both surfaces.
- fix(executor-popover): swap the <span tabIndex role="button"> trigger
  for <Button variant="ghost"> so Enter/Space activation, focus rings on
  Safari, and disabled semantics come for free.
- test(docker): rewrite TestDockerStopInstance_PreservesSessionDirOnPlainStop
  to drive the real DockerExecutor.StopInstance path with ContainerID=""
  (skips the docker daemon, exercises the kandev-side cleanup branch).
  Previous version only iterated shouldRunExecutorCleanup strings — would
  pass even if StopInstance always cleaned up. Now table-driven over
  preserve+destructive reasons with on-disk asserts.
- refactor(orchestrator): drop dead `_ = arg` parameters from persist*
  helpers (persistResumeState, persistModelSwitchState, persistLaunchState).
  Brings signatures from 9→6 args, under the funlen lint cap.
- refactor(orchestrator): collapse byte-identical isContainerizedExecutor
  ≡ isCloneBasedExecutorType predicate into one.
- refactor(lifecycle): drop dead getMetadataBool, FileUploader.RunCommand,
  expandMountSource {home} arm (no production agent ships {home} in
  Mounts; codex_acp_test asserts it), dockerTaskBranch one-line wrapper.

* refactor(scripts): postlude owns worktree-branch checkout

Strip the inline `git checkout -B` block from the Docker and Sprites
default prepare scripts. The kandev-managed postlude
(KandevBranchCheckoutPostlude) is appended unconditionally after every
user prepare script, so keeping the same logic inline made the default
and the postlude two sources of truth that could drift.

* refactor(lifecycle): drop dead instance_port persistence entry

`instance_port` is only used as a zap log field — it's never written to
the executor metadata map, so carrying it across resume in
`persistentMetadataKeys` was a no-op.

* refactor(lifecycle): drop legacy {home} session-dir fallback

ExpandSessionDir kept a `{home}/<dotdir>` fallback for the case where
kandevHomeDir was unresolved. In production, ResolvedHomeDir() always
returns a non-empty path, so the fallback only ever fired in tests
that hadn't been updated to pass an instance ID. Returning the empty
string in that case makes container.go skip the bind mount — the
correct behaviour, since without a kandev home there's no session dir
to mount.

* refactor(orchestrator): collapse env-reuse helpers into reuseExistingEnvironment

The two env-reuse helpers (`applyExistingEnvironment` and
`applyExistingEnvironmentRuntimeMetadata`) were always called together
and shared the same intent: carry the previous TaskEnvironment forward
into the launch request. Merge them into a single
`reuseExistingEnvironment(ctx, req, env)` so the executor-type gate
runs once for both env-level fields and runtime metadata, and callers
have a single entry point.

Document the layered metadata rule (running wins for container_id;
env wins for sprite_name and other stable IDs) inline so the next
reader doesn't have to reverse-engineer it from two functions.

* refactor(web): lift prepare-summary reducer + isFallbackNoticeStep into lib/prepare

Move the popover's prepare-summary reducer (and the
`isFallbackNoticeStep` helper duplicated word-for-word in
`prepare-progress.tsx`) into `apps/web/lib/prepare/summarize.ts`. The
hook moves to `hooks/domains/session/use-prepare-summary.ts` to match
the project's domain-organized hook layout.

The popover's PrepareSummary type and the chat panel's EffectiveStatus
serve different UIs, so the panel keeps its own `deriveStatus` — only
the literal duplicate (`isFallbackNoticeStep`) is shared.

* refactor(web): split executor-settings-button by concern

Slice the 471-line button file into three modules with single
concerns: the popover shell stays in `executor-settings-button.tsx`
(168 lines, just trigger + popover layout + icon/status dot),
`executor-environment-info.tsx` owns the env detail rows + status
badge, and `hooks/domains/session/use-task-environment.ts` owns the
fetch/poll/reset lifecycle alongside other session hooks.

* refactor(e2e): extract docker + session helpers from docker-launch.spec

Pull the inline docker CLI helpers (`dockerInspectExists`, `dockerStop`,
`dockerCurrentBranch`, etc.) and the generic session-state pollers
(`waitForLatestSessionDone`, `waitForSessionDone`,
`waitForSessionEnvironment`) into `e2e/helpers/docker.ts` and
`e2e/helpers/session.ts`. The spec drops from 763 to 647 lines and the
helpers can be reused by future docker / session tests.

* test(scripts): execute postlude under bash -e against a real git repo

Pair the static-content assertion with a behaviour test that runs the
postlude as a real shell script. Three scenarios exercise the three
branches inside the snippet: remote tip exists (track origin), no
remote (fresh local branch), and already on the target branch
(idempotency). This catches regressions where the snippet still parses
as bash but no longer does the right thing — wrong refspec, missing
-B, swallowed exit code, etc.

* test(sprites): integration check for stop preserve + resume reconnect path

Each piece of the resume-fast path is unit-tested separately
(StopInstance keeps the token cache, spritesShouldReconnect reads
sprite_name, the reconnect plan has 3 steps). This new test wires them
together end to end so a future change that accidentally strips
sprite_name on stop or forgets to look at metadata on launch fails
loudly here, not at runtime.

* test(sprites): assert plan ordering instead of absolute indexes

Replace `plan.index(spriteStepWaitHealthy) == 1` with an
`assertPlanOrder` helper that checks "X runs before Y". The contract
the test guards is the order of create-sprite → wait-healthy →
agent-instance, not their absolute slot numbers — adding a new step
shouldn't break the test if the order is preserved.

* test(web): hoist test constants above vi.mock factories

Move SESSION_ID and friends above the vi.mock factories so the mock
closures don't depend on vitest hoisting magic capturing TDZ-undefined
locals. The previous layout worked because factories aren't invoked
until the module under test is imported (after const initialization),
but a refactor that imports earlier would break the closure silently.

* fix(seeder): contain localFileUploader writes inside the session root

CodeQL flagged go/path-injection on the localFileUploader.WriteFile
sink: instanceID flows into the write path, and a malformed value
would let writes escape the kandev session root and clobber arbitrary
host files. Bind the uploader to its session root and reject any path
that filepath.Rel resolves to a "../" escape. Production callers
always pass an internally generated instanceID; the check is defense
in depth against a future caller plumbing in user input.

* refactor(docker): split reconnectToContainer + cover reconnect helpers with unit tests

Address recurring review feedback on the docker reconnect path:

- reconnectToContainer was 98 lines (over the 80-line repo limit). Split
  into ensureContainerRunning (inspect + start + re-inspect) and
  bringupAgentctl (health-check, find-or-create instance, transparent
  re-handshake). The orchestration body now reads top-down at 49 lines.
- resolveDockerEndpoint and waitForAgentctlHealth grew real branches in
  this PR (host-port lookup with container-IP fallback; 240×500ms
  retry loop) but had no direct tests. Introduce narrow hostPortLookup
  / healthChecker interfaces, satisfied by *docker.Client and
  *agentctl.ControlClient, so both functions are now driven by stubs
  that exercise happy-path, fallback, retry-until-healthy, exhaustion,
  and context-cancellation branches.
- Add direct unit tests for the docker package's pure helpers
  (normalizeDockerHostIP, buildDockerPortBindings) and for
  dockerAgentctlPortBindings — a regression in the published port
  range or host IP would silently break container reconnect.

* test(handlers): cover ensureShellExecution stale-execution recovery

The recovery branch (Failed execution → CleanupStaleExecutionBySessionID
→ relaunch) is the load-bearing piece of shell session resilience and
had no direct test. Extract the body into a free function that takes a
narrow shellLifecycle interface so the four branches are testable
without standing up a real lifecycle.Manager:
  - happy path (running execution, no cleanup)
  - stale recovery (Failed → cleanup → fresh)
  - initial GetOrEnsureExecution failure
  - cleanup failure must NOT trigger a re-launch loop
  - relaunch failure surfaces a clear "recover execution" error

* fix(backend): clear lint blockers from CI lint job

CI's only-new-issues lint surfaced three real items left over from
recent additions:

- container.go bootstrap snippet had two `fi` shell tokens flagged by
  dupword. Tagged with the same nolint directive used by the
  KandevBranchCheckoutPostlude (two distinct shell blocks closing).
- DockerExecutor.CreateInstance was 89 lines (over funlen 80). Split
  the body into helpers: reportCreateInstanceProgress (deferred
  progress reporter), tryReconnect (gates the resume path),
  seedSessionDir (per-container auth seed), buildContainerLaunchConfig,
  and buildCreatedInstance. CreateInstance is now a 30-line top-down
  orchestrator.
- executor_execute.go was 887 effective lines (over revive 800). Move
  reuseExistingEnvironment + its helpers (latestExecutorRunningForEnv,
  executorRunningMatchesEnvironment, applyExecutorRunningMetadata,
  ensureLaunchMetadata) into a sibling executor_environment_reuse.go.

* fix(web): only block submit during real env prepare, not every STARTING

The "agent still being set up" submit gate was triggering on every
session that briefly transitions through STARTING — including local
quick-chat, which has no prepare phase and has its agent live within
~100ms. The eager-init quick-chat e2e tests (chat/quick-chat.spec.ts
and chat/message-queue.spec.ts) wait for the editor to become
editable, then press Cmd+Enter. The race made wrappedSubmit a no-op
exactly when the test was firing — message dropped, agent never saw
it, downstream assertions failed.

Split the merged isStarting flag in useSessionState: keep the broad
flag (still used for the workspace-prep loading UI), and expose
isPreparingEnvironment as a separate signal that's only true while
prepareProgress.status === "preparing". Thread it through chat-input-
area → chat-input-container → useChatInputContainer and gate the
submit-disabled tooltip on it instead of the broad isStarting.

Net: Docker / Sprites bootstrap still surfaces the "agent still being
set up" message and disables submit; quick-chat / local sessions stop
silently dropping in-flight messages.

* fix(web): keep editor disabled during STARTING so quick-chat sends don't race

My previous fix made submit no longer block on isStarting, with the
goal of keeping local quick-chat sessions from silently dropping
messages. The trace from CI showed it traded one failure for another:
the editor became contenteditable while the session was still
STARTING, the e2e suite pressed Cmd+Enter immediately, and the
backend rejected with "Failed to send message to agent" because the
agent process wasn't ready yet.

Restore the original semantics — STARTING blocks both editor and
submit — but keep `isPreparingEnvironment` as the narrower trigger for
the "agent still being set up" tooltip. Net behaviour:
  - STARTING transition: editor disabled, no tooltip (signal is the
    disabled state itself)
  - container/sandbox prepare: editor disabled + tooltip explaining why
  - quick-chat sessions: editor stays disabled until RUNNING, no race

* docs(web): correct isPreparingEnvironment JSDoc after STARTING re-block

The 9389ac9 fix restored isStarting as the editor disabled gate.
Update the JSDoc on isPreparingEnvironment to match: it now drives
the "agent still being set up" submit-disabled tooltip only — the
disabled state itself is gated on the broader isStarting so e2e
Cmd+Enter doesn't race the not-yet-ready agent.

* fix(lifecycle): make agentctl health retry delay cancelable

Replace `time.Sleep(retryDelay)` in `waitForHealth` (container.go) and
`waitForAgentctlHealthWith` (executor_docker.go) with a cancelable
`select { <-ctx.Done() | <-time.After(retryDelay) }`. Also skip the
final retry's sleep — the loop only re-enters when `i+1 < maxRetries`,
so the extra delay was just added latency on aborted launches.

Cancellation now propagates within ≤500 ms instead of waiting for the
next probe iteration to observe `ctx.Err()`.

* fix(scripts): non-destructive postlude branch checkout

The previous postlude tried `git fetch + git checkout -B feature
origin/feature` first, which is force-reset semantics: any unpushed
local commits on the resumed workspace would be orphaned without
warning.

Reorder to prefer the existing local branch (preserving in-flight
work after a sandbox/container resume), fall through to a fresh
tracking branch off origin, and only as a last resort create the
branch off HEAD:

  1. local feature branch exists  → checkout (preserves)
  2. else fetch + checkout -b      → tracks origin, no force-reset
  3. else                          → create new local branch

Behaviour tests still pass: fresh sandbox with remote tip → falls
through to step 2 (clean track). The static-content test now also
verifies the destructive `-B "feature" "origin/feature"` form is
absent.

* fix(lifecycle): reject non-regular files for KANDEV_MOCK_AGENT_LINUX_BINARY

`os.Stat` succeeds on a directory, so the previous resolver happily
accepted a directory path and forwarded it to the Docker bind-mount +
exec path, where it surfaced as a confusing "permission denied" /
"exec format error" deep in container bootstrap.

Validate `info.Mode().IsRegular()` up front and return a clear error
naming the env var.

* docs(test): update stale comment after retry-delay became cancelable

aaec9e87 swapped the retry sleep for a select on ctx.Done. The test
comment in TestWaitForAgentctlHealth_HonoursContextCancellation still
described the old "subsequent time.Sleep is short" reasoning, which is
no longer how cancellation propagates — the select exits immediately
via ctx.Done() within the same iteration.

* fix(web): extract buildDialog{FormBody,Footer}Props to satisfy lint cap

After the main merge, prettier expanded my hand-compacted destructuring
in TaskCreateDialog onto multiple lines, putting the function at 115
lines (over the 100 cap). Extract the two prop-assembly blocks into
buildDialogFormBodyProps + buildDialogFooterProps helper functions so
the orchestrator becomes a thin JSX wrapper. No behaviour change — the
helpers project the same shape, 1:1.

Also fixes the upstream CI failure: prettier --check was rejecting the
hand-compacted destructuring lines.

* test(executor): cover repositoryCloneURL LocalPath fallback

The function gained a second code path to support Docker E2E tests with
file:// remotes — when ProviderOwner/ProviderName are both empty and
LocalPath is set, it shells out to git remote get-url origin. The
original table-driven test had eight cases, none of which exercised
the new branch.

Add a dedicated TestRepositoryCloneURL_LocalPathFallback with four
sub-cases:
  - local-only repo with a valid origin remote → returns the URL
  - local-only repo without an origin remote → returns empty
  - local-only non-git directory → returns empty
  - no provider and no local path → returns empty (regression for the
    early-return branch the old table already covered)

* refactor(web): move task-create-dialog prop builders to sibling file

3a0c45a0 introduced buildDialogFormBodyProps + buildDialogFooterProps
to slim TaskCreateDialog under the function-line cap, but adding them
in-file pushed task-create-dialog.tsx to 612 raw lines. Local eslint
still passes (skipBlankLines + skipComments brings the effective count
under 600), but Claude's review flagged the raw-line growth as a
blocker — and the prop builders are pure projections from setup state
to plain JS objects, no JSX, so they belong outside the component
module anyway.

Move computeHasAllBranches and both builders to a new
task-create-dialog-prop-builders.ts. Export TaskCreateDialogProps and
useTaskCreateDialogSetup from dialog.tsx so the builders can derive
their parameter types via ReturnType<typeof useTaskCreateDialogSetup>.

task-create-dialog.tsx: 612 → 522 raw lines.

* chore(web): apply prettier to task-create-dialog-prop-builders.ts

The new file from 08aa04c5 wasn't run through prettier before push,
so CI's --check rejected the long import line. Mechanical reformat
(import collapsed to one line, no semantic change).

* fix(web): import-type the prop-builders circular dep + add computeHasAllBranches test

Two findings from Claude on 08aa04c5 + fa5ba9d6:

  - The mutual import between task-create-dialog-prop-builders.ts and
    task-create-dialog.tsx is purely type-level (TaskCreateDialogProps
    is an interface; useTaskCreateDialogSetup is consumed only through
    `ReturnType<typeof ...>`). Switch to `import type` so bundlers and
    analysis tools don't flag this as a runtime cycle.

  - computeHasAllBranches was a private function in
    task-create-dialog.tsx and didn't need its own test. Moving it to
    a sibling .ts utility makes it a public helper, and the SKILL.md
    convention is that public utilities carry their own tests. Add a
    table-driven unit test covering the three branches: noRepository
    short-circuit, useGitHubUrl branch validation, and the
    every-repo-has-a-branch case.

* fix(web): pass executorType to kanban card RemoteCloudTooltip

The kanban-card call site was missing the optional executorType prop,
so getExecutorStatusIcon(undefined, ...) fell through to the default
arm and rendered IconServer for every remote-executor task —
including Docker, which should be IconBox. Other call sites
(task-item.tsx, session-mobile-top-bar.tsx) already pass it.

This was a pre-existing bug from c64e8356 that became visible because
this PR makes Docker tasks first-class on the home view.

* fix(web): give executor popover env header breathing room above the title

The EnvironmentInfo block was `px-3 pb-1.5` (no top padding), so the
"Local Docker" heading hugged the popover top edge when no prepare-
status row was rendered. Add `pt-2.5` to match the visual rhythm of
the prepare-status row that sometimes sits above it.

* fix(sprites): DestroySandbox passes terminal StopReason so cleanup actually runs

shouldRunExecutorCleanup gates Sprites teardown on a small set of
terminal reasons (task/session archived/deleted). DestroySandbox was
calling StopInstance with an empty StopReason, which never matched
that set — so the sandbox was never actually destroyed, only the
local execution state was cleaned up. The sprite kept running
upstream until it idled out.

Pass StopReasonTaskDeleted explicitly. While here, lift the four
terminal-reason literals into named constants so callers don't have
to keep them in sync with shouldRunExecutorCleanup's switch.

* fix(web): block submit when selected agent is incompatible with executor

useExecutorProfileCompat returned noCompatibleAgent=true only when
NO agent profile was compatible with the chosen executor. The user
could still pick an agent that happened to be incompatible with the
currently-selected executor (e.g. by switching executor after the
agent was chosen) and the submit gate didn't fire.

Two fixes:
  - Hook now takes the selected agent profile ID and returns
    noCompatibleAgent=true when the *selected* agent isn't in the
    compatible set (covers both the old "no agents at all" case and
    the new "wrong agent picked" case).
  - Footer's session-mode disable was gating only on missing agent;
    add noCompatibleAgent so the disable matches the reason text
    sessionDefaultReason already surfaces.

* fix(task-env): sanitize generic 500 response on reset failure

The reset-environment endpoint was returning the raw err.Error() in the
500 response body, leaking internal details (paths, wrapped chains,
infra error strings). Keep the structured log at error level for
operators and return a generic "failed to reset task environment"
to clients.

* fix(worktree): push to the branch's configured upstream remote, not hard-coded origin

PushEnvironmentBranch was running `git push origin <branch>`, which
breaks for repos whose primary remote isn't called "origin" (fork
workflows that use "upstream", "github", etc.). Read the branch's
configured upstream remote via `git config branch.<branch>.remote`
and fall back to "origin" only when no upstream is set.

* refactor(web): type workflows/snapshots in DialogFormBodyProps properly

DialogFormBodyProps had `workflows: unknown[]` and `snapshots: unknown`
which dropped type safety and forced unsafe casts at the call site.
Replace with the actual store-derived types (WorkflowsState["items"]
and KanbanMultiState["snapshots"]) that the call sites already
produce.

* fix(turns): align info.TaskEnvironmentID with the resolved env after fallback

GetWorkspaceInfoForSession initializes info.TaskEnvironmentID from
session.TaskEnvironmentID, then resolves the env — first by that ID,
falling back to GetTaskEnvironmentByTaskID when the session's ID
points to a stale/missing row. When the fallback kicks in, the
resolved env has a different ID, but applyTaskEnvironmentToWorkspaceInfo
was only updating info.TaskEnvironmentID when it was empty. So
downstream reconcilers received metadata + path from one env keyed by
a different env's ID.

Always overwrite info.TaskEnvironmentID with env.ID inside the apply
function — once we've picked an env to spread, the info should
identify that env consistently.

* fix(web): gate compatibility on effectiveAgentProfileId, not the raw form value

The previous fix passed fs.agentProfileId to useExecutorProfileCompat,
so workflow-locked sessions (where fs.agentProfileId is empty and the
real value comes from workflowAgentProfileId) bypassed the
noCompatibleAgent check entirely — selectedAgentProfileId was "" so
the new "selected agent isn't in compatible set" branch silently
returned false.

Thread `effectiveAgentProfileId` (which already coalesces the form
value with the workflow override) through to the compat hook so
locked agent selections are gated the same way as user-picked ones.

* test: cover detectBranchRemote + applyTaskEnvironmentToWorkspaceInfo stale-ID fallback

Two test additions for the Round 29/30 Claude blockers — both are
recent backend fixes (a07b8f1f, e6195f3f) that landed without
regression coverage.

  - worktree_test.go: TestDetectBranchRemote covers the three
    branches of the new helper — configured upstream returned,
    no upstream → "origin" fallback, non-git dir → "origin"
    fallback. GIT_CONFIG_NOSYSTEM=1 keeps host config from
    interfering.
  - service_turns_test.go: a regression test that drives
    GetWorkspaceInfoForSession down the fallback path
    (session.TaskEnvironmentID points at a missing row; real env
    exists under a different ID for the same task) and asserts
    info.TaskEnvironmentID is aligned with the resolved env, plus
    WorkspacePath/container_id come from the same env.

* fix(backend): extract defaultGitRemote constant to satisfy goconst

The new detectBranchRemote helper plus its tests pushed "origin"
literal occurrences past goconst's min-occurrences=3 threshold.

* fix(backend): inject git identity in worktree tests for CI

CI runners have no user.email/user.name configured, so
`git commit --allow-empty` aborts with "Author identity unknown".
GIT_AUTHOR_*/GIT_COMMITTER_* env vars override the config check.

* fix(backend): base mustGit env on os.Environ to keep HOME/PATH

Appending to a nil cmd.Env produced a single-element slice that
replaced the inherited environment, leaving the git subprocess
without HOME, PATH, etc. Base on os.Environ() instead.
2026-05-14 21:26:09 +01:00
Carlos Florêncio 08f782af22 fix(agentctl): drop -l from task shells so PATH keeps agent CLI bin dir (#889) 2026-05-13 18:39:39 +01:00
Carlos Florêncio b1cd2d2076 feat(github): rate-limit awareness, poller throttling, and GraphQL batching (#821) 2026-05-06 16:51:06 +01:00
João Salavisa 9fe2cbd874 fix: source commit pushed status from git remote, not PR commits (#833)
* fix: source commit pushed status from git remote, not PR commits

Commits showed as "not pushed" whenever the branch had no open PR,
since the UI derived pushed status by matching local SHAs against
the PR's commit list. Sourcing it from the upstream tracking ref in
agentctl's GetLog keeps the answer correct without a PR, after
rebases, and across multi-repo workspaces.

* fix: treat PR-matched commits as pushed alongside the git remote signal

mergeCommits now flags a commit as pushed when EITHER the backend's
git-sourced flag is true OR a PR commit matches by SHA. The git signal
fixes the original "no PR = unpushed" bug; the PR signal preserves the
"PR exists ⇒ commit is on remote" assumption that the E2E mocks rely
on (and that holds in practice, since GitHub PRs require pushed
branches). Also corrects the validation-path comment in
markPushedCommits to describe the actual non-branch fall-through.

* perf: bound rev-list walk in markPushedCommits to result length

Without -n the walk was unbounded — a branch with many local-only
commits would traverse full unpushed history per GetLog call. Capping
at len(commits) keeps the two walks proportional. rev-list walks
newest-first like GetLog, so the cap covers every commit we mark.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-05 22:09:02 +01:00
João Salavisa 7e0b46c227 feat(slack): cookie-mode integration that triages threads via a utility agent (#775)
* feat(slack): add cookie-mode integration that triages messages via utility agent

Adds an install-wide Slack integration that polls the connected user's
own messages for `!kandev <instruction>`, hands the thread context to a
configured utility agent with the Kandev MCP server attached, and
creates the resulting task. Auth uses the user's xoxc- token + d cookie
so it works on locked-down Enterprise Slack tenants where bot installs
aren't possible.

Shared infra:
- ACP host-utility executor accepts McpServers on session/new
- New MCP tool list_repositories_kandev so the triage agent can pick the
  right repo
- Template engine exposes Slack-specific variables for prompt injection
- secretadapter + healthpoll reused from jira/linear

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(slack): address PR review feedback round 9

- Drop dead `var _ = time.Second` no-op and its misleading comment in
  trigger_test.go.
- `RecordAuthHealth` now invalidates the cached client when the probe
  fails, so the poller self-recovers from credential rotation without
  requiring a re-save.
- Stable secondary sort key (`workspace_id DESC`) on the legacy →
  singleton migration so promoted-row selection is deterministic on
  `updated_at` ties.
- Extract `categorySlack` constant in `engine.go` and update the
  `VariableInfo.Category` doc-comment to list the full set of values.
- Realign `slack-integration/spec.md` with what shipped: cookie-mode +
  `!kandev` triage as v1, with bot install / User OAuth / reaction &
  slash & shortcut triggers moved under Future scope.

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>
2026-05-05 09:03:42 +01:00
João Salavisa 5c5e7bf6a7 fix(changes-panel): scope commits/diff to live branch divergence; flatten single-repo (#816)
* fix(changes-panel): scope commits/diff to live branch divergence; flatten single-repo

Two bugs in the COMMITS panel made it surface commits and file changes
that were already on main:

1. The commit list and the cumulative diff both anchored to a stale base.
   Commit list used `git merge-base HEAD <local-branch>` and the file diff
   used the session's stored base SHA captured once at session start. On
   long-lived worktrees the local main ref lags far behind origin/main, so
   the merge-base lands on an old SHA; once the agent merges main back in,
   the stored base is also stale. Both end up sweeping in dozens of main
   commits as if they were the branch's work.

2. `git log <base>..HEAD` follows both parents of a merge commit by
   default, surfacing main commits brought in via merges from main.

Fixes:
- agentctl computes merge-base against `origin/<target_branch>` (with a
  local-ref fallback for unfetched/local-only branches), and uses that
  for both the commit log and the cumulative diff. Mirrors GitHub's
  three-dot PR diff semantics — anchor to live divergence with the
  upstream, not a session-start snapshot.
- `git log` now passes `--first-parent` so only the branch's own line of
  work plus its merge commits show up; commits brought in via merges from
  main are excluded.

Frontend: drop the per-repo "REPOSITORY" sub-header in the COMMITS panel
for single-repo workspaces. CommitsRepoGroup gains a `showHeader` prop;
when the workspace has one untagged group the section renders flat with
the per-repo Push / PR (and Stage all / Commit / Unstage all) buttons
lifted into the section header. Multi-repo rendering unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR review feedback

- Scope --first-parent to the divergence-range path only (Greptile P1).
  The flag is wrong for open-ended history calls — future activity
  widgets etc. need the full graph.
- Log empty merge-base case in cumulative diff (Greptile P2). Silent
  fallback to stored base was invisible during diagnostics.
- Rename TestRunGitCumulativeDiffForRepo_PrefersOriginRef →
  TestComputeMergeBase_CorrectAnchorForCumulativeDiff (Claude). Test
  exercises computeMergeBase, not the runGitCumulativeDiffForRepo
  conditional; new name reflects what it actually proves.
- Add TestGetLog_NoBaseReturnsFullGraph to lock in the --first-parent
  scoping.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address PR review round 2

- Drop dead ActionButtonsSection from changes-panel-timeline.tsx (unused
  export, no consumers anywhere). Brings the file from 640 → 516 lines,
  comfortably under the 600-line TS limit Claude flagged.
- Extract isSingleRepoGroup helper into lib/group-by-repo.ts and use it
  in all three sites (CommitsSection, FileListBody, FileListSection) so
  single-repo detection has one source of truth.

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>
2026-05-04 17:28:04 +01:00
Carlos Florêncio b01e6abc06 fix: retry CLI passthrough launch without resume flag after fast-fail (#810) 2026-05-04 08:45:20 +01:00