Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tower-mode-fixes.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`): fix tower mode never starting when enabled through `[experimental] tower = true` in `config.toml` instead of the environment variable. When tower mode cannot be enabled, the error now names the actual blocker — the disabled experiment, a required restart, or the owning session. When another live session owns the workspace tower, the message also names the owning session's title alongside its id. /tower now also works in a directory that is not a git repository — it runs git init and commits what is there (an empty initial commit for empty directories).
7 changes: 7 additions & 0 deletions packages/agent-core-v2/docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ compositions over the existing seams:
feature contributes at Agent scope appears in every existing and future Agent scope,
bound by the same cascade rules as a static registration.

A feature whose assembly is gated on an experimental flag decides inside its constructor
(e.g. `TowerFeature` returns early when `flags.enabled('tower')` is false). `ConfigService`
seeds its state synchronously at construction (a best-effort sync read of the config
file), so a config-sourced flag is already visible in that constructor. A flag flipped at
runtime does not re-run feature constructors: turning a flag-gated feature on or off from
config at runtime requires a restart.

## Static channels vs Feature channels (the rule for built-in features)

Some contribution kinds must stay on the **static import=register channels** even when
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-core-v2/docs/flag.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ Highest wins; env is read live on every call (nothing cached):
## Config integration

- `FlagService` registers the `[experimental]` section into `IConfigRegistry` at construction (`registerSection('experimental', ExperimentalConfigSchema)`) and reads overrides from `IConfigService`.
- It subscribes `IConfigService.onDidChangeConfiguration` and refreshes overrides whenever the `experimental` domain changes, so config edits apply live.
- It subscribes `IConfigService.onDidChangeConfiguration` and refreshes overrides whenever the `experimental` domain changes, so config edits apply live. `ConfigService` seeds its state synchronously at construction (a best-effort sync read of the config file), so overrides from `config.toml` are already visible during App-scope creation.
- Overrides are re-read on every change (`enabled()`/`explain()` are computed, not cached), so new values apply to all subsequent calls. Decisions already made from the old values are not revisited: in particular a flag-gated Feature's constructor runs once at assembly, and a runtime flip only takes effect for it after a restart.
- `IConfigRegistry.registerSection` throws if a domain is registered twice — `experimental` is owned exclusively by `FlagService`.
- `setConfigOverrides(overrides)` is an imperative escape hatch for tests and hosts without an `IConfigService`; hosts on `IConfigService` should set the `[experimental]` section instead.

Expand Down
24 changes: 24 additions & 0 deletions packages/agent-core-v2/src/app/config/configService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { readFileSync } from 'node:fs';

import { parse as parseToml } from 'smol-toml';

import { type CollectionView } from '#/_base/di/collection';
import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope } from '#/app/scopes';
Expand Down Expand Up @@ -322,6 +326,7 @@ export class ConfigService extends Disposable implements IConfigService {
this._register(this.registry.onDidRegisterOverlay(() => this.reapplyOverlays()));
const { configKey } = this;
const { homeDir } = this.bootstrap;
this.seedInitialLoad();
this.ready = (async () => {
await migrateThinkingEffortMaxToHigh(this.documentStore, configKey, homeDir);
await this.load('load');
Expand Down Expand Up @@ -519,6 +524,25 @@ export class ConfigService extends Disposable implements IConfigService {
return run;
}

private seedInitialLoad(): void {
let fileData: ResolvedConfig;
try {
const text = readFileSync(this.bootstrap.configPath, 'utf8');
const data: unknown = text.trim().length === 0 ? {} : parseToml(text);
if (!isPlainObject(data)) return;
fileData = data;
} catch {
return;
}
this.rawSnake = cloneRecord(fileData);
this.raw = transformTomlData(fileData, this.registry);
this.validated = this.buildValidated(this.raw);
const next = { ...this.validated };
this.applySectionEnvBindings(next, true);
this.applyEnvOverlay(next);
this.effective = next;
}

private async load(source: ConfigChangeSource): Promise<void> {
this.diagnosticsList.length = 0;
let fileData: ResolvedConfig = {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,7 @@ Working principles:

## Prepare (only when the directory is not a tower-ready git repo)

`TowerInit` requires a git repository with at least one commit. If `git rev-parse --is-inside-work-tree` fails:

- **Empty directory** → `git init` + `git commit --allow-empty -m "tower: init"`, then proceed. No confirmation needed.
- **Non-empty directory** → never `git add -A`: a blind initial commit can seal secrets, large binaries, or dependency directories into history irreversibly. Survey the directory (file count, largest files, secret-looking names like `.env` or `*.pem`), present the summary, and ask the human **exactly once** whether to initialize and commit the existing files — but only when asking is possible. Under auto permission mode `AskUserQuestion` is disabled: do not call it into a deny error. Default to the safe behavior instead — do NOT commit existing files; stop tower there and tell the human in your reply the two commands to run themselves (`git init` plus an initial commit of their choosing). If they agree to the commit, write a conservative `.gitignore` (dependencies, build output, secrets), show the staged list, commit, proceed.
`TowerInit` requires a git repository with at least one commit. If the session working directory is not inside one, the engine bootstraps it for you: `git init`, then an initial commit on the base branch — an empty directory gets `git commit --allow-empty -m "tower: init"`; a non-empty directory gets every present file committed as a dirty-base snapshot (`tower: snapshot of uncommitted base checkout changes (base <base>)` — the same semantics as starting a tower over an uncommitted checkout). If the directory holds secrets or large files that must not enter history, move them out or add a `.gitignore` BEFORE starting the tower — the snapshot commits everything present.

## Tower workflow

Expand Down
27 changes: 26 additions & 1 deletion packages/agent-core-v2/src/features/tower/protocol/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,35 @@ export async function branchExists(cwd: string, branch: string): Promise<boolean

const ADD_PATHS_CHUNK = 100;

export async function initRepository(cwd: string): Promise<void> {
await git(cwd, ['init']);
}

async function gitCommit(cwd: string, args: readonly string[]): Promise<void> {
try {
await git(cwd, args);
} catch (error) {
if (!(error instanceof GitError) || !/identity unknown/.test(error.stderr)) {
throw error;
}
await git(cwd, [
'-c',
'user.name=Kimi Tower',
'-c',
'user.email=kimi-tower@localhost',
...args,
]);
}
}

export async function checkoutNewLocalBranch(cwd: string, branch: string): Promise<void> {
await git(cwd, ['checkout', '-b', branch]);
}

export async function commitAllowEmpty(cwd: string, message: string): Promise<void> {
await gitCommit(cwd, ['commit', '--allow-empty', '-m', message]);
}

export async function commitPaths(
cwd: string,
paths: readonly string[],
Expand All @@ -88,7 +113,7 @@ export async function commitPaths(
for (let i = 0; i < paths.length; i += ADD_PATHS_CHUNK) {
await git(cwd, ['add', '-A', '--', ...paths.slice(i, i + ADD_PATHS_CHUNK)]);
}
await git(cwd, ['commit', '-m', message]);
await gitCommit(cwd, ['commit', '-m', message]);
}

export async function isAncestor(cwd: string, ancestor: string, ref: string): Promise<boolean> {
Expand Down
39 changes: 34 additions & 5 deletions packages/agent-core-v2/src/features/tower/protocol/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ import { parseFrontmatter, renderFrontmatter } from './frontmatter';
import {
branchExists,
branchTip,
checkoutNewLocalBranch,
commitAllowEmpty,
commitPaths,
currentBranch,
diffNameOnly,
hasAnyCommit,
initRepository,
isAncestor,
isInsideRepo,
isWorktreeDirty,
Expand Down Expand Up @@ -155,12 +159,28 @@ export class TowerStore {
}
}

async init(sessionId?: string, base?: string): Promise<TowerInitResult> {
if (!(await isInsideRepo(this.repoRoot))) {
throw new TowerProtocolError(
'tower needs a git repository (the session working directory is not inside one)',
);
async ensureRepository(base?: string): Promise<void> {
if (await isInsideRepo(this.repoRoot)) return;
await initRepository(this.repoRoot);
const unborn = (await tryGit(this.repoRoot, ['symbolic-ref', '--short', 'HEAD'])) ?? 'main';
const resolvedBase = base ?? unborn;
if (resolvedBase !== unborn) {
await checkoutNewLocalBranch(this.repoRoot, resolvedBase);
}
const dirty = await listBaseDirtyEntries(this.repoRoot);
if (dirty.length === 0) {
await commitAllowEmpty(this.repoRoot, 'tower: init');
return;
}
await commitPaths(
this.repoRoot,
dirty.map((entry) => entry.path),
`tower: snapshot of uncommitted base checkout changes (base ${resolvedBase})`,
);
}

async init(sessionId?: string, base?: string): Promise<TowerInitResult> {
await this.ensureRepository(base);
if (!(await hasAnyCommit(this.repoRoot))) {
throw new TowerProtocolError(
'the repository has no commits yet — create an initial commit first',
Expand Down Expand Up @@ -255,6 +275,15 @@ export class TowerStore {
return stale.map((agent) => agent.name);
}

async release(sessionId: string): Promise<void> {
if (!(await this.isInitialized())) return;
const state = await this.load();
if (state.sessionId !== sessionId) return;
state.sessionId = undefined;
await this.save(state);
await this.appendLog(TOWER_NAME, 'release', { session: sessionId });
}

private async ensureGitExclude(): Promise<void> {
const gitDir = (await readGitDir(this.repoRoot)) ?? join(this.repoRoot, '.git');
const excludePath = join(gitDir, 'info', 'exclude');
Expand Down
34 changes: 33 additions & 1 deletion packages/agent-core-v2/src/features/tower/tower.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,44 @@ export const TOWER_WORKER_PROFILE = 'tower-worker';

export const TOWER_FLAG_ID = 'tower';

export type TowerEnterFailure =
| {
readonly entered: false;
readonly reason: 'not-main-agent' | 'experiment-off' | 'feature-not-assembled';
}
| {
readonly entered: false;
readonly reason: 'owned-by-live-session';
readonly owner: string;
readonly ownerTitle?: string;
};

export type TowerEnterResult = { readonly entered: true } | TowerEnterFailure;

export function towerEnterFailureMessage(failure: TowerEnterFailure): string {
switch (failure.reason) {
case 'not-main-agent':
return 'tower mode is only supported by the main agent';
case 'experiment-off':
return 'the tower experiment is disabled; enable it with KIMI_CODE_EXPERIMENTAL_TOWER=1 or `[experimental] tower = true` in config.toml';
case 'feature-not-assembled':
return 'the tower feature is not assembled in this process; a restart is required';
case 'owned-by-live-session': {
const owner =
failure.ownerTitle === undefined
? failure.owner
: `${failure.ownerTitle} (${failure.owner})`;
return `another live session owns the workspace tower (session ${owner})`;
}
}
}

export interface IAgentTowerService {
readonly _serviceBrand: undefined;

readonly isActive: boolean;
readonly requestedBase: string | undefined;
enter(base?: string): Promise<void>;
enter(base?: string): Promise<TowerEnterResult>;
exit(): void;
}

Expand Down
48 changes: 40 additions & 8 deletions packages/agent-core-v2/src/features/tower/towerService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { join } from 'node:path';

import { Disposable } from '#/_base/di/lifecycle';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { ScopeActivation, registerScopedService, type ISessionScopeHandle } from '#/_base/di/scope';
import { ILogService } from '#/_base/log/log';
import { IAgentReminderService } from '#/features/reminder/reminderService';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
Expand All @@ -25,6 +26,8 @@ import { ISessionActivityView } from '#/session/sessionActivity/sessionActivity'
import { isWithinDirectory } from '#/tool/path-access';
import type { ToolFileAccess } from '#/tool/toolContract';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { isUntitled } from '#/session/sessionMetadata/promptMetadata';
import { SubagentStarted } from '#/session/subagent/mirrorAgentRun';
import { TowerModeInjection } from './injection/towerModeInjection';
import {
Expand All @@ -43,6 +46,7 @@ import {
TOWER_FLAG_ID,
TOWER_TOOL_NAMES,
TOWER_WORKER_PROFILE,
type TowerEnterResult,
} from './tower';
import { isTowerFeatureAssembled } from './towerFeature';
import { TowerModeEnter, TowerModeExit, towerBaseKey, towerKey, towerOwnerKey } from './towerOps';
Expand All @@ -67,6 +71,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService
@IAgentReminderService reminder: IAgentReminderService,
@IAgentContextMemoryService context: IAgentContextMemoryService,
@IEventBus eventBus: IEventBus,
@ILogService private readonly log: ILogService,
) {
super();
this.agentState.contributeState(towerKey);
Expand Down Expand Up @@ -186,25 +191,28 @@ export class AgentTowerService extends Disposable implements IAgentTowerService
);
}

async enter(base?: string): Promise<void> {
if (this.agentCtx.agentId !== 'main') return;
if (!this.flags.enabled(TOWER_FLAG_ID)) return;
if (!isTowerFeatureAssembled(this.flags)) return;
async enter(base?: string): Promise<TowerEnterResult> {
if (this.agentCtx.agentId !== 'main') return { entered: false, reason: 'not-main-agent' };
if (!this.flags.enabled(TOWER_FLAG_ID)) return { entered: false, reason: 'experiment-off' };
if (!isTowerFeatureAssembled(this.flags)) return { entered: false, reason: 'feature-not-assembled' };
if (base !== undefined) {
await this.prepareUserBase(base);
}
if (this.isActive) {
if (base !== undefined && base !== this.agentState.get(towerBaseKey)) {
this.dispatchEnter(base);
}
return;
return { entered: true };
}
const owner = await this.resolveTowerOwner();
if (owner !== undefined && owner !== this.sessionCtx.sessionId) {
const ownerHandle = this.sessions.get(owner);
if (ownerHandle !== undefined) {
const activity = ownerHandle.accessor.get(ISessionActivityView).state();
if (activity.busy || activity.pendingInteraction !== 'none') return;
if (activity.busy || activity.pendingInteraction !== 'none') {
const ownerTitle = await this.resolveOwnerTitle(ownerHandle);
return { entered: false, reason: 'owned-by-live-session', owner, ownerTitle };
}
ownerHandle.accessor
.get(IAgentLifecycleService)
.handleOf('main')
Expand All @@ -215,6 +223,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService
for (const name of TOWER_MODE_TOOLS) this.profile.addActiveTool(name);
this.lastPublished = true;
this.dispatchEnter(base);
return { entered: true };
}

get requestedBase(): string | undefined {
Expand All @@ -224,6 +233,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService
private async prepareUserBase(base: string): Promise<void> {
const repoRoot = resolveTowerRepoRoot(this.sessionCtx.cwd);
const store = new TowerStore(repoRoot);
await store.ensureRepository(base);
if (await store.isInitialized()) {
const state = await store.load();
if (state.base === base) {
Expand Down Expand Up @@ -289,6 +299,19 @@ export class AgentTowerService extends Disposable implements IAgentTowerService
if (!this.agentState.get(towerKey)) return;
this.lastPublished = false;
void this.dispatcher.dispatch(new TowerModeExit({ agentId: this.agentCtx.agentId }));
void this.releaseTowerOwnership();
}

private async releaseTowerOwnership(): Promise<void> {
const store = new TowerStore(resolveTowerRepoRoot(this.sessionCtx.cwd));
await store.release(this.sessionCtx.sessionId).then(
() => undefined,
(error: unknown) => {
this.log.warn(
`failed to release tower workspace ownership: ${error instanceof Error ? error.message : String(error)}`,
);
},
);
}

get isActive(): boolean {
Expand All @@ -306,7 +329,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService
const owner = await this.resolveTowerOwner();
if (owner === undefined || owner === this.sessionCtx.sessionId) return;
if (this.sessions.get(owner) === undefined) return;
void this.dispatcher.dispatch(new TowerModeExit({ agentId: this.agentCtx.agentId }));
this.exit();
}

private async resolveTowerOwner(): Promise<string | undefined> {
Expand All @@ -318,6 +341,15 @@ export class AgentTowerService extends Disposable implements IAgentTowerService
return storeOwner ?? this.agentState.get(towerOwnerKey);
}

private async resolveOwnerTitle(ownerHandle: ISessionScopeHandle): Promise<string | undefined> {
try {
const meta = await ownerHandle.accessor.get(ISessionMetadata).read();
return isUntitled(meta.title) ? undefined : meta.title;
} catch {
return undefined;
}
}

private async recordTowerAgentDeath(info: AgentTaskInfo): Promise<void> {
if (info.kind !== 'agent') return;
if (info.agentId === undefined) return;
Expand Down
Loading
Loading