Skip to content
5 changes: 5 additions & 0 deletions .changeset/tower-mode-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Tower mode (experimental, `KIMI_CODE_EXPERIMENTAL_TOWER=1`): spawned workers now start from the base checkout's uncommitted changes instead of missing them, and TowerMerge refuses to merge while the checkout still holds those changes uncommitted. Also, a new session can now enter tower mode after the previous owning session stopped without exiting, instead of being refused while that session stays open. Tower mode now stays on after tower teardown; turn it off explicitly with /tower off. Tower mode is now mutually exclusive with plan mode and swarm mode: entering any one of them exits the others.
8 changes: 8 additions & 0 deletions packages/agent-core-v2/src/agent/modeMutex/modeMutex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';

export interface IAgentModeMutexService {
readonly _serviceBrand: undefined;
}

export const IAgentModeMutexService: ServiceIdentifier<IAgentModeMutexService> =
createDecorator<IAgentModeMutexService>('agentModeMutexService');
51 changes: 51 additions & 0 deletions packages/agent-core-v2/src/agent/modeMutex/modeMutexService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Disposable } from '#/_base/di/lifecycle';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { IAgentStateService } from '#/agent/state/agentState';
import { IEventBus } from '#/app/event/eventBus';
import { LifecycleScope } from '#/app/scopes';
import { IAgentPlanService } from '#/features/plan/plan';
import { PlanModeEnter, planKey } from '#/features/plan/planOps';
import { IAgentSwarmService } from '#/features/swarm/agent/swarm';
import { SwarmModeEnter } from '#/features/swarm/swarmOps';
import { IAgentTowerService } from '#/features/tower/tower';
import { TowerModeEnter } from '#/features/tower/towerOps';

import { IAgentModeMutexService } from './modeMutex';

export class AgentModeMutexService extends Disposable implements IAgentModeMutexService {
declare readonly _serviceBrand: undefined;

constructor(
@IAgentPlanService private readonly plan: IAgentPlanService,
@IAgentSwarmService private readonly swarm: IAgentSwarmService,
@IAgentTowerService private readonly tower: IAgentTowerService,
@IAgentStateService private readonly agentState: IAgentStateService,
@IEventBus eventBus: IEventBus,
) {
super();
this._register(
eventBus.subscribe(PlanModeEnter, () => {
if (this.tower.isActive) this.tower.exit();
}),
);
this._register(
eventBus.subscribe(SwarmModeEnter, () => {
if (this.tower.isActive) this.tower.exit();
}),
);
this._register(
eventBus.subscribe(TowerModeEnter, () => {
if (this.agentState.get(planKey).active) this.plan.exit();
if (this.swarm.isActive) this.swarm.exit();
}),
);
}
}

registerScopedService(
LifecycleScope.Agent,
IAgentModeMutexService,
AgentModeMutexService,
ScopeActivation.OnScopeCreated,
'modeMutex',
);
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Working principles:
- Finding → triage: assign to a mission, plan a new one, or backlog — the disposition is your call; tell the human.
- Completion report with a suspicious diff (🟢 claimed, zero changed files) → investigate before accepting.
5. **Merge** — `TowerMerge(branch)` in Dependency Flow order. The gate refuses when there is no clean review for the current tip, dependencies are unmerged, or files escaped the scope — the error message is your next step. After a merge, the result lists branches that now conflict: tell those workers (resume) to rebase onto the new base, resolve, push, and request re-review; their moved tip makes the gate demand a fresh clean review.
6. **Teardown promptly** — when `TowerStatus` shows every mission ✅ merged and no unactioned inbox items remain, call `TowerTeardown` **right away** and report the final summary (missions, merges, review rounds, findings and their disposition). Do not wait for the human to ask: branches and `.tower/comms/` (including the activity log) are kept and dirty worktrees are protected by the tool — only disk is freed. A `/tower teardown` from the human is the same instruction at any earlier point.
6. **Teardown promptly** — when `TowerStatus` shows every mission ✅ merged and no unactioned inbox items remain, call `TowerTeardown` **right away** and report the final summary (missions, merges, review rounds, findings and their disposition). Do not wait for the human to ask: branches and `.tower/comms/` (including the activity log) are kept and dirty worktrees are protected by the tool — only disk is freed. Teardown does **not** exit tower mode — you remain the tower, ready to `TowerInit` the next objective, until the human turns the mode off with `/tower off`. A `/tower teardown` from the human is the same instruction at any earlier point.

## Hard rules for the tower

Expand Down
55 changes: 55 additions & 0 deletions packages/agent-core-v2/src/features/tower/protocol/baseWip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { git } from './git';
import { TOWER_ROOT } from './paths';

export interface BaseDirtyEntry {
readonly path: string;
readonly unmerged: boolean;
}

const UNMERGED_CODES = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);
const ADD_PATHS_CHUNK = 100;

export async function listBaseDirtyEntries(cwd: string): Promise<readonly BaseDirtyEntry[]> {
const out = await git(cwd, ['status', '--porcelain', '-z', '--no-renames', '--untracked-files=normal']);
const entries: BaseDirtyEntry[] = [];
for (const record of out.split('\0')) {
if (record.length < 4) continue;
const code = record.slice(0, 2);
const raw = record.slice(3).replace(/\/+$/, '');
if (raw.length === 0 || raw.split('/').includes(TOWER_ROOT)) continue;
entries.push({ path: raw, unmerged: UNMERGED_CODES.has(code) });
}
return entries;
}

export async function snapshotBaseWip(
cwd: string,
base: string,
paths: readonly string[],
message: string,
): Promise<string | null> {
if (paths.length === 0) return null;
const topLevel = await git(cwd, ['rev-parse', '--show-toplevel']);
const baseTip = await git(topLevel, ['rev-parse', base]);
const indexDir = await mkdtemp(join(tmpdir(), 'tower-wip-index-'));
const env = {
GIT_INDEX_FILE: join(indexDir, 'index'),
GIT_LITERAL_PATHSPECS: '1',
};
try {
await git(topLevel, ['read-tree', baseTip], { env });
for (let i = 0; i < paths.length; i += ADD_PATHS_CHUNK) {
await git(topLevel, ['add', '-A', '--', ...paths.slice(i, i + ADD_PATHS_CHUNK)], { env });
}
const tree = await git(topLevel, ['write-tree'], { env });
const baseTree = await git(topLevel, ['rev-parse', `${baseTip}^{tree}`]);
if (tree === baseTree) return null;
return await git(topLevel, ['commit-tree', tree, '-p', baseTip, '-m', message], { env });
} finally {
await rm(indexDir, { recursive: true, force: true });
}
}
21 changes: 19 additions & 2 deletions packages/agent-core-v2/src/features/tower/protocol/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,25 @@ export class GitError extends Error {
}
}

export async function git(cwd: string, args: readonly string[]): Promise<string> {
export interface GitOptions {
readonly env?: Readonly<Record<string, string>>;
}

export async function git(
cwd: string,
args: readonly string[],
options: GitOptions = {},
): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
'git',
[...args],
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 },
{
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: 16 * 1024 * 1024,
env: options.env === undefined ? process.env : { ...process.env, ...options.env },
},
(error, stdout, stderr) => {
if (error !== null) {
reject(new GitError(args, stderr || error.message));
Expand Down Expand Up @@ -61,6 +74,10 @@ export async function branchExists(cwd: string, branch: string): Promise<boolean
);
}

export async function isAncestor(cwd: string, ancestor: string, ref: string): Promise<boolean> {
return (await tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, ref])) !== null;
}

export async function worktreeAdd(
cwd: string,
path: string,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './baseWip';
export * from './frontmatter';
export * from './git';
export * from './paths';
Expand Down
91 changes: 82 additions & 9 deletions packages/agent-core-v2/src/features/tower/protocol/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { dirname, join } from 'node:path';

import picomatch from 'picomatch';

import { listBaseDirtyEntries, snapshotBaseWip } from './baseWip';
import { parseFrontmatter, renderFrontmatter } from './frontmatter';
import {
branchExists,
branchTip,
currentBranch,
diffNameOnly,
hasAnyCommit,
isAncestor,
isInsideRepo,
isWorktreeDirty,
mergeNoFf,
Expand Down Expand Up @@ -110,6 +112,12 @@ export interface TowerMissionPatch {
readonly taskDone?: string;
readonly owner?: string;
readonly scope?: readonly string[];
readonly spawnBase?: string;
}

export interface TowerAddWorktreeResult {
readonly rel: string;
readonly spawnBase?: string;
}

const FINDING_TYPES: readonly TowerFindingType[] = ['bug', 'improve', 'vuln', 'idea'];
Expand Down Expand Up @@ -428,9 +436,19 @@ export class TowerStore {
patch.clearBlockers === undefined &&
patch.taskDone === undefined &&
patch.owner === undefined &&
patch.scope === undefined;
patch.scope === undefined &&
patch.spawnBase === undefined;
if (isNoOp) return mission;

if (patch.spawnBase !== undefined) {
if (callerName !== TOWER_NAME) {
throw new TowerProtocolError(
`agent "${callerName}" cannot record a mission spawn base — only the tower does`,
);
}
mission.spawnBase = patch.spawnBase;
}

if (patch.owner !== undefined) {
if (callerName !== TOWER_NAME) {
throw new TowerProtocolError(
Expand Down Expand Up @@ -485,7 +503,8 @@ export class TowerStore {
patch.blocker === undefined &&
patch.clearBlockers === undefined &&
patch.owner === undefined &&
patch.scope === undefined;
patch.scope === undefined &&
patch.spawnBase === undefined;
if (!taskTickOnly && options.silent !== true) {
await this.appendLog(callerName, 'mission.update', {
id,
Expand All @@ -494,6 +513,7 @@ export class TowerStore {
blocker: patch.blocker !== undefined ? 'added' : undefined,
owner: patch.owner,
scope: patch.scope?.join(','),
spawn_base: patch.spawnBase,
});
}
return mission;
Expand Down Expand Up @@ -757,7 +777,7 @@ export class TowerStore {
}

if (mission.kind === 'survey') {
const changed = await diffNameOnly(this.repoRoot, state.base, branch);
const changed = await diffNameOnly(this.repoRoot, await this.diffBase(state, mission), branch);
if (changed.length > 0) {
throw await block(
'read-only-survey',
Expand Down Expand Up @@ -794,7 +814,7 @@ export class TowerStore {
);
}

const changed = await diffNameOnly(this.repoRoot, state.base, branch);
const changed = await diffNameOnly(this.repoRoot, await this.diffBase(state, mission), branch);
const outOfScope = changed.filter(
(file) => !mission.scope.some((glob) => picomatch.isMatch(file, glob)),
);
Expand All @@ -821,6 +841,18 @@ export class TowerStore {
);
}

const touched = await diffNameOnly(this.repoRoot, 'HEAD', branch);
if (touched.length > 0) {
const dirty = new Set((await listBaseDirtyEntries(this.repoRoot)).map((entry) => entry.path));
const blocked = touched.filter((file) => dirty.has(file));
if (blocked.length > 0) {
throw await block(
'base-dirty',
`merge blocked: the main checkout has uncommitted changes in file(s) this merge would overwrite: ${blocked.slice(0, 5).join(', ')} — commit or stash them first, then retry; nothing was merged`,
);
}
}

const mergeCommit = await mergeNoFf(this.repoRoot, branch);
mission.status = 'merged';

Expand All @@ -829,7 +861,7 @@ export class TowerStore {
for (const other of state.missions) {
if (other.branch === branch || !isOpenMission(other)) continue;
if (!(await branchExists(this.repoRoot, other.branch))) continue;
const otherChanged = await diffNameOnly(this.repoRoot, state.base, other.branch);
const otherChanged = await diffNameOnly(this.repoRoot, await this.diffBase(state, other), other.branch);
const overlap = otherChanged.filter((file) => changedSet.has(file));
if (overlap.length > 0) {
conflictsWith.push({ branch: other.branch, files: overlap });
Expand All @@ -843,11 +875,52 @@ export class TowerStore {
return { mergeCommit, conflictsWith };
}

async addWorktree(worktree: string, branch: string, base: string): Promise<string> {
async diffBase(state: TowerState, mission: TowerMission): Promise<string> {
if (
mission.spawnBase !== undefined &&
(await isAncestor(this.repoRoot, mission.spawnBase, mission.branch))
) {
return mission.spawnBase;
}
return state.base;
}

async addWorktree(worktree: string, branch: string, base: string): Promise<TowerAddWorktreeResult> {
const rel = join(WORKTREES_DIR, worktree);
await worktreeAdd(this.repoRoot, this.abs(rel), branch, base);
await this.appendLog(TOWER_NAME, 'worktree.add', { worktree, branch, base });
return rel;
let spawnBase: string | undefined;
if (!(await branchExists(this.repoRoot, branch))) {
const dirty = await listBaseDirtyEntries(this.repoRoot);
if (dirty.some((entry) => entry.unmerged)) {
throw new TowerProtocolError(
'the base checkout has unmerged paths (an in-progress merge, rebase, or cherry-pick) — finish or abort it before spawning workers',
);
}
if (dirty.length > 0) {
let checkout: string;
try {
checkout = await currentBranch(this.repoRoot);
} catch {
throw new TowerProtocolError(
`the main checkout is in a detached HEAD state with uncommitted changes, and the recorded base is "${base}" — a WIP snapshot would carry detached-HEAD content into the mission branch; check out "${base}" (\`git checkout ${base}\`) or commit/stash the changes before spawning workers`,
);
}
if (checkout !== base) {
throw new TowerProtocolError(
`the main checkout is on "${checkout}" with uncommitted changes, not the recorded base "${base}" — a WIP snapshot would carry "${checkout}" content into the mission branch; switch back to "${base}" (\`git checkout ${base}\`) or commit/stash the changes before spawning workers`,
);
}
}
spawnBase =
(await snapshotBaseWip(
this.repoRoot,
base,
dirty.map((entry) => entry.path),
Comment thread
tpoisonooo marked this conversation as resolved.
`tower: snapshot of uncommitted base checkout changes (worktree ${worktree})`,
)) ?? undefined;
}
await worktreeAdd(this.repoRoot, this.abs(rel), branch, spawnBase ?? base);
await this.appendLog(TOWER_NAME, 'worktree.add', { worktree, branch, base, spawn_base: spawnBase });
return { rel, spawnBase };
}

async teardown(options: { readonly force?: boolean } = {}): Promise<readonly string[]> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface TowerMission {
scope: string[];
readonly branch: string;
readonly worktree: string;
spawnBase?: string;
readonly deps: readonly string[];
status: TowerMissionStatus;
owner?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Merge a tower mission branch into the base branch (--no-ff).

Hard gate, enforced by the store — the merge is refused unless: the branch's latest review is "clean" and was written against the current branch tip, all dependency missions are already merged, and every changed file falls inside the mission's declared scope. On refusal, the error message tells you exactly what to do next (assign a reviewer, wait for fixes, re-review a moved tip, merge deps first, widen the scope or revert the extra changes). After a merge, branches reported as conflicting must rebase onto the new base and be re-reviewed before they can merge.
Hard gate, enforced by the store — the merge is refused unless: the branch's latest review is "clean" and was written against the current branch tip, all dependency missions are already merged, and every changed file falls inside the mission's declared scope. The scope diff starts from the mission's recorded spawn base while that snapshot commit is still part of the branch's history, so base-checkout WIP captured as a snapshot commit at spawn time is never mistaken for a worker scope violation; once a rebase drops the snapshot (typically because the WIP has since been committed on the base branch), the diff falls back to the base branch. On refusal, the error message tells you exactly what to do next (assign a reviewer, wait for fixes, re-review a moved tip, merge deps first, widen the scope or revert the extra changes). After a merge, branches reported as conflicting must rebase onto the new base and be re-reviewed before they can merge.

The main checkout must be clean for the files the merge touches: if it still has uncommitted changes in any file the merge would overwrite, the merge is refused and nothing is merged — commit or stash those changes first, then retry. This matters when a mission branch carries a snapshot of the checkout's WIP: that WIP merges into the base history, so the checkout must not still hold the same changes uncommitted.
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ Spawn a tower worker or reviewer as a background subagent and register it in the

Workers: pass mission_id — the tool creates the mission worktree, marks the mission active with this worker as owner, and briefs the agent with the full mission text. Reviewers: pass review_target — the agent gets a review checklist and must submit its verdict via TowerReview.

If the base checkout has uncommitted changes (staged, unstaged, or untracked) when a worker spawns, the tool captures them as a snapshot commit that becomes the mission branch's first commit — the worker starts from HEAD + that WIP instead of plain HEAD. The checkout itself is never touched (nothing is committed, staged, or stashed there), and the merge gate later diffs the branch from that snapshot while it remains part of the branch's history (falling back to the base branch once a rebase drops the snapshot commit), so the WIP is never mistaken for the worker's own scope. Snapshotting requires the main checkout to be on the recorded base branch: WIP sitting on a different branch (or a detached HEAD) belongs to that line of work, so the spawn is refused rather than mixing that content into the base — switch back to the base or commit/stash first. The snapshot only happens when the branch is first created; re-adding an existing branch reuses it as-is.

The briefing prompt is assembled by this tool (worktree path, scope, protocol rules); use instructions only for extra context. If the name is already registered, resume the existing agent with the Agent tool instead of spawning a duplicate.
Loading
Loading