Files
kandev/apps/backend/internal/agentctl/server/process/manager.go
T
João Salavisa a510b46e4e feat(multi-repo): support tasks spanning multiple repositories (#767)
* feat(task): add task_environment_repos schema and model for multi-repo support

Phase 0 of multi-repository task support: introduces the per-repository
child table and model alongside legacy single-repo fields (dual-write,
no behavior change). Lays the foundation for the orchestrator/lifecycle
refactor in subsequent phases.

- New task_environment_repos table with FK CASCADE on task_environments.
- New task_dir_name column on task_environments (idempotent ALTER).
- TaskEnvironmentRepo model + Repos slice + RepoFor helper on TaskEnvironment.
- CRUD methods + interface extension; env getters preload Repos.
- Idempotent backfill from legacy single-repo task_environments rows.
- Tests cover round-trip, CRUD, preload ordering, embedded create, cascade
  delete, and backfill from a legacy-shaped row.

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

* feat(lifecycle): add Repositories list to LaunchRequest and EnvPrepareRequest

Phase 1.1 of multi-repository task support: extends the lifecycle types
with explicit per-repo lists, while keeping the legacy single-repo
top-level fields as the synthesized fallback. No caller behavior changes
yet — preparers still see length-1 specs for single-repo launches.

- LaunchRequest.Repositories []RepoLaunchSpec + RepoSpecs() helper.
- EnvPrepareRequest.Repositories []RepoPrepareSpec + RepoSpecs() helper.
- EnvPrepareResult.Worktrees []RepoWorktreeResult for per-repo outcomes.
- runEnvironmentPreparer forwards req.Repositories into the prepare
  request, inheriting the request-level RepoSetupScript when each
  per-repo entry has none.
- Tests cover explicit-list precedence, synthesized fallback, and
  repo-less requests.

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

* feat(worktree,lifecycle): support multiple worktrees per session

Phases 1.2 and 1.3 of multi-repository task support. The pieces are
intertwined so they ship together — the preparer's per-repo loop
depends on the manager being able to hold one worktree per
(session, repository) pair instead of one per session.

Worktree manager:
- In-memory cache key changes from sessionID to (sessionID|repositoryID).
- New MultiRepoStore optional capability + SQLite implementation:
  GetWorktreesBySessionID and GetWorktreeBySessionAndRepository.
- New Manager methods: GetAllBySessionID, GetBySessionAndRepo. Existing
  GetBySessionID returns the first match (single-repo callers unchanged).
- Manager.Create's "already exists" check is now repo-scoped so the
  second repo's create no longer returns the first repo's worktree.
- All cache write/delete sites updated to the composite key.

WorktreePreparer:
- New prepareMultiRepo path activated when EnvPrepareRequest.Repositories
  has >=2 entries; single-repo and repo-less behavior is byte-identical.
- prepareOneRepo runs validate -> sync -> create -> fetch-PR -> per-repo
  setup-script for each spec; rolls back already-created worktrees on
  any failure.
- Workspace path becomes the task root (parent of any repo subdir);
  legacy WorktreeID/WorktreeBranch/MainRepoGitDir mirror Worktrees[0]
  for downstream code that hasn't been migrated yet.
- Per-repo setup script runs verbatim (no script-engine resolution
  yet — the per-repo placeholder namespace lands in a follow-up).

Tests:
- Manager: GetAllBySessionID, GetBySessionAndRepo (hit + miss),
  GetBySessionID single-repo compat, cache key separation.
- Preparer: multi-repo creates one worktree per repo; rollback removes
  active worktrees on partial failure; per-repo setup script runs in
  each worktree directory and emits its own step.

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

* feat(orchestrator): resolve all task repos and persist per-repo rows on launch

Phase 1.4 of multi-repository task support: the orchestrator now reads
every repository linked to a task, forwards the full list through the
adapter into the lifecycle layer, and persists one TaskEnvironmentRepo
plus one TaskSessionWorktree row per repo when the launch is multi-repo.
Single-repo and repo-less tasks keep their previous code path verbatim
(dual-write — legacy single-repo fields stay populated from the primary).

- New resolveAllRepoInfo / resolveTaskRepoInfo on the executor; primary
  is the first by Position.
- buildLaunchAgentRequest takes both the primary repoInfo and the full
  list, populating req.Repositories []RepoSpec only when length > 1.
- New RepoSpec / RepoWorktreeResult on the orchestrator's executor
  package mirror the lifecycle types so the adapter can translate
  between them without leaking lifecycle types into the orchestrator
  public API.
- Adapter (cmd/kandev/adapters.go) forwards req.Repositories into
  lifecycle.LaunchRequest and surfaces per-repo prepare results back
  on LaunchAgentResponse.Worktrees.
- persistTaskEnvironment writes env.Repos in the same create
  transaction; persistTaskEnvironmentRepos handles the existing-env
  reuse path. TaskEnvironment.TaskDirName is now persisted.
- persistWorktreeAssociation loops over resp.Worktrees in multi-repo,
  inserting one row per repo with its own RepositoryID.
- ResumeSession now also populates req.Repositories from task repos
  and stamps the existing TaskDirName so the preparer reuses the same
  task root.
- repoStore / executorStore interfaces extended with the new repo and
  env-repo CRUD methods; mocks updated accordingly.

Tests:
- LaunchPreparedSession with two task repositories populates
  req.Repositories in order and keeps legacy single-repo top-level
  fields filled from the primary.
- The same launch persists one TaskEnvironment + 2 TaskEnvironmentRepo
  + 2 TaskSessionWorktree rows.
- Single-repo launch still produces zero entries in req.Repositories
  and exercises only the legacy fields.

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

* feat(agentctl): git endpoints accept optional repo subpath for multi-repo

Phase 2 of multi-repository task support: every per-call git endpoint
gains an optional `repo` field (form param for GET, JSON for POST) that
selects a sub-directory of the workspace. Empty `repo` = workspace root,
preserving single-repo behavior verbatim.

- Manager.GitOperatorFor(subpath) returns a per-subpath GitOperator
  (cached). Validates the subpath against path traversal, absolute
  paths, and missing directories.
- New api server helper gitOpForRepo() emits a 400 with a per-operation
  GitOperationResult on invalid subpaths.
- Repo field added to: pull, push, rebase, merge, abort, commit,
  rename-branch, stage, unstage, discard, create-pr, revert-commit,
  show-commit, reset, log, cumulative-diff. (git/status still reads
  from the workspace tracker — that endpoint will gain multi-repo
  support when the tracker itself is split per repo.)
- Tests cover empty subpath, valid subpath caching, traversal
  rejection, and missing-directory rejection.

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

* feat(web/comments): scope diff comments by repository for multi-repo tasks

Phase 3 of multi-repository task support: comments now carry an
optional repositoryId so two repos with the same file path render
their own comment threads instead of bleeding together.

- CommentBase.repositoryId (optional, backwards compatible).
- getCommentsForFile gains an optional repositoryId arg; legacy
  comments without a repositoryId match any repo filter so persisted
  data keeps showing.
- Diff store interface in lib/diff/types.ts mirrors the new arg.
- Tests cover per-repo filter, omitted-arg fallback, and legacy data.

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

* feat(github): per-repo PR association on task PRs and watches

Phase 4 of multi-repository task support: github_task_prs and
github_pr_watches gain a repository_id column so multi-repo tasks can
have one PR per repo without colliding on (task_id) uniqueness.

- Idempotent ALTER TABLE adds repository_id to both tables.
- TaskPR.RepositoryID and PRWatch.RepositoryID model fields.
- New GetTaskPRByRepository and ListTaskPRsByTask lookups; existing
  GetTaskPR keeps single-PR semantics for callers not yet migrated.
- ReplaceTaskPR's delete is scoped by repository_id when set so a
  multi-repo task replacing one repo's PR no longer wipes the other.
- Tests cover per-repo storage and scoped replace.

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

* feat(web): foundations for multi-repo task UI

Phase 5 of multi-repository task support: primitives the upcoming UI
work needs without changing any visible behavior yet.

- TaskRepository.checkout_branch surfaces the optional PR head branch
  field that the backend already exposes.
- primaryTaskRepository(repos) picks by lowest Position so consumers
  match the backend's "primary" semantics instead of `[0]`.
- New useTaskRepositories(taskId) / useTaskPrimaryRepository(taskId)
  hooks read the live kanban task and return its repos sorted by
  position. Future kanban/diff/UI consumers should use these instead
  of poking task.repositories directly.
- Tests cover lowest-position selection and empty/single-repo edge cases.

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

* feat(web): kanban card shows multi-repo "+N" chip and hydrates full repo list

Phase 6 (subset) of multi-repository task support. The kanban now
exposes the per-task repository list to the UI and renders a compact
"+N" badge on cards whose tasks span more than one repository.

- KanbanState.tasks[].repositories slim shape (id, repository_id,
  base_branch, checkout_branch, position).
- SSR mapper hydrates the full list and uses primaryTaskRepository()
  to pick the position-aware primary instead of [0].
- useKanbanData passes repositories through to the card props.
- KanbanCard renders the primary repo name with a small "+N" chip
  when len(repositories) > 1; tooltip names the count.
- useTaskRepositories / useTaskPrimaryRepository hooks return the
  slim KanbanTaskRepository shape so multi-repo consumers share one
  read path.

Larger UI surfaces (task-create dialog, diff view, file browser,
mobile sheets) still target the primary repo only — they will be
ported as the multi-repo task creation flow lands. No visible change
for single-repo tasks.

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

* feat(task): reject duplicate repositories on multi-repo task create

Phase 8 of multi-repository task support: surface duplicate-repository
input as a clean validation error instead of letting the unique
(task_id, repository_id) constraint surface mid-loop with some rows
already inserted.

- createTaskRepositories tracks seen repository IDs and returns
  "repository <id> listed more than once for the task" before any
  CreateTaskRepository call.
- Tests cover the rejection path and confirm two distinct repos still
  succeed with both rows persisted in Position order.

Repository discovery and per-repo cloning were already covered by
Phase 1.4 (resolveAllRepoInfo loops ensureRepoCloned) and the
existing repository_discovery service is repo-list aware by design.

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

* feat(web/task-create): repeatable repository rows for multi-repo task creation

Users can now actually create a multi-repo task: a "+ Add repository"
button below the primary repo + branch selectors expands the form with
additional (repository, base-branch) rows. Each extra row picks from a
filtered repository list that excludes the primary and any other already-
chosen extras, so duplicates are impossible to enter.

- ExtraRepositoryRow type + form-state hook (extraRepositories, add /
  remove / update / reset). Lives in its own file so the main state hook
  stays under the file-length lint cap.
- ExtraRepositoryRows component renders the rows and the "+ Add" button;
  per-row branches are loaded on demand via the existing
  useRepositoryBranches hook.
- buildRepositoriesPayload accepts the extras and appends them after the
  primary, dropping rows with empty repository_id silently so an
  unfinished row does not block submit. The backend's duplicate-repo
  validation (Phase 8) catches anything that slips through.
- DialogFormBody is split: CreateModeSelectors (selectors + extra rows)
  and useDialogHandlers move to dedicated files, keeping touched files
  under the lint thresholds.
- Tests cover the multi-repo payload (ordering, drop-empty, no-primary
  recovery, single-repo unchanged) and the existing
  store-multi-repo.test.ts gets named constants to satisfy the
  no-duplicate-string rule.

Verified: pnpm exec tsc --noEmit clean, vitest 608/608 green,
eslint --max-warnings 0 clean.

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

* feat(agentctl): per-repo workspace tracker for multi-repo git/status

The git/status endpoint now accepts an optional ?repo=<subpath> query
param that selects a sub-directory of the workspace, completing the
per-repo plumbing started in the multi-repo phase. Single-repo callers
keep the workspace-root behavior unchanged.

- Manager.GetWorkspaceTrackerFor(subpath) returns a per-subpath
  WorkspaceTracker (lazy + cached). Validation reuses a new
  resolveSubpath helper shared with GitOperatorFor, so both code paths
  reject path traversal / absolute paths / missing dirs the same way.
- Per-subpath trackers are NOT started (no polling goroutines); the
  multi-repo path uses them only for synchronous GetCurrentGitStatus
  queries which fall through to getGitStatus when no cache is present.
  This keeps per-repo cost cheap.
- handleGitStatus reads c.Query("repo") and routes through the new
  resolver, returning a 400 with an error payload on invalid input.
- Tests cover empty subpath (root), valid subpath caching, traversal
  rejection.

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

* feat(web/review): group file tree by repository for multi-repo task changes

The review file tree now renders per-repo top-level nodes when a task's
changes span multiple repositories, so the same file path in two repos
no longer collapses into a single ambiguous entry.

- ReviewFile.repository_id and ReviewFile.repository_name (optional)
  identify which repo each file belongs to. Single-repo callers leave
  them unset and the tree falls back to the existing flat grouping.
- buildFileTree splits into buildMultiRepoTree (when 2+ distinct
  repository_names are present) and buildFlatTree (the original logic).
  Repo roots have isRepoRoot=true and a repositoryId for downstream
  filters; their children are full path subtrees per repo.
- collapse() skips repo roots so a repo with a single changed file
  still shows its repo header.
- ReviewFileTree renders repo roots with a stronger label, a top
  border, and an "N files" count badge; data-testid="repo-root-node"
  for E2E coverage. File-level rendering is unchanged.
- Tests cover the flat-tree fallback (no repo info, single repo),
  multi-repo grouping, repo-root pinning, and same-filename-different-
  repos isolation.

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

* feat(web/task-top-bar): "+N" repo chip on multi-repo task header

The task page top-bar breadcrumb now shows a small "+N" chip next to the
primary repository name when the task spans more than one repository,
matching the kanban-card treatment introduced earlier. Hover surfaces the
exact count via tooltip ("frontend (+2 more repos)").

- resolveTaskProps populates repositoryCount from task.repositories.length.
- TaskTopBar threads repositoryCount through to the new
  RepositoryBreadcrumb subcomponent.
- The chip carries data-testid="topbar-extra-repo-count" for E2E
  selectors.
- TopBarLeft was nearing the function-length / complexity caps; the
  breadcrumb extraction restores headroom for future additions.

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

* fix(web/task-create): disable "Add repository" when no other repos available

When the user only has the primary repository in their workspace,
clicking "+ Add repository" used to insert a row whose dropdown was
empty — looking like a bug ("no other repos show up"). The button now
disables itself when there are no remaining workspace repositories to
choose from, with a tooltip explaining why ("All workspace repositories
are already added"). The previous "primary not selected" guard is also
surfaced via tooltip.

- ExtraRepositoryRows computes remainingCount = repos − used (primary
  + already-added extras) and gates the Add button on it.
- New computeAddButtonHint helper returns a per-state title string for
  the disabled cases.
- Tests cover: dropdown shows the unused workspace repos when opened
  (the click path the user reported wasn't actually broken — the gate
  was just missing); Add button disabled when only the primary exists.

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

* fix(agentctl): workspace tracker falls back to first repo subdir for multi-repo task roots

The "Changes" panel was empty for multi-repo tasks because the
agent's workspace path is the task root — a plain holder directory
that holds one git worktree per repository as siblings. The workspace
tracker tried to run git commands against that bare root and silently
failed, so no git status updates ever reached the UI.

The tracker now detects this at construction: when the configured
workDir isn't itself a git repo, it falls back to the first immediate
child directory that is. The user gets the primary repo's changes in
the panel; cross-repo aggregation is a follow-up.

- preferGitRepoChildIfRootIsBare scans os.ReadDir entries (skipping
  hidden / non-directory entries) and returns the first that has a
  valid git index. Logs an info line when the rewrite happens so it's
  visible in the agentctl log.
- Repo-less tasks (e.g. quick chat) and single-repo tasks keep their
  previous behavior verbatim — the fallback only fires when the root
  itself fails resolveGitIndexPath.
- Tests cover: fallback when the workdir is a multi-repo holder,
  no-fallback for a real git repo, no-fallback when no git child
  exists.

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

* feat(multi-repo): per-repo workspace tracking — Changes panel shows all repos

The agent's workspace tracker is now split per-repository for multi-repo
task roots. Each repo gets its own tracker that polls git independently
and stamps its repository name onto every emitted event. The Changes
panel aggregates files from every repo and groups them under per-repo
headers using the file-tree machinery from Phase 6.

Backend (agentctl):
- GitStatusUpdate / FileListUpdate gain a RepositoryName field. Empty
  for single-repo workspaces (legacy clients see no change).
- WorkspaceTracker carries repositoryName and stamps it onto every
  emitted status/file-list. Two constructors: NewWorkspaceTracker for
  the legacy single-repo path (with the bare-root fallback) and
  NewWorkspaceTrackerForRepo for explicit per-repo trackers.
- Manager scans direct child directories for git repos at construction.
  When 2+ repos are found, builds one tracker per repo subdir on top of
  the root tracker (which stays bound to the bare task root and emits
  nothing useful). Single-repo workspaces are unchanged.
- Manager.SubscribeWorkspaceStream / UnsubscribeWorkspaceStream fan out
  one client subscription across the root + all per-repo trackers via
  AttachWorkspaceStreamSubscriber / DetachWorkspaceStreamSubscriber on
  the tracker. The WS handler switched from the single-tracker subscribe
  to the manager fan-out.
- Tests cover: per-repo trackers spawn for multi-repo task roots,
  single-repo doesn't, both repos emit GitStatusUpdates tagged with
  RepositoryName.

Frontend:
- GitStatusEntry / GitStatusData carry repository_name (optional).
- session-runtime gitStatus.byEnvironmentRepo new map keyed by env id
  then repository name; setGitStatus routes per-repo updates into it
  while still updating the legacy byEnvironmentId for single-repo
  consumers. clearGitStatus drops both maps.
- migrateEnvKeyedData migrates the new per-repo map alongside the
  existing keys when sessionId resolves to an environmentId.
- WS git-status handler forwards repository_name from event.status.
- New useSessionGitStatusByRepo hook returns sorted [{repo, status}]
  pairs for the active session.
- task-changes-panel mergeReviewFiles iterates statusByRepo when
  available, stamping repository_name onto every uncommitted file so
  the Changes panel groups them under per-repo headers (using the
  ReviewFile.repository_name plumbing from Phase 6).
- Tests: per-repo routing, no overwrite on sibling repo update,
  legacy single-repo fallback, clear-on-cleanup.

Verified: pnpm exec vitest run 623/623, eslint --max-warnings 0 clean,
tsc --noEmit clean, make test (backend) clean, make lint 0 issues.

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

* feat(web/changes-panel): per-repo group headers above each repo's files

The Changes panel now renders a sticky "frontend / N files" header above
the diffs from each repository so the user can visually distinguish
where each change lives. Single-repo tasks render unchanged (no header
shown when there's only one unnamed group).

- ReviewDiffList groups its file list by repository_name (preserving the
  caller's order within each group), then renders one RepoGroupHeader
  per group followed by the existing FileDiffSection list. Header gating
  rule: show when len(groups) > 1 OR the single group has a non-empty
  repository name.
- Group key uses "repo:path" so a file with the same path in two repos
  no longer collides on React's reconciliation.
- groupFilesByRepository + RepoGroupHeader live in a sibling
  review-diff-list-groups.tsx file to keep review-diff-list.tsx under
  the 600-line lint cap.
- Tests cover: no header for plain (no repo_name) files, one header per
  repo for 2+ groups, header shown even for a single named repo, file
  paths land under the right group's section.

Verified: vitest 627/627, eslint clean, tsc clean.

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

* fix(lifecycle): propagate RepositoryName through PublishGitStatus

The agentctl WorkspaceTracker stamps every per-repo GitStatusUpdate
with RepositoryName, but the lifecycle PublishGitStatus translation
into GitEventPayload was dropping that field. The orchestrator then
forwarded a status with empty repository_name, the frontend store
routed everything into the empty-key bucket, and the Changes panel's
multi-repo grouping never triggered — no header, no per-repo split.

- GitStatusData gains RepositoryName (omitempty so single-repo
  payloads stay byte-identical on the wire).
- PublishGitStatus copies update.RepositoryName onto the payload.
- Test asserts the field survives the publish → subscribe round trip
  via the in-memory event bus on the GitEvent.<sessionID> subject.

Restart the agentctl process for any running multi-repo task to pick
up the new field; new launches get it automatically.

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

* feat(web/changes-panel): per-repo group headers in the dockview Changes tab

The Changes tab in the dockview panel (the Files / Changes (N) tab the
user actually interacts with — not the standalone Review dialog) was
still rendering a flat file list, so multi-repo task changes either
collided onto the legacy single-status (showing only one repo's files)
or rendered without grouping.

- FileInfo gains repository_name (optional, single-repo unchanged).
- useSessionGit aggregates files from useSessionGitStatusByRepo when
  multi-repo data is present, stamping each FileInfo with its
  repository_name. Falls back to the legacy single status for
  single-repo workspaces. Aggregation extracted into
  aggregateFilesAcrossRepos to keep useSessionGit under the 100-line
  function lint cap.
- ChangedFile (changes-panel.tsx + changes-panel-timeline.tsx) carries
  repositoryName through to the renderer.
- FileListSection delegates to a new FileListBody that groups rows by
  repositoryName; renders a small per-repo subheader when 2+ repos OR
  any single named repo is present, otherwise falls back to the
  pre-multi-repo flat list (same DOM shape).
- Tests cover: no header for plain (no repo_name) files, headers
  per repo for 2+ groups, single named repo still gets a label.

Restart the backend so the per-repo trackers spawn for in-flight
multi-repo tasks; new launches are unaffected.

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

* feat(multi-repo): per-repo git ops, unified branch endpoint, chip-style task create

Bundles a string of multi-repo fixes and refactors that came up while
testing the feature end-to-end:

- workspace_path stored on multi-repo TaskEnvironment was one level too
  high, so agentctl booted with the wrong WorkDir on cold start; fixed
  in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession.
- Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo`
  through agentctl client → WS handler → frontend hook → SessionGit
  fan-out, so the right git repo is targeted (was running at the task
  root, which isn't a git repo for multi-repo tasks).
- agentctl GitLog is multi-repo aware: when no `repo` is specified and
  per-repo trackers exist, fans out one log per repo and merges sorted
  newest-first with RepositoryName stamped on each commit.
- Per-repo grouping for files (Changes panel) and commits, with
  collapsible group headers; commits section defaults to collapsed.
  Algorithm extracted into one generic groupByRepositoryName helper
  (was copy-pasted three times).
- Task creation dialog rewritten around a unified `fs.repositories[]`
  list — no more "primary vs extras" split. Chip-style row with one
  pill per repo + branch, "+" to add, x to remove, GitHub URL toggle
  inline. Discovered (on-machine) repos appear in any chip's dropdown.
  Removes ~6 fields and 3 helpers from the dialog state.
- Branch listing endpoints collapsed to one:
  GET /workspaces/:id/branches?repository_id=X | path=Y
  Frontend collapses to a single useBranches hook with a tagged-union
  source. Drops two HTTP routes, two service methods, two API client
  funcs, and one whole hook file.

Tests added across the chain: workspace_path resolver, sort-commits-
by-date, manager poll-mode propagation, RepoSubpaths, chip grouping
+ collapse + per-repo isLatest, group-by-repo helper, dialog payload
builder + chip wiring + discovered-repo flow.

* feat(multi-repo): unified per-repo git controls for all VCS operations

Changes panel and top-bar VCS button render per-repo Stage all, Commit,
Unstage all, Push, Pull, Rebase, Merge, and Create PR uniformly across
single-repo and multi-repo workspaces. Operations route to the correct
git repo subpath; fan-outs collect per-repo results and surface partial
success in the toast layer.

Backend:
- Stamp RepositoryName on Reset/BranchSwitch/FileChange notifications
  and forward through orchestrator handlers
- Surface per-repo errors from handleGitLogMultiRepo via PerRepoErrors

Frontend:
- pull/push/rebase/merge/abort/commit fan out via PerRepoOperationResult
- Per-repo group headers always render with action buttons
- Top-bar dropdown lists per-repo Pull/Rebase/Merge entries
- pendingStageFiles keyed per-repo to prevent cross-repo clears
- useRepoDisplayName distinguishes loading from multi-repo
- Commit dialog file summary respects stageAll
- New helpers: changes-panel-helpers.ts, changes-panel-repo-groups.tsx,
  use-repo-display-name.ts

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

* feat(multi-repo): plumb repo through file/commit/diff endpoints; per-repo PRs and review UI

- backend: thread `repo` subpath through pull/push/rebase/merge/abort, file
  content/update, cumulative-diff, and commit-show endpoints; per-repo
  fan-out for log + cumulative-diff with each repo's own merge-base; emit
  full `repositories` array on task.updated WS payload
- github: store/list TaskPRs as []*TaskPR (per-repo); orchestrator/web store
  rekey to `Record<taskId, TaskPR[]>`
- web: aggregate PR icon (count badge + worst-of color, multi-PR tooltip);
  branch hover card renders one row per repo with each repo's task base
  branch; review dialog merges per-repo file maps with composite keys and
  propagates repository_name through diff/expand/revert; commit-detail
  panel scopes to the owning repo
- kanban card: chip row of repo names (up to 2 + "+N" overflow, tooltip
  lists all)
- task-create dialog: gate non-worktree executors to multi-repo only; move
  fresh-branch toggle inline next to the repo chips (icon-only, with
  title/aria-label)

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

* fix(multi-repo): scope PR watches/associations and review UI per repository

Schema: add repository_id to UNIQUE constraints on github_pr_watches and
github_task_prs (with idempotent copy-and-rename migration) so one task
spanning N repos can hold one watch/PR per repo without collisions.

Plumb repository_id through CreatePRWatch/EnsurePRWatch/AssociatePRWithTask
/AssociatePRByURL, fan TriggerPRSync across all per-repo watches, and
disambiguate status sync by (task, owner, repo, pr_number) instead of
"first match". Add Repo to wsCreateFile/Delete/Rename and wsCreatePR/
RenameBranch so file and git ops target the right working tree.

Frontend review dialog: switch reviewedFiles, staleFiles, fileRefs,
selectedFile and comment counts to NUL-separated `${repo}\x00${path}`
keys so same-named files in different repos no longer share state.
useTaskPR consumes the new {prs: TaskPR[]} envelope.

Also: snapshot repoTrackers slice before background refresh in
SetWorkspacePollMode to prevent races with Stop(), and key
mergeCumulativeFiles by NUL-separated (repo, path) to match.

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

* chore: apply prettier formatting

Unblock CI prettier check on files modified earlier on this branch
that pre-date the current prettier config.

* fix: address PR review feedback and unblock E2E

Backend:
- ListWorkspaceTaskPRs background sweep now uses TriggerPRSyncAll so
  multi-repo tasks refresh every per-repo PR (was only the most recently
  updated watch). Caught by claude review on #767.

Frontend (no behaviour change):
- Replace literal NUL byte (0x00) in FILE_KEY_SEP and the inline key
  builder in use-review-dialog with the explicit   escape so the
  source is plain ASCII. The runtime separator is unchanged; this just
  removes a footgun that GitHub's diff renders as a visible space.
- Show the auto-resolved branch as a small read-only pill next to the
  GitHub URL input so the URL-mode dialog surfaces the branch the task
  will run on (previously only the URL was visible).

E2E:
- changes-panel-focus / pr-detection / pr-switcher-changes: expand the
  commits-section before asserting on commit text -- the section starts
  collapsed since the chip-style refactor.
- repository-selector-scroll: open the popover via repo-chip-trigger
  instead of the legacy single repository-selector testid.

* test(e2e): align task-create dialog tests with chip refactor

Updates three more tests that broke when the dialog migrated from a
single repository/branch combobox to per-row repo/branch chips and the
URL-mode toggle relabel:

- create-task.spec.ts (pre-select + remembers-selections): switch
  repository-selector / branch-selector lookups to repo-chip-trigger /
  branch-chip-trigger.
- create-task-github-url.spec.ts (toggle test): the inverse-state label
  is now "use a workspace repository", not the legacy
  "or select a repository".

No code changes — purely test alignment with the existing UI.

* fix(web): re-apply workbench-chrome topbar + restore branch-selector behaviour

Two regressions surfaced by the multi-repo merge into main:

1. task-top-bar.tsx had been resolved in our favour at the workbench-chrome
   merge (#761), losing the TopbarActionOverflow wiring that the new test
   asserts on. Re-applies main's topbar structure (TopbarCluster groups,
   TopbarActionOverflow, IntegrationsMenu, MoreToolsMenu, SettingsButton)
   on top of our multi-repo additions: repositoryCount + RepositoryBreadcrumb
   "+N" chip, gitStatus + isMultiRepo plumbing, and the GitAheadBehindBadges
   suppression for multi-repo. GitAheadBehindBadges moved to its own file to
   stay under the file-length cap.

2. The chip-style task-create dialog dropped the legacy branch-selector
   semantics. Restored:
   - Local-executor branch lock: the per-row branch chip is now disabled
     when isLocalExecutor and fresh-branch is off. The user's checkout
     dictates the branch for local execution; fresh-branch unlocks it.
   - Branch override: when locked, the chip shows currentLocalBranch
     instead of the value the user picked under a different executor
     (so worktree -> local doesn't display a stale "develop" pick).
   - Branch refresh: useBranches now exposes a refresh() callback for
     id-based sources (calls listRepositoryBranches with refresh=true),
     and Pill renders BranchRefreshButton in its popover header when
     onRefresh is wired.
   - URL-mode branch picker: GitHubUrlSection now renders a real
     branch-chip-trigger Pill backed by fs.githubBranches instead of a
     read-only span, restoring the "branch selectable in URL mode"
     behaviour (URL mode is always remote-clone, so the local-executor
     lock does not apply).

E2E spec updated: bulk-rename legacy branch-selector / repository-selector
testids to branch-chip-trigger / repo-chip-trigger.first(). All 13
create-task-branch-selector tests pass locally.

* ci: force workflow re-run

* fix(web): unblock E2E shard 2

Three issues showed up only after the merge with main, which brought back
the E2E shard 2 tests touching git/changes-panel and improve-kandev:

1. Wire the TaskCreateDialog slot props (extraFormSlot, aboveDescriptionSlot,
   bottomSlot, descriptionPlaceholder) through to DialogPromptSection. They
   were declared on the public API but dropped on the floor before this
   change, so Improve Kandev's contributor banner ("@octocat" /
   "@kandev-maint") never rendered. Also gate the "or paste a GitHub URL"
   toggle on lockedFields.repository so the locked-repo dialog hides it
   (matches main's behaviour and the existing test expectation).

2. Revert "commits section starts collapsed" (introduced in 9e8079de). The
   collapsed-by-default tradeoff is genuine, but it broke 14+ E2E tests
   that assert on commit text in the changes panel — the win wasn't worth
   the test churn. Updated the unit test to match.

3. Update the git-changes-panel E2E tests to use the stable
   `[data-testid^="commit-row-"]` selector instead of `commitsList li`.
   The chip-style refactor introduced a per-repo group <li> wrapper, so
   counting all <li> descendants now over-counts (1 wrapper + N commits).
   Same fix in the "distinguishes pushed and unpushed" test which queried
   `li` with text and matched both the wrapper and the commit row.

Locally verified: full shard 2 (45 tests) passes; full unit-test suite
(943 tests) passes; lint + typecheck clean.

* fix: address claude review feedback

Three findings from claude[bot] review on PR #767:

- types.ts: add 13 unit tests for reviewFileKey / splitReviewFileKey
  covering bare paths, composite keys, slashes/spaces in paths, repo
  names containing slashes, and full round-trip. Asks were repeated
  across five review rounds; the round-trip coverage is what catches
  the FILE_KEY_SEP NUL-vs-space class of bug at the type level.

- mergeCumulativeFiles (api/git.go): shallow-copy each per-file payload
  before stamping repository_name + path so the source map isn't
  mutated. The previous code wrote directly into the per-repo result
  map, which would have permanently rewritten the original entry if
  any future caller iterated the same source twice.

- buildRepoSpecs (executor_execute.go / executor_resume.go): drop the
  unused execCfg parameter (and the speculative "reserved for future
  per-repo config" suppression). Per repo overrides do not exist
  today and the speculative scaffolding masked an actual dead arg.

* docs(readme): mention multi-repository task support

* fix(web/topbar): drop standalone ahead/behind badges (duplicate of split button)

The Create PR / Push button already shows the inline ↑N ↓N counts, so
the separate "3 ahead" / "2 behind" badges next to the PR icon were
just visual noise. Removes the GitAheadBehindBadges component (now
unused) and the gitStatus / isMultiRepo prop chain that fed it.

* fix(github): heal legacy repository_id='' rows on github_task_prs

A task that pre-dated the multi-repo schema kept its github_task_prs row
with repository_id=''. After the schema migration, a subsequent sync
would resolve the task's primary repository_id and CreateTaskPR with the
new value -- but GetTaskPRByRepository(task, repo) returned nil because
the legacy row had an empty repository_id, so the new code inserted a
duplicate row instead of updating the legacy one. Net effect: two rows
for the same PR, surfacing as a "+2" PR badge on a single-repo task.

Adds backfillTaskPRsRepositoryID, run on schema init right after
migratePRTablesForMultiRepo:

  1. Dedup pass: when a (task, owner, repo, pr_number) tuple has both a
     legacy (repository_id='') and a per-repo row, drop the legacy one.
  2. Backfill pass: any remaining empty-repo rows get stamped with the
     task's primary repository_id from task_repositories.

The backfill is gated on task_repositories existing -- github-store unit
tests init only this package's schema, and the dedup pass alone is the
load-bearing fix for the symptom.

Tests cover the dedup path, the cross-package-skip path, and idempotency.

* test(github): cover UPDATE backfill path for orphan legacy rows

Adds TestBackfillTaskPRsRepositoryID_BackfillsOrphanRow per claude
review feedback. The two prior tests cover the DELETE dedup path and
the early-exit when task_repositories is absent, but the UPDATE path
that stamps the primary repository_id onto an orphan legacy row had
no coverage -- a wrong column name or off-by-one WHERE would have
slipped through silently.

* feat(web/changes-panel): group PR Changes by repository

Multi-repo tasks now have one TaskPR per repo, but the PR Changes
section was still rendering files flat using only the primary PR.
Adds useActiveTaskPRsWithFiles, which fans out one WS request per
TaskPR and tracks each fetch by (owner/repo/pr_number/last_synced_at)
so a server-side sync invalidates the cache.

Each PR's files are stamped with the resolved repository_name (looked
up from the workspace repos slice), and PRFilesGroupedList groups by
that name -- mirroring how Commits and Changes already render. Single-
repo tasks (one group, empty name) still render flat.

Mechanics:

- PRChangedFile gains an optional repository_name.
- mapPRFilesToChangedFiles takes a repository_name and stamps it.
- New PRFilesGroupedList + PRFilesRepoGroup live in
  changes-panel-pr-files.tsx so changes-panel-timeline.tsx stays
  under the 600-line cap; PRFilesSection delegates to them.
- Hook uses refs (not state) for the in-flight / fetched dedup so
  setState is only called when results arrive.
- Stable EMPTY_PRS sentinel avoids the new-array-each-render trap
  that would re-trigger every effect downstream.
- Workspace repo lookup memoised once via useMemo so the merged file
  list isn't rebuilt on every render.

* fix(web/changes-panel): show per-repo PR header when task is multi-repo

Previous logic only stamped repository_name on PR files when prs.length > 1
(2+ PRs). For a multi-repo task with only ONE PR yet (one repo doesn't
have a branch / hasn't been pushed), no per-repo header rendered, even
though STAGED and COMMITS already grouped under their repo names. Now
gates the stamp on the task itself being multi-repo, matching how the
other sections decide.

* fix(executor): serialise concurrent LaunchPreparedSession per session

Two paths in task_operations.go call LaunchPreparedSession on the same
session_id:

  1. PrepareTaskSession spawns a background goroutine with launchWorkspace=
     true the moment a session is created (so file browsing works before
     the user clicks Start).
  2. StartTaskWithSession runs a foreground launch when the agent is
     actually started (workflow auto-start, user click).

Without serialisation both reach agent_manager.LaunchAgent in parallel.
The fast-path check at the top of LaunchPreparedSession (session.Agent
ExecutionID != "") only kicks in once the first call has *persisted*
its execution id; the second call grabs a stale snapshot, runs the full
env-prep + builds runtime, then loses the executionStore.Add race and
fails with "already has an agent running (race resolved during register)".
Visible in the UI as "Environment setup failed".

Multi-repo amplifies the window because env prep runs sequentially per
repo (~2x the time for two repos), so what was a rare flake on
single-repo became consistent on multi-repo tasks.

Fix: acquire the per-session lock (the same sync.Map used by the resume
path) at the entry of LaunchPreparedSession and re-fetch the session
under the lock so the fast-path check sees the AgentExecutionID the
first holder just persisted. Second caller now takes the existing-
workspace path and starts only the agent subprocess, matching the
intended single-launch semantics.

Adds TestLaunchPreparedSession_SerialisesConcurrentLaunches that fires
two concurrent LaunchPreparedSession calls with the first one blocked
inside LaunchAgent. Without the lock, both reach LaunchAgent and the
test fails with call_count == 2; with the lock only the first does.

* fix(orchestrator): per-repo push detection so each repo's PR gets associated

Multi-repo tasks would only get one PR detected per task: the agent
pushes branches in BOTH repos, but the pushTracker keyed by sessionID
alone meant agentctl's per-repo git status events overwrote each
other's ahead counts. Whichever repo's events interleaved unfavourably
lost its >0 -> 0 transition and silently dropped its push detection.
Net effect: only the first/last repo's PR got an associated TaskPR row;
the other PR existed on GitHub but Kandev never knew about it.

Fix:

- pushTracker is now keyed by "<sessionID>|<repository_name>" so each
  repo's transitions are tracked independently. Empty repository_name
  preserves the legacy single-repo behaviour.
- trackPushAndAssociatePR forwards data.Status.RepositoryName to
  detectPushAndAssociatePR.
- detectPushAndAssociatePR resolves owner/name/repository_id for the
  specific repo via new resolvePushRepo helper (looks up the task's
  task_repository whose repository.Name matches the event), then uses
  GetPRWatchBySessionAndRepo to scope the existing-watch check.
- New associatePRFromPushScoped takes the per-repo repository_id
  directly instead of always resolving the primary one. CheckSessionPR
  (manual "check now") wired through the same scoped helper.

Existing tests pass with the explicit empty repository_name argument.
The user's existing task with the missing kandev PR row needs a manual
push retrigger or DB heal — the migration only deduped legacy empty
rows; this fix prevents the gap from recurring on future tasks.

* feat(web): one PR button per repo in topbar + per-PR detail panels

PRTopbarButton previously rendered just the primary TaskPR via
useActiveTaskPR(), so multi-repo tasks with N PRs only surfaced one.
Switch to useTaskPR(activeTaskId).prs and render one button per PR --
each opens its own dockview panel keyed by owner/repo/pr_number.

Plumbing:

- dockview-store / dockview-panel-actions: addPRPanel(prKey?: string)
  builds id "pr-detail|<key>" when a key is supplied; the legacy
  single-repo "pr-detail" id stays for the auto-add path.
- dockview-desktop-layout: forwards `params` to PRDetailPanelComponent
  so the panel can receive its prKey.
- pr-detail-panel: new prTaskKey helper + the panel looks up the
  matching TaskPR from the per-task list when params.prKey is set,
  falling back to useActiveTaskPR() for legacy panels and tooltips.
- pr-topbar-button: rewritten to render a button per PR, each labelled
  with #pr_number and tooltipped with owner/repo + title; click opens
  the per-PR panel.

* feat(web): collapse multi-PR topbar to a dropdown + show every PR in the + menu

Two follow-ups on top of per-PR panels:

- The dockview "+" panel-add menu still listed only state.pr (the primary
  TaskPR), so a 2-repo task only offered "PR #2" -- never the kandev
  one. Switch to state.prs[] and render one menu item per PR; multi-repo
  rows are labelled "PR #N -- repo" so the user can tell them apart.
  Each item opens its own panel via addPRPanel(prTaskKey(pr)).

- The topbar grew one outline button per PR after the previous commit,
  which doesn't scale (3+ PRs would crowd out other actions). Single PR
  keeps the existing single-button rendering; 2+ collapses to one
  outline button labelled "N PRs" with the aggregated status colour
  (red if any failing, etc.) and a dropdown listing each PR with repo,
  number, title, and per-PR status icon. Click an item -> open that
  PR's panel.

useLeftHeaderState now reads useTaskPR(taskId).prs and forwards the
full list through AddPanelMenuState as `prs`.

* fix(web/changes-panel): PR files arrive after race-cancelled fetch

useActiveTaskPRsWithFiles dropped responses from the previous effect
instance via a `cancelled` flag, but since the dedup tracking refs
(inFlightRef / fetchedRef) carry across renders, the next effect's
early-continue still saw the key in inFlightRef and skipped a fresh
request. The ref then got populated by the cancelled response and
fetchedRef permanently locked the key out of any future fetch.

Net effect: every render after the first prs reference change left
the merged prFiles array empty -- the PR Changes section never showed
up on multi-repo tasks even though both PRs existed and the WS request
would have returned files.

Drop the cancellation flag entirely. Per-key dedup is enough to prevent
duplicate requests, and the .then handlers use functional setState so
they're safe to land after the effect re-runs.

* test: cover review-feedback gaps (mapPRFilesToChangedFiles, push multi-repo, lock test channel-sync)

Three follow-ups from claude review on PR #767:

1. apps/web/components/task/changes-panel-helpers.test.ts: 5 new tests
   for mapPRFilesToChangedFiles. Covers status mapping (added / removed /
   renamed / modified / fallback), forwarding of plus/minus/oldPath, and
   the new repository_name stamping behaviour (multi-repo path stamps
   every row; single-repo path defaults to "" so PRFilesGroupedList
   skips per-repo sub-headers).

2. event_handlers_github_test.go: TestDetectPushAndAssociatePR gains
   two multi-repo cases. resolvePushRepo must scope CreatePRWatch +
   AssociatePRWithTask to the repository_id matching the event's
   repository_name (not silently fall back to primary); unknown
   repository_name must drop the event rather than mis-associate.
   mockGitHubService captures lastCreateWatchRepositoryID /
   lastAssociateRepositoryID so the per-repo scoping is asserted
   directly.

3. executor_test.go: TestLaunchPreparedSession_SerialisesConcurrentLaunches
   replaces the time.Sleep(50ms) coordination with a buffered `entered`
   channel + bounded select wait per CLAUDE.md. The test now waits for
   the first launch to enter LaunchAgent, then asserts no second entry
   arrives within a 100ms window — without the lock the second
   goroutine reaches LaunchAgent in microseconds, so the timeout serves
   as the "didn't race in" oracle, not a synchronisation primitive.

* fix(orchestrator): detect push when first poll lands post-sync

trackPushAndAssociatePR only fired on a >0→0 ahead transition, which
required two distinct status events to be observed. In multi-repo tasks,
any repo whose first agentctl status poll happened to land after the
push had already completed (ahead=0 with remote branch set on the very
first observation) silently dropped the push event — the watch was
never created, the PR never associated. Reproduced on task
4fdff41b-095a-4158-a311-4a1a23abe064 where 2/3 repos pushed but only
one PR appeared.

Now also fire on first observation when the status already reflects a
post-push state (ahead=0 + RemoteBranch set). detectPushAndAssociatePR
is idempotent against an existing watch, so a spurious fire on a
worktree that started from an already-synced remote just costs one
GitHub FindPRByBranch call.

The deferred pushTracker.Delete in detectPushAndAssociatePR is removed:
keeping ahead=0 stored is what suppresses repeated firing on subsequent
status events. Cleanup now happens at session-delete time via a new
pushTrackerForget helper that walks all (session|repo) entries.

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

* fix(web/topbar): restore pr-topbar-button testid for single-PR case

The multi-PR refactor renamed PRSingleButton's testid from
pr-topbar-button to pr-topbar-button-${pr_number}, breaking E2E tests
that relied on the stable selector (task-topbar-overflow.spec.ts and
the prTopbarButton page-object helper used by vcs-button-context).

Restore the canonical testid and expose pr_number via data-pr-number
for any test that needs to discriminate. PRMultiButton already uses
the same testid; with at most one PR-area control rendered at a time,
sharing the testid is unambiguous.

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

* fix(orchestrator): create one PR watch per repo on multi-repo tasks

The orchestrator only ever ensured a watch for a task's PRIMARY
repository. Both call sites that create watches were primary-only:

  - ensureSessionPRWatch (called when a session starts/resumes) used
    resolvePrimaryTaskRepositoryID and made one EnsurePRWatch call.

  - buildTaskBranchList (used by the poller's reconciler) keyed dedup
    by sessionID alone, so as soon as the primary repo got a watch
    the whole session was marked "watched" and the poller skipped
    every other repo.

Result: in a multi-repo task, the secondary repos never got a watch
row, so the periodic GitHub poller never searched for their PRs and
never associated them. Push detection (which fires on >0→0 ahead
transitions vs origin/main) doesn't help here either because Ahead is
measured against the integration branch, not the upstream tracking
branch — pushing doesn't change Ahead.

Reproduced on task a7a59bd8: PRs existed on github for both kandev
and aistats, but only the primary (kandev) was associated.

Refactor:

  - resolveSessionWatchTargets walks task_repositories × session
    worktrees and returns one (repo_id, owner, repo, branch) target
    per repo. Branch priority preserves the legacy precedence
    (checkout_branch > primary fallback > worktree branch) so PR-
    review-style synthetic worktree names don't clobber the real one.

  - ensureSessionPRWatch loops over targets and ensures one watch per
    repo.

  - buildTaskBranchList emits one TaskBranchInfo per (session, repo)
    and dedups against (session_id, repository_id) pairs from
    ListActivePRWatches — replacing the old session-only dedup that
    suppressed non-primary repos.

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-02 20:26:42 +01:00

1478 lines
46 KiB
Go

// Package process manages the agent subprocess lifecycle
package process
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/kandev/kandev/internal/agentctl/server/adapter"
"github.com/kandev/kandev/internal/agentctl/server/config"
"github.com/kandev/kandev/internal/agentctl/server/shell"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
tools "github.com/kandev/kandev/internal/tools/installer"
"go.uber.org/zap"
)
// Status represents the agent process status
type Status string
const (
StatusStopped Status = "stopped"
StatusStarting Status = "starting"
StatusRunning Status = "running"
StatusPaused Status = "paused"
StatusStopping Status = "stopping"
StatusError Status = "error"
)
// errorWrapper wraps an error so it can be stored in atomic.Value (which cannot store nil)
type errorWrapper struct {
err error
}
// PendingPermission represents a permission request waiting for user response
type PendingPermission struct {
ID string
Request *adapter.PermissionRequest
ResponseCh chan *adapter.PermissionResponse
CreatedAt time.Time
}
// PermissionNotification is sent when the agent requests permission
type PermissionNotification struct {
PendingID string `json:"pending_id"`
SessionID string `json:"session_id"`
ToolCallID string `json:"tool_call_id"`
Title string `json:"title"`
Options []adapter.PermissionOption `json:"options"`
ActionType string `json:"action_type,omitempty"`
ActionDetails map[string]interface{} `json:"action_details,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// defaultStderrBufferSize is the number of recent stderr lines to keep for error context
const defaultStderrBufferSize = 50
// Manager manages the agent subprocess
type Manager struct {
cfg *config.InstanceConfig
logger *logger.Logger
// Process state
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
status atomic.Value // Status
exitCode atomic.Int32
exitErr atomic.Value // error
// Stderr buffering for error context
stderrBuffer []string
stderrMu sync.RWMutex
// Workspace tracker for git status and file changes
workspaceTracker *WorkspaceTracker
// repoTrackers holds per-repository trackers for multi-repo task roots.
// Each tracker stamps RepositoryName onto its emitted events and shares
// subscriber channels with the root via the Manager fan-out.
// Empty for single-repo workspaces.
repoTrackers []*WorkspaceTracker
// workspaceTrackersBySubpath caches per-subpath trackers for multi-repo
// task roots. Key is the cleaned subpath (relative to cfg.WorkDir). The
// root tracker lives in workspaceTracker above.
workspaceTrackersBySubpath map[string]*WorkspaceTracker
workspaceTrackersMu sync.Mutex
// Script/process runner (dev server, setup, cleanup, custom)
processRunner *ProcessRunner
// Embedded shell session (auto-created when agent starts)
shell *shell.Session
// Per-terminal shell sessions for remote executor support
shellMgr *shell.Manager
// Protocol adapter for agent communication
adapter adapter.AgentAdapter
adapterCfg *adapter.Config
// Agent event notifications (protocol-agnostic)
updatesCh chan adapter.AgentEvent
// Pending permission requests waiting for user response
pendingPermissions map[string]*PendingPermission
permissionMu sync.RWMutex
// VS Code server manager (lazy-initialized on demand)
vscode *VscodeManager
vscodeMu sync.Mutex
// Git operator for git operations (lazy-initialized)
gitOperator *GitOperator
gitOperatorMu sync.Mutex
// gitOperatorsBySubpath caches per-subpath operators for multi-repo task
// roots. Key is the cleaned subpath (relative to cfg.WorkDir); empty key
// is reserved for the root operator and lives in gitOperator above.
gitOperatorsBySubpath map[string]*GitOperator
// Final command string (full command with all adapter args)
finalCommand string
// Synchronization
mu sync.RWMutex
wg sync.WaitGroup
stopCh chan struct{}
doneCh chan struct{}
startMu sync.Mutex
}
// NewManager creates a new process manager
func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager {
cfg.WorkDir = resolveExistingWorkDir(cfg.WorkDir, log.WithFields(zap.String("component", "process-manager")))
m := &Manager{
cfg: cfg,
logger: log.WithFields(zap.String("component", "process-manager")),
updatesCh: make(chan adapter.AgentEvent, 100),
pendingPermissions: make(map[string]*PendingPermission),
}
// Multi-repo task roots hold one git worktree per repository as siblings.
// In that case build a per-repo tracker for each child so each emits its
// own GitStatusUpdate (tagged with RepositoryName) and the changes panel
// can show all repos. The root tracker covers the single-repo case via
// preferGitRepoChildIfRootIsBare; we skip its fallback when we've already
// detected a multi-repo root to avoid double-tracking the first repo.
repoChildren := scanRepositorySubdirs(cfg.WorkDir)
if len(repoChildren) >= 2 {
// Multi-repo: root tracker bound to the bare task root (no fallback,
// no events), plus one tracker per repo subdir.
m.workspaceTracker = NewWorkspaceTrackerForRepo(cfg.WorkDir, "", log)
for _, child := range repoChildren {
m.repoTrackers = append(m.repoTrackers,
NewWorkspaceTrackerForRepo(child.path, child.name, log))
}
} else {
m.workspaceTracker = NewWorkspaceTracker(cfg.WorkDir, log)
}
m.processRunner = NewProcessRunner(m.workspaceTracker, log, cfg.ProcessBufferMaxBytes)
m.shellMgr = shell.NewManager(cfg.WorkDir, log)
m.status.Store(StatusStopped)
m.exitCode.Store(-1)
return m
}
// repositorySubdir is one git-repo child of a multi-repo task root.
type repositorySubdir struct {
name string // directory basename (used as RepositoryName on emitted events)
path string // absolute path to the repo subdir
}
// scanRepositorySubdirs returns the immediate child directories of workDir
// that are themselves git repositories or worktrees. Returns an empty slice
// when workDir doesn't exist, isn't readable, or contains zero git children.
// Used to detect multi-repo task roots at Manager construction.
func scanRepositorySubdirs(workDir string) []repositorySubdir {
if workDir == "" {
return nil
}
entries, err := os.ReadDir(workDir)
if err != nil {
return nil
}
var out []repositorySubdir
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
candidate := filepath.Join(workDir, entry.Name())
if resolveGitIndexPath(candidate) == "" {
continue
}
out = append(out, repositorySubdir{name: entry.Name(), path: candidate})
}
return out
}
// Status returns the current process status
func (m *Manager) Status() Status {
return m.status.Load().(Status)
}
// ExitCode returns the exit code (-1 if not exited)
func (m *Manager) ExitCode() int {
return int(m.exitCode.Load())
}
// ExitError returns the exit error if any
func (m *Manager) ExitError() error {
if v := m.exitErr.Load(); v != nil {
if w, ok := v.(errorWrapper); ok {
return w.err
}
}
return nil
}
// GetWorkspaceTracker returns the workspace tracker for git status and file monitoring
func (m *Manager) GetWorkspaceTracker() *WorkspaceTracker {
return m.workspaceTracker
}
// SubscribeWorkspaceStream creates a single workspace stream subscriber and
// fans it out across the root tracker plus every per-repo tracker, so the
// caller receives events from all repositories on one channel. Use
// UnsubscribeWorkspaceStream to detach and close.
//
// For single-repo workspaces this is equivalent to
// GetWorkspaceTracker().SubscribeWorkspaceStream() — the per-repo list is
// empty and only the root tracker fires events.
func (m *Manager) SubscribeWorkspaceStream() types.WorkspaceStreamSubscriber {
sub := make(types.WorkspaceStreamSubscriber, 100)
m.workspaceTracker.AttachWorkspaceStreamSubscriber(sub)
for _, t := range m.repoTrackers {
t.AttachWorkspaceStreamSubscriber(sub)
}
return sub
}
// UnsubscribeWorkspaceStream detaches the subscriber from every tracker and
// closes the channel exactly once.
func (m *Manager) UnsubscribeWorkspaceStream(sub types.WorkspaceStreamSubscriber) {
m.workspaceTracker.DetachWorkspaceStreamSubscriber(sub)
for _, t := range m.repoTrackers {
t.DetachWorkspaceStreamSubscriber(sub)
}
close(sub)
}
// GetWorkspaceTrackerFor returns a workspace tracker scoped to a sub-directory
// of the workspace. Used by multi-repo task roots where each repository lives
// at {WorkDir}/{subpath}. Empty subpath returns the root tracker.
//
// Per-subpath trackers are created lazily on first request and cached. They
// are NOT started (no polling goroutines) — the multi-repo path uses them
// only for synchronous git-status queries via GetCurrentGitStatus, which
// falls through to getGitStatus when no cache is present. This keeps the
// per-repo cost cheap while preserving the long-running polling behavior of
// the root tracker for the agent's primary workspace.
func (m *Manager) GetWorkspaceTrackerFor(subpath string) (*WorkspaceTracker, error) {
cleaned, full, err := m.resolveSubpath(subpath)
if err != nil {
return nil, err
}
if cleaned == "" {
return m.workspaceTracker, nil
}
m.workspaceTrackersMu.Lock()
defer m.workspaceTrackersMu.Unlock()
if m.workspaceTrackersBySubpath == nil {
m.workspaceTrackersBySubpath = make(map[string]*WorkspaceTracker)
}
if t, ok := m.workspaceTrackersBySubpath[cleaned]; ok {
return t, nil
}
t := NewWorkspaceTracker(full, m.logger)
m.workspaceTrackersBySubpath[cleaned] = t
return t, nil
}
// StartProcess runs a script/process with isolated stdout/stderr.
func (m *Manager) StartProcess(ctx context.Context, req StartProcessRequest) (*ProcessInfo, error) {
if m.processRunner == nil {
return nil, fmt.Errorf("process runner not available")
}
return m.processRunner.Start(ctx, req)
}
// StopProcess stops a running process by ID.
func (m *Manager) StopProcess(ctx context.Context, req StopProcessRequest) error {
if m.processRunner == nil {
return fmt.Errorf("process runner not available")
}
return m.processRunner.Stop(ctx, req)
}
// GetProcess returns a process by ID.
func (m *Manager) GetProcess(id string, includeOutput bool) (*ProcessInfo, bool) {
if m.processRunner == nil {
return nil, false
}
return m.processRunner.Get(id, includeOutput)
}
// ListProcesses returns processes for a session (or all if sessionID empty).
func (m *Manager) ListProcesses(sessionID string) []ProcessInfo {
if m.processRunner == nil {
return nil
}
return m.processRunner.List(sessionID)
}
// RepoSubpaths returns the subpath name (relative to cfg.WorkDir) for every
// per-repo tracker discovered at construction time. Empty for single-repo
// workspaces. Used by callers that want to fan an op out across repos.
func (m *Manager) RepoSubpaths() []string {
out := make([]string, 0, len(m.repoTrackers))
for _, t := range m.repoTrackers {
if t.repositoryName != "" {
out = append(out, t.repositoryName)
}
}
return out
}
// SetWorkspacePollMode propagates a poll-mode change to the root tracker and
// every per-repo tracker, then forces a RefreshGitStatus on each non-paused
// tracker so a fresh snapshot reaches every subscriber. Without the refresh,
// monitorTick only pushes on detected change — multi-repo workspaces would
// leave the per-repo state map sparse (one repo present, the other missing)
// after a focus event, since the agent's initial pushes happen at boot and
// no replay path exists for clients that subscribe later.
func (m *Manager) SetWorkspacePollMode(ctx context.Context, mode PollMode) {
m.workspaceTracker.SetPollMode(mode)
for _, t := range m.repoTrackers {
t.SetPollMode(mode)
}
if mode == PollModePaused {
return
}
// Snapshot the tracker slice before launching the goroutine so a
// concurrent Stop()/teardown that mutates m.repoTrackers can't race or
// nil-deref the iteration. Refresh in background — RefreshGitStatus
// blocks on git commands which can take seconds on large repos; the
// HTTP caller shouldn't wait.
root := m.workspaceTracker
trackers := make([]*WorkspaceTracker, len(m.repoTrackers))
copy(trackers, m.repoTrackers)
go func() {
root.RefreshGitStatus(ctx)
for _, t := range trackers {
t.RefreshGitStatus(ctx)
}
}()
}
// GitOperator returns the git operator for git operations against the
// workspace root. Lazy-initialized.
func (m *Manager) GitOperator() *GitOperator {
m.gitOperatorMu.Lock()
defer m.gitOperatorMu.Unlock()
if m.gitOperator == nil {
m.gitOperator = NewGitOperator(m.cfg.WorkDir, m.logger, m.workspaceTracker)
}
return m.gitOperator
}
// GitOperatorFor returns a git operator scoped to a sub-directory of the
// workspace. Used by multi-repo task roots where each repository lives at
// {WorkDir}/{subpath}. Empty subpath returns the root operator.
//
// The subpath is validated to prevent path-traversal: it must be a clean
// relative path with no parent-references and must resolve to an existing
// directory inside cfg.WorkDir.
func (m *Manager) GitOperatorFor(subpath string) (*GitOperator, error) {
cleaned, full, err := m.resolveSubpath(subpath)
if err != nil {
return nil, err
}
if cleaned == "" {
return m.GitOperator(), nil
}
m.gitOperatorMu.Lock()
defer m.gitOperatorMu.Unlock()
if m.gitOperatorsBySubpath == nil {
m.gitOperatorsBySubpath = make(map[string]*GitOperator)
}
if op, ok := m.gitOperatorsBySubpath[cleaned]; ok {
return op, nil
}
op := NewGitOperatorForRepo(full, cleaned, m.logger, m.workspaceTracker)
m.gitOperatorsBySubpath[cleaned] = op
return op, nil
}
// resolveSubpath normalises and validates a repo subpath relative to
// cfg.WorkDir. Returns ("", "", nil) for the root (empty/"."); otherwise
// returns the cleaned relative path and the absolute full path.
//
// Rejects: parent-references, absolute paths, paths containing "..",
// and paths that don't resolve to an existing directory.
func (m *Manager) resolveSubpath(subpath string) (string, string, error) {
subpath = strings.TrimSpace(subpath)
if subpath == "" || subpath == "." {
return "", m.cfg.WorkDir, nil
}
cleaned := filepath.Clean(subpath)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "..") {
return "", "", fmt.Errorf("invalid repo subpath: %q", subpath)
}
if filepath.IsAbs(cleaned) {
return "", "", fmt.Errorf("repo subpath must be relative: %q", subpath)
}
for _, part := range strings.Split(cleaned, string(filepath.Separator)) {
if part == ".." {
return "", "", fmt.Errorf("repo subpath escapes workspace: %q", subpath)
}
}
full := filepath.Join(m.cfg.WorkDir, cleaned)
info, err := os.Stat(full)
if err != nil {
return "", "", fmt.Errorf("repo subpath not found: %w", err)
}
if !info.IsDir() {
return "", "", fmt.Errorf("repo subpath is not a directory: %q", subpath)
}
return cleaned, full, nil
}
// JoinRepoPath validates a repo subpath and returns the workspace-relative
// path obtained by joining `subpath` and `path`. Empty `subpath` returns
// `path` unchanged (single-repo workspaces). Used by file content / update
// handlers to scope a per-repo path under the right repository directory
// before delegating to the workspace tracker.
func (m *Manager) JoinRepoPath(subpath, path string) (string, error) {
cleaned, _, err := m.resolveSubpath(subpath)
if err != nil {
return "", err
}
if cleaned == "" {
return path, nil
}
return filepath.Join(cleaned, path), nil
}
// Start starts the agent process
func (m *Manager) Start(ctx context.Context) error {
m.startMu.Lock()
defer m.startMu.Unlock()
if m.Status() == StatusRunning || m.Status() == StatusStarting {
return fmt.Errorf("agent is already running")
}
m.logger.Info("starting agent process",
zap.String("protocol", string(m.cfg.Protocol)),
zap.Strings("args", m.cfg.AgentArgs),
zap.String("workdir", m.cfg.WorkDir),
zap.Int("mcp_servers", len(m.cfg.McpServers)))
m.status.Store(StatusStarting)
m.exitCode.Store(-1)
m.exitErr.Store(errorWrapper{err: nil})
if len(m.cfg.AgentArgs) == 0 {
m.status.Store(StatusError)
return fmt.Errorf("no agent command configured")
}
// Build adapter config and create protocol adapter
if err := m.buildAdapterConfig(); err != nil {
m.status.Store(StatusError)
return err
}
// One-shot adapters manage their own subprocess per prompt.
// Skip process creation — the adapter spawns processes in Prompt().
if oneShotAdapter, ok := m.adapter.(adapter.OneShotAdapter); ok && oneShotAdapter.IsOneShot() {
return m.startOneShot()
}
// Assemble final command (does not start the process yet)
if err := m.buildFinalCommand(); err != nil {
m.status.Store(StatusError)
return err
}
// Set up stdin/stdout/stderr pipes (must happen before process starts)
if err := m.startProcessPipes(); err != nil {
m.status.Store(StatusError)
return err
}
// Start the subprocess now that pipes are connected
if err := m.cmd.Start(); err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to start agent: %w", err)
}
m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{})
// Connect adapter to the process stdin/stdout pipes
if err := m.adapter.Connect(m.stdin, m.stdout); err != nil {
m.status.Store(StatusError)
return fmt.Errorf("failed to connect adapter: %w", err)
}
// Start stderr reader and exit waiter
m.wg.Add(2)
go m.readStderr()
go m.waitForExit()
// Forward adapter updates to our channel
m.wg.Add(1)
go m.forwardUpdates()
// Start workspace tracker with background context (not tied to HTTP request)
m.workspaceTracker.Start(context.Background())
for _, t := range m.repoTrackers {
t.Start(context.Background())
}
// Auto-create shell session if enabled
m.startAgentShell()
m.status.Store(StatusRunning)
m.logger.Info("agent process started", zap.Int("pid", m.cmd.Process.Pid))
return nil
}
// startOneShot initialises a one-shot adapter without spawning a long-lived subprocess.
// The adapter manages its own per-prompt subprocess lifecycle internally.
func (m *Manager) startOneShot() error {
m.stopCh = make(chan struct{})
m.doneCh = make(chan struct{})
// Forward adapter updates to our channel
m.wg.Add(1)
go m.forwardUpdates()
// Start workspace tracker with background context (not tied to HTTP request)
m.workspaceTracker.Start(context.Background())
for _, t := range m.repoTrackers {
t.Start(context.Background())
}
// Auto-create shell session if enabled
m.startAgentShell()
m.status.Store(StatusRunning)
m.logger.Info("one-shot adapter started (no persistent subprocess)")
return nil
}
// buildAdapterConfig constructs the adapter configuration and initialises the
// protocol adapter, including merging any adapter-provided environment variables.
func (m *Manager) buildAdapterConfig() error {
mcpServers := make([]adapter.McpServerConfig, len(m.cfg.McpServers))
for i, mcp := range m.cfg.McpServers {
mcpServers[i] = adapter.McpServerConfig{
Name: mcp.Name,
URL: mcp.URL,
Type: mcp.Type,
Command: mcp.Command,
Args: mcp.Args,
Env: mcp.Env,
Headers: mcp.Headers,
}
}
m.adapterCfg = &adapter.Config{
WorkDir: m.cfg.WorkDir,
AutoApprove: m.cfg.AutoApprovePermissions,
ApprovalPolicy: m.cfg.ApprovalPolicy,
McpServers: mcpServers,
AgentID: m.cfg.AgentType, // From registry (e.g., "auggie", "amp", "claude-code")
AssumeMcpSse: m.cfg.AssumeMcpSse,
}
// Configure one-shot mode when a continue command is provided.
// One-shot adapters (e.g., Amp) spawn a new subprocess per prompt.
if m.cfg.ContinueCommand != "" {
m.adapterCfg.OneShotConfig = &adapter.OneShotConfig{
InitialArgs: m.cfg.AgentArgs,
ContinueArgs: config.ParseCommand(m.cfg.ContinueCommand),
Env: m.cfg.AgentEnv,
WorkDir: m.cfg.WorkDir,
}
}
for i, srv := range mcpServers {
m.logger.Debug("MCP server config",
zap.Int("index", i),
zap.String("name", srv.Name),
zap.String("url", srv.URL),
zap.String("type", srv.Type),
zap.String("command", srv.Command))
}
if err := m.createAdapter(); err != nil {
return fmt.Errorf("failed to create adapter: %w", err)
}
// Merge adapter-provided environment variables into the subprocess environment
adapterEnv, err := m.adapter.PrepareEnvironment()
if err != nil {
m.logger.Warn("failed to prepare protocol environment", zap.Error(err))
}
for k, v := range adapterEnv {
m.cfg.AgentEnv = append(m.cfg.AgentEnv, fmt.Sprintf("%s=%s", k, v))
}
return nil
}
// buildFinalCommand assembles the full command args and creates the exec.Cmd.
// The process group is set so child processes can be killed together.
func (m *Manager) buildFinalCommand() error {
extraArgs := m.adapter.PrepareCommandArgs()
cmdArgs := make([]string, 0, len(m.cfg.AgentArgs)-1+len(extraArgs))
cmdArgs = append(cmdArgs, m.cfg.AgentArgs[1:]...)
cmdArgs = append(cmdArgs, extraArgs...)
m.finalCommand = strings.Join(append([]string{m.cfg.AgentArgs[0]}, cmdArgs...), " ")
m.logger.Debug("final agent command",
zap.String("binary", m.cfg.AgentArgs[0]),
zap.Strings("args", cmdArgs),
zap.Int("extra_args_count", len(extraArgs)))
// NOTE: We intentionally don't use exec.CommandContext here because we don't want
// the HTTP request context to kill the agent process when the request completes.
m.cmd = exec.Command(m.cfg.AgentArgs[0], cmdArgs...)
m.cmd.Dir = m.cfg.WorkDir
m.cmd.Env = m.cfg.AgentEnv
// Create a new process group so we can kill all child processes together.
// This is important for adapters like OpenCode that spawn child processes
// (npx -> sh -> node -> opencode binary).
setProcGroup(m.cmd)
m.logger.Info("agent command prepared",
zap.Strings("args", m.cfg.AgentArgs),
zap.Strings("extra_args", extraArgs),
zap.String("workdir", m.cfg.WorkDir),
zap.Int("env_count", len(m.cfg.AgentEnv)))
return nil
}
// startProcessPipes creates stdin, stdout, and stderr pipes for the agent subprocess.
// The pipes must be created before the process starts.
func (m *Manager) startProcessPipes() error {
var err error
m.stdin, err = m.cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %w", err)
}
m.stdout, err = m.cmd.StdoutPipe()
if err != nil {
_ = m.stdin.Close()
return fmt.Errorf("failed to create stdout pipe: %w", err)
}
m.stderr, err = m.cmd.StderrPipe()
if err != nil {
_ = m.stdin.Close()
_ = m.stdout.Close()
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
return nil
}
// startAgentShell auto-creates a shell session when ShellEnabled is configured.
// Failure is non-fatal: the agent can still work without a shell session.
func (m *Manager) startAgentShell() {
if !m.cfg.ShellEnabled {
return
}
shellCfg := shell.DefaultConfig(m.cfg.WorkDir)
shellCfg.ShellCommand = preferredShellCommand(m.cfg.AgentEnv)
shellSession, err := shell.NewSession(shellCfg, m.logger)
if err != nil {
m.logger.Warn("failed to create shell session", zap.Error(err))
return
}
m.shell = shellSession
m.logger.Info("shell session auto-created")
}
func preferredShellCommand(env []string) string {
if value := lookupEnvValue(env, "AGENTCTL_SHELL_COMMAND"); value != "" {
return value
}
return lookupEnvValue(env, "SHELL")
}
func lookupEnvValue(env []string, key string) string {
prefix := key + "="
for _, entry := range env {
if strings.HasPrefix(entry, prefix) {
return strings.TrimPrefix(entry, prefix)
}
}
return ""
}
// Configure sets the agent command and optional environment variables.
// This must be called before Start() if the instance was created without a command.
// continueCommand is optional — when set, the adapter uses it for one-shot follow-up prompts.
func (m *Manager) Configure(command string, env map[string]string, approvalPolicy, continueCommand string) error {
m.startMu.Lock()
defer m.startMu.Unlock()
if m.Status() == StatusRunning || m.Status() == StatusStarting {
return fmt.Errorf("cannot configure while agent is running")
}
if command == "" {
return fmt.Errorf("agent command cannot be empty")
}
// Parse the command string and update config
args := config.ParseCommand(command)
if len(args) == 0 {
return fmt.Errorf("failed to parse agent command")
}
m.cfg.AgentCommand = command
m.cfg.AgentArgs = args
// Set approval policy if provided (for Codex)
if approvalPolicy != "" {
m.cfg.ApprovalPolicy = approvalPolicy
}
// Store continue command for one-shot adapters (e.g., Amp)
if continueCommand != "" {
m.cfg.ContinueCommand = continueCommand
}
// Merge additional env vars
if len(env) > 0 {
for k, v := range env {
m.cfg.AgentEnv = append(m.cfg.AgentEnv, fmt.Sprintf("%s=%s", k, v))
}
}
m.logger.Info("agent configured",
zap.String("command", command),
zap.Strings("args", args),
zap.String("approval_policy", m.cfg.ApprovalPolicy),
zap.String("continue_command", continueCommand),
zap.Int("env_count", len(env)))
return nil
}
// createAdapter creates the appropriate protocol adapter based on configuration.
// This should be called before starting the process so PrepareEnvironment can run.
func (m *Manager) createAdapter() error {
protocol := m.cfg.Protocol
if protocol == "" {
return fmt.Errorf("protocol not specified in configuration")
}
m.logger.Debug("creating adapter", zap.String("protocol", string(protocol)))
adpt, err := adapter.NewAdapter(protocol, m.adapterCfg, m.logger)
if err != nil {
return fmt.Errorf("failed to create adapter: %w", err)
}
m.adapter = adpt
// Set stderr provider for adapters that support it (Codex, StreamJSON)
if setter, ok := m.adapter.(adapter.StderrProviderSetter); ok {
setter.SetStderrProvider(m)
}
// Set the permission handler
m.adapter.SetPermissionHandler(m.handlePermissionRequest)
return nil
}
// forwardUpdates forwards updates from the adapter to the manager's channel
func (m *Manager) forwardUpdates() {
defer m.wg.Done()
updatesCh := m.adapter.Updates()
for {
select {
case update, ok := <-updatesCh:
if !ok {
return
}
select {
case m.updatesCh <- update:
default:
m.logger.Warn("updates channel full, dropping notification")
}
case <-m.stopCh:
return
}
}
}
// GetUpdates returns the channel for agent event notifications
func (m *Manager) GetUpdates() <-chan adapter.AgentEvent {
return m.updatesCh
}
// SendErrorEvent sends an error event on the updates channel so the
// lifecycle manager (and ultimately the UI) learns about the failure.
func (m *Manager) SendErrorEvent(errorMessage string) {
select {
case m.updatesCh <- adapter.AgentEvent{
Type: adapter.EventTypeError,
Error: errorMessage,
}:
default:
m.logger.Warn("updates channel full, could not send error event")
}
}
// GetAdapter returns the protocol adapter
func (m *Manager) GetAdapter() adapter.AgentAdapter {
m.mu.RLock()
defer m.mu.RUnlock()
return m.adapter
}
// GetSessionID returns the current session ID from the adapter.
// The adapter is the single source of truth for session ID.
func (m *Manager) GetSessionID() string {
m.mu.RLock()
a := m.adapter
m.mu.RUnlock()
if a != nil {
return a.GetSessionID()
}
return ""
}
// Stop stops the agent process
func (m *Manager) Stop(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
status := m.Status()
if status == StatusStopped || status == StatusStopping {
m.logger.Info("Stop called but already stopped/stopping", zap.String("status", string(status)))
return nil
}
m.logger.Info("stopping agent process - START")
m.status.Store(StatusStopping)
m.stopShellAndProcesses(ctx)
m.closeAdapterAndStdin()
m.killProcessGroupIfRequired()
m.waitForProcessExit(ctx)
m.status.Store(StatusStopped)
m.logger.Info("stopping agent process - COMPLETE")
return nil
}
// stopShellAndProcesses stops the shell session, VS Code, workspace processes, and workspace tracker.
func (m *Manager) stopShellAndProcesses(ctx context.Context) {
// Stop VS Code server if running
m.logger.Debug("stopping vscode server")
if err := m.StopVscode(ctx); err != nil {
m.logger.Debug("failed to stop vscode server", zap.Error(err))
}
m.logger.Debug("vscode server stopped")
m.logger.Debug("stopping shell session")
if m.shell != nil {
if err := m.shell.Stop(); err != nil {
m.logger.Debug("failed to stop shell session", zap.Error(err))
}
m.shell = nil
}
m.logger.Debug("shell session stopped")
m.logger.Debug("stopping terminal shells")
m.shellMgr.StopAll()
m.logger.Debug("terminal shells stopped")
// Stop all running workspace processes (dev server, setup, cleanup, custom).
m.logger.Debug("stopping workspace processes")
if m.processRunner != nil {
if err := m.processRunner.StopAll(ctx); err != nil {
m.logger.Debug("failed to stop workspace processes", zap.Error(err))
}
}
m.logger.Debug("workspace processes stopped")
m.logger.Debug("stopping workspace tracker")
if m.workspaceTracker != nil {
m.workspaceTracker.Stop()
}
for _, t := range m.repoTrackers {
t.Stop()
}
m.logger.Debug("workspace tracker stopped")
}
// closeAdapterAndStdin closes the protocol adapter, the stop channel, and stdin.
func (m *Manager) closeAdapterAndStdin() {
m.logger.Debug("closing adapter")
if m.adapter != nil {
if err := m.adapter.Close(); err != nil {
m.logger.Debug("failed to close adapter", zap.Error(err))
}
}
m.logger.Debug("adapter closed")
m.logger.Debug("closing stop channel")
if m.stopCh != nil {
close(m.stopCh)
}
m.logger.Debug("stop channel closed")
// Close stdin to signal EOF to agent
m.logger.Debug("closing stdin")
if m.stdin != nil {
if err := m.stdin.Close(); err != nil {
m.logger.Debug("failed to close stdin", zap.Error(err))
}
}
m.logger.Debug("stdin closed")
}
// killProcessGroupIfRequired kills the entire process group for adapters (such as
// OpenCode) that run as HTTP servers and do not exit when stdin is closed.
func (m *Manager) killProcessGroupIfRequired() {
if m.adapter == nil || !m.adapter.RequiresProcessKill() {
return
}
if m.cmd == nil || m.cmd.Process == nil {
return
}
// We kill the process group to ensure all child processes are killed too.
// This is important because OpenCode spawns: npx -> sh -> node -> opencode binary
pid := m.cmd.Process.Pid
m.logger.Debug("killing process group", zap.Int("pgid", pid))
if err := killProcessGroup(pid); err != nil {
m.logger.Debug("failed to kill process group, trying single process", zap.Error(err))
if err := m.cmd.Process.Kill(); err != nil {
m.logger.Warn("failed to kill process", zap.Error(err))
}
}
}
// waitForProcessExit waits for all goroutines to finish, force-killing on context timeout.
func (m *Manager) waitForProcessExit(ctx context.Context) {
m.logger.Debug("waiting for process to exit")
done := make(chan struct{})
go func() {
m.wg.Wait()
close(done)
}()
select {
case <-done:
m.logger.Info("agent process stopped gracefully")
case <-ctx.Done():
if m.cmd != nil && m.cmd.Process != nil {
m.logger.Warn("force killing agent process")
if err := m.cmd.Process.Kill(); err != nil {
m.logger.Warn("failed to kill agent process", zap.Error(err))
}
}
}
}
// readStderr reads and logs stderr from the agent
func (m *Manager) readStderr() {
defer m.wg.Done()
scanner := bufio.NewScanner(m.stderr)
for scanner.Scan() {
line := scanner.Text()
m.logger.Debug("agent stderr", zap.String("line", line))
// Buffer the line for error context
m.appendStderr(line)
}
if err := scanner.Err(); err != nil {
m.logger.Debug("stderr reader error", zap.Error(err))
}
}
// ansiEscapeRegex matches ANSI escape sequences
var ansiEscapeRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
// stripANSI removes ANSI escape codes from a string
func stripANSI(s string) string {
return ansiEscapeRegex.ReplaceAllString(s, "")
}
// appendStderr adds a line to the stderr ring buffer
func (m *Manager) appendStderr(line string) {
m.stderrMu.Lock()
defer m.stderrMu.Unlock()
// Strip ANSI escape codes for cleaner display
cleanLine := stripANSI(line)
if len(m.stderrBuffer) >= defaultStderrBufferSize {
// Ring buffer: drop oldest line
m.stderrBuffer = m.stderrBuffer[1:]
}
m.stderrBuffer = append(m.stderrBuffer, cleanLine)
}
// GetRecentStderr returns a copy of the recent stderr lines
func (m *Manager) GetRecentStderr() []string {
m.stderrMu.RLock()
defer m.stderrMu.RUnlock()
result := make([]string, len(m.stderrBuffer))
copy(result, m.stderrBuffer)
return result
}
// ClearStderrBuffer clears the stderr buffer (e.g., after successful operation)
func (m *Manager) ClearStderrBuffer() {
m.stderrMu.Lock()
defer m.stderrMu.Unlock()
m.stderrBuffer = nil
}
// waitForExit waits for the process to exit
func (m *Manager) waitForExit() {
defer m.wg.Done()
defer close(m.doneCh)
err := m.cmd.Wait()
if err != nil {
m.exitErr.Store(errorWrapper{err: err})
exitCode := -1
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
m.exitCode.Store(int32(exitCode))
}
// Include recent stderr for better error diagnostics
recentStderr := m.GetRecentStderr()
m.logger.Error("agent process exited with error",
zap.Error(err),
zap.Int("exit_code", exitCode),
zap.Strings("recent_stderr", recentStderr))
// Send error event to the updates channel so UI can display it
errorMsg := fmt.Sprintf("Agent process exited with code %d", exitCode)
if len(recentStderr) > 0 {
errorMsg = fmt.Sprintf("%s: %s", errorMsg, strings.Join(recentStderr, "; "))
}
select {
case m.updatesCh <- adapter.AgentEvent{
Type: adapter.EventTypeError,
Error: errorMsg,
Data: map[string]any{
"exit_code": exitCode,
"recent_stderr": recentStderr,
},
}:
default:
m.logger.Warn("updates channel full, could not send exit error event")
}
} else {
m.exitCode.Store(0)
m.logger.Info("agent process exited successfully")
}
m.status.Store(StatusStopped)
}
// GetFinalCommand returns the full command string that was used to start the agent process,
// including all adapter-added arguments (sandbox mode, MCP flags, etc.).
func (m *Manager) GetFinalCommand() string {
return m.finalCommand
}
// GetProcessInfo returns information about the process
func (m *Manager) GetProcessInfo() map[string]interface{} {
info := map[string]interface{}{
"status": string(m.Status()),
"exit_code": m.ExitCode(),
}
if m.cmd != nil && m.cmd.Process != nil {
info["pid"] = m.cmd.Process.Pid
}
if err := m.ExitError(); err != nil {
info["exit_error"] = err.Error()
}
return info
}
// handlePermissionRequest handles permission requests from the agent
// It stores the pending request and waits for a response from the backend
func (m *Manager) handlePermissionRequest(ctx context.Context, req *adapter.PermissionRequest) (*adapter.PermissionResponse, error) {
// Use the adapter-provided pending ID if available, otherwise generate one.
// This ensures the ID sent to the frontend matches the one used for response lookup.
// For OpenCode (per_xxx) and Claude Code (requestID), the adapter passes its ID
// so we use the same ID throughout the permission flow.
pendingID := req.PendingID
if pendingID == "" {
pendingID = fmt.Sprintf("%s-%s-%d", req.SessionID, req.ToolCallID, time.Now().UnixNano())
}
m.logger.Info("handling permission request",
zap.String("pending_id", pendingID),
zap.String("session_id", req.SessionID),
zap.String("tool_call_id", req.ToolCallID),
zap.String("title", req.Title),
zap.Bool("auto_approve", m.cfg.AutoApprovePermissions))
// If auto-approve is enabled, immediately approve with the first "allow" option
if m.cfg.AutoApprovePermissions {
return m.autoApprovePermission(req)
}
// Create pending permission with response channel
pending := &PendingPermission{
ID: pendingID,
Request: req,
ResponseCh: make(chan *adapter.PermissionResponse, 1),
CreatedAt: time.Now(),
}
// Store pending permission
m.permissionMu.Lock()
m.pendingPermissions[pendingID] = pending
m.permissionMu.Unlock()
// Clean up when done
defer func() {
m.permissionMu.Lock()
delete(m.pendingPermissions, pendingID)
m.permissionMu.Unlock()
}()
// Send notification to backend via updates channel
m.sendPermissionNotification(pending)
// Wait for response indefinitely - user may close and reopen the page
select {
case resp := <-pending.ResponseCh:
m.logger.Info("received permission response",
zap.String("pending_id", pendingID),
zap.String("option_id", resp.OptionID),
zap.Bool("cancelled", resp.Cancelled))
if resp.Cancelled {
m.sendPermissionCancelledNotification(pending)
}
return resp, nil
case <-ctx.Done():
m.logger.Warn("permission request context cancelled",
zap.String("pending_id", pendingID))
// Send cancellation notification so the backend can update the permission message status
m.sendPermissionCancelledNotification(pending)
return &adapter.PermissionResponse{Cancelled: true}, nil
}
}
// autoApprovePermission automatically approves a permission request
// by selecting the first "allow" option, or the first option if no allow option exists
func (m *Manager) autoApprovePermission(req *adapter.PermissionRequest) (*adapter.PermissionResponse, error) {
if len(req.Options) == 0 {
m.logger.Warn("no options available for auto-approve, cancelling")
return &adapter.PermissionResponse{Cancelled: true}, nil
}
// Find the first "allow" option
var selectedOption *adapter.PermissionOption
for i := range req.Options {
opt := &req.Options[i]
if opt.Kind == "allow_once" || opt.Kind == "allow_always" {
selectedOption = opt
break
}
}
// If no allow option, use the first option
if selectedOption == nil {
selectedOption = &req.Options[0]
}
m.logger.Info("auto-approving permission request",
zap.String("option_id", selectedOption.OptionID),
zap.String("option_name", selectedOption.Name),
zap.String("kind", selectedOption.Kind))
return &adapter.PermissionResponse{
OptionID: selectedOption.OptionID,
}, nil
}
// sendPermissionNotification sends a permission request notification through the updates channel.
// Uses a blocking send with timeout to ensure delivery. If delivery fails within 5 seconds,
// auto-cancels the permission so the agent doesn't hang waiting for a response.
func (m *Manager) sendPermissionNotification(pending *PendingPermission) {
// Convert options to streams.PermissionOption (types.PermissionOption is an alias)
options := make([]streams.PermissionOption, len(pending.Request.Options))
copy(options, pending.Request.Options)
event := adapter.AgentEvent{
Type: adapter.EventTypePermissionRequest,
SessionID: pending.Request.SessionID,
ToolCallID: pending.Request.ToolCallID,
PendingID: pending.ID,
PermissionTitle: pending.Request.Title,
PermissionOptions: options,
ActionType: pending.Request.ActionType,
ActionDetails: pending.Request.ActionDetails,
}
m.logger.Info("sending permission notification via updates channel",
zap.String("pending_id", pending.ID),
zap.String("title", pending.Request.Title),
zap.String("action_type", pending.Request.ActionType))
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case m.updatesCh <- event:
// Sent successfully
case <-timer.C:
m.logger.Error("failed to deliver permission notification, auto-cancelling",
zap.String("pending_id", pending.ID))
select {
case pending.ResponseCh <- &adapter.PermissionResponse{Cancelled: true}:
default:
}
}
}
// sendPermissionCancelledNotification sends a notification that a permission request was cancelled.
// This happens when the context is cancelled (e.g., agent completes or user stops the task)
// before the user responds to the permission request.
func (m *Manager) sendPermissionCancelledNotification(pending *PendingPermission) {
event := adapter.AgentEvent{
Type: adapter.EventTypePermissionCancelled,
SessionID: pending.Request.SessionID,
PendingID: pending.ID,
}
m.logger.Info("sending permission cancelled notification",
zap.String("pending_id", pending.ID),
zap.String("session_id", pending.Request.SessionID))
select {
case m.updatesCh <- event:
// Sent successfully
default:
m.logger.Warn("updates channel full, dropping permission cancelled notification",
zap.String("pending_id", pending.ID))
}
}
// RespondToPermission responds to a pending permission request
func (m *Manager) RespondToPermission(pendingID string, optionID string, cancelled bool) error {
m.permissionMu.RLock()
pending, ok := m.pendingPermissions[pendingID]
m.permissionMu.RUnlock()
if !ok {
return fmt.Errorf("pending permission not found: %s", pendingID)
}
m.logger.Info("responding to permission request",
zap.String("pending_id", pendingID),
zap.String("option_id", optionID),
zap.Bool("cancelled", cancelled))
// Send response (non-blocking since channel is buffered)
select {
case pending.ResponseCh <- &adapter.PermissionResponse{
OptionID: optionID,
Cancelled: cancelled,
}:
return nil
default:
return fmt.Errorf("response channel full for pending permission: %s", pendingID)
}
}
// CancelPendingPermissions cancels all pending permission requests.
// Called before sending a new prompt so the agent isn't blocked waiting for responses.
func (m *Manager) CancelPendingPermissions() {
m.permissionMu.RLock()
pending := make([]*PendingPermission, 0, len(m.pendingPermissions))
for _, p := range m.pendingPermissions {
pending = append(pending, p)
}
m.permissionMu.RUnlock()
for _, p := range pending {
m.logger.Info("cancelling pending permission before new prompt",
zap.String("pending_id", p.ID))
select {
case p.ResponseCh <- &adapter.PermissionResponse{Cancelled: true}:
default:
}
}
}
// Shell returns the embedded shell session, or nil if not available
func (m *Manager) Shell() *shell.Session {
m.mu.RLock()
defer m.mu.RUnlock()
return m.shell
}
// ShellManager returns the per-terminal shell session manager.
func (m *Manager) ShellManager() *shell.Manager {
return m.shellMgr
}
// StartShell creates and starts the shell session independently of the agent process.
// This is used in passthrough mode where the agent runs directly via InteractiveRunner
// but we still need shell access for the workspace.
// Returns nil if shell is already started or if ShellEnabled is false.
func (m *Manager) StartShell() error {
m.mu.Lock()
defer m.mu.Unlock()
// Already running
if m.shell != nil {
return nil
}
// Shell disabled
if !m.cfg.ShellEnabled {
return nil
}
shellCfg := shell.DefaultConfig(m.cfg.WorkDir)
shellCfg.ShellCommand = preferredShellCommand(m.cfg.AgentEnv)
shellSession, err := shell.NewSession(shellCfg, m.logger)
if err != nil {
return fmt.Errorf("failed to create shell session: %w", err)
}
m.shell = shellSession
m.logger.Info("shell session started independently")
return nil
}
// StartVscode starts the code-server process on a random OS-assigned port.
// The VS Code server runs independently of the agent process.
// Start is non-blocking — the caller should poll VscodeInfo() for status updates.
func (m *Manager) StartVscode(_ context.Context, theme string) {
m.vscodeMu.Lock()
defer m.vscodeMu.Unlock()
if m.vscode != nil {
info := m.vscode.Info()
if info.Status == VscodeStatusRunning || info.Status == VscodeStatusStarting || info.Status == VscodeStatusInstalling {
return
}
}
command := m.cfg.VscodeCommand
if command == "" {
command = "code-server"
}
strategy := codeServerInstallStrategy(m.logger)
m.vscode = NewVscodeManager(command, m.cfg.WorkDir, theme, strategy, m.logger)
m.vscode.Start()
}
// codeServerInstallStrategy returns a tarball strategy that auto-installs code-server.
func codeServerInstallStrategy(log *logger.Logger) tools.Strategy {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
installDir := filepath.Join(home, ".kandev", "tools", "code-server")
return tools.NewGithubTarballStrategy(installDir, "code-server", tools.GithubTarballConfig{
Owner: "coder",
Repo: "code-server",
Version: "4.96.4",
AssetPattern: "code-server-{version}-{os}-{arch}.tar.gz",
BinaryPath: "code-server-{version}-{os}-{arch}/bin/code-server",
Targets: map[string]string{
"darwin/arm64": "macos-arm64",
"darwin/amd64": "macos-amd64",
"linux/amd64": "linux-amd64",
"linux/arm64": "linux-arm64",
},
}, log)
}
// StopVscode stops the code-server process if running.
func (m *Manager) StopVscode(ctx context.Context) error {
m.vscodeMu.Lock()
defer m.vscodeMu.Unlock()
if m.vscode == nil {
return nil
}
err := m.vscode.Stop(ctx)
m.vscode = nil
return err
}
// VscodeInfo returns the current VS Code server status.
func (m *Manager) VscodeInfo() VscodeInfo {
m.vscodeMu.Lock()
defer m.vscodeMu.Unlock()
if m.vscode == nil {
return VscodeInfo{Status: VscodeStatusStopped}
}
return m.vscode.Info()
}
// VscodeOpenFile opens a file in the running VS Code instance via the Remote CLI.
// If code-server is not running, it auto-starts it and waits for readiness.
//
// Note: there is a benign race between the needsStart check and WaitForRunning —
// another goroutine could stop vscode in between. WaitForRunning handles this
// correctly by returning an error for stopped/error states.
func (m *Manager) VscodeOpenFile(ctx context.Context, path string, line, col int) error {
m.vscodeMu.Lock()
needsStart := m.vscode == nil
if !needsStart {
info := m.vscode.Info()
needsStart = info.Status == VscodeStatusStopped || info.Status == VscodeStatusError
}
m.vscodeMu.Unlock()
if needsStart {
m.logger.Info("auto-starting code-server for open-file request")
m.StartVscode(ctx, "")
}
m.vscodeMu.Lock()
vscode := m.vscode
m.vscodeMu.Unlock()
if vscode == nil {
return fmt.Errorf("failed to initialize code-server")
}
if err := vscode.WaitForRunning(ctx); err != nil {
return fmt.Errorf("code-server not ready: %w", err)
}
return vscode.OpenFile(ctx, path, line, col)
}