Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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/lazy-global-search-startup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix several seconds of startup lag: the global search index (used only by the web UI's search) was being opened and synced in every terminal session, including ones that never search. It now loads on demand, so interactive startup stays fast.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个是在修复启动的时候,要十几秒卡住的 bug。如果 master 已修,可以删掉。

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

目前 master 还没修掉

5 changes: 5 additions & 0 deletions .changeset/queue-skill-commands-while-busy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Queue slash skill commands entered while the agent is busy instead of rejecting them with "Cannot /<cmd> while streaming". Skills that declare `allow-activation-while-busy` in frontmatter (such as /tower) still take effect immediately at the next step boundary.
5 changes: 5 additions & 0 deletions .changeset/tower-slash-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add the /tower slash command to orchestrate multiple agents iterating on one repo in parallel — you act as the control tower while worker agents execute missions in their own git worktrees. Run /tower to start.
11 changes: 3 additions & 8 deletions apps/kimi-code/src/tui/commands/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,9 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla

const skillName = resolveSkillCommand(options.skillCommandMap, parsed.name);
if (skillName !== undefined) {
const busyReason = slashCommandBusyReason(options);
if (busyReason !== undefined) {
return {
kind: 'blocked',
commandName: parsed.name,
reason: busyReason,
};
}
// Skill activations are never blocked by a busy session: the TUI queues

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

未声明 busy 的 skill 会从"被拒"变成"延迟自动执行",看能否接受。

// them behind the running turn exactly like normal messages (see
// sendSkillActivation), so commands like /tower can be issued any time.
return {
kind: 'skill',
commandName: parsed.name,
Expand Down
7 changes: 4 additions & 3 deletions apps/kimi-code/src/tui/components/panes/queue-pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ export class QueuePaneComponent extends Container {
this.messages = options.messages;

if (options.messages.length > 0) {
// Bash commands (`! …`) are not steerable, so only advertise Ctrl-S when
// there is at least one plain-text item that steering would actually send.
const hasSteerable = options.messages.some((m) => m.mode !== 'bash');
// Bash commands (`! …`) and slash-skill activations are not steerable, so
// only advertise Ctrl-S when there is at least one plain-text item that
// steering would actually send.
const hasSteerable = options.messages.some((m) => m.mode !== 'bash' && m.mode !== 'skill');
const canSteer = options.canSteerImmediately && hasSteerable;
this.hint =
options.isCompacting && !options.isStreaming
Expand Down
13 changes: 8 additions & 5 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,10 +270,11 @@ export class EditorKeyboardController {
const text = editor.getText().trim();
const editorIsBash = editor.inputMode === 'bash';

// Bash commands (`! …`) are not steerable: keep them queued so they run
// after the current task instead of being injected into the turn as text.
// Bash commands (`! …`) and slash-skill activations are not steerable:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/tower 和 ctrl+s 冲突,因此需要改。

// keep them queued so they run/activate after the current task instead
// of being injected into the turn as plain text.
const queued = host.state.queuedMessages;
const steerable = queued.filter((m) => m.mode !== 'bash');
const steerable = queued.filter((m) => m.mode !== 'bash' && m.mode !== 'skill');

const items: SteerInputItem[] = [];
for (const m of steerable) {
Expand Down Expand Up @@ -314,7 +315,7 @@ export class EditorKeyboardController {
) {
return;
}
host.state.queuedMessages = queued.filter((m) => m.mode === 'bash');
host.state.queuedMessages = queued.filter((m) => m.mode === 'bash' || m.mode === 'skill');
if (!editorIsBash) editor.setText('');
const session = host.session;
if (host.state.appState.model.trim().length === 0 || session === undefined) {
Expand Down Expand Up @@ -354,7 +355,9 @@ export class EditorKeyboardController {
editor.setText(recalled.text);
// Restore the queued item's mode so a recalled `!` command runs as a
// shell command again instead of being submitted as a normal prompt.
const mode = recalled.mode ?? 'prompt';
// Skill activations recall as prompt mode: their text is the original
// `/name args` slash command, which re-parses on submit.
const mode = recalled.mode === 'bash' ? 'bash' : 'prompt';
if (editor.inputMode !== mode) {
editor.inputMode = mode;
editor.onInputModeChange?.(mode);
Expand Down
53 changes: 53 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ export class KimiTUI {
private readonly reverseRpcDisposers: Array<() => void> = [];
private skillCommands: readonly KimiSlashCommand[] = [];
readonly skillCommandMap = new Map<string, string>();
/** Skills whose frontmatter opts into steering a slash activation into a running turn. */
private readonly skillsActivatableWhileBusy = new Set<string>();
private pluginCommands: readonly KimiSlashCommand[] = [];
readonly pluginCommandMap = new Map<string, string>();
private readonly imageStore = new ImageAttachmentStore();
Expand Down Expand Up @@ -531,6 +533,7 @@ export class KimiTUI {
}
this.skillCommands = [];
this.skillCommandMap.clear();
this.skillsActivatableWhileBusy.clear();
this.setupAutocomplete();
return;
}
Expand All @@ -551,6 +554,12 @@ export class KimiTUI {
for (const [commandName, skillName] of skillCommands.commandMap) {
this.skillCommandMap.set(commandName, skillName);
}
this.skillsActivatableWhileBusy.clear();
for (const skill of skills) {
if (skill.allowActivationWhileBusy === true) {
this.skillsActivatableWhileBusy.add(skill.name);
}
}
this.setupAutocomplete();
}

Expand Down Expand Up @@ -1481,6 +1490,12 @@ export class KimiTUI {
void this.runShellCommandFromInput(item.text);
return;
}
if (item.mode === 'skill' && item.skillName !== undefined) {
// sendSkillActivation re-checks the busy state, so a premature drain
// re-queues at the tail instead of racing the running turn.
this.sendSkillActivation(session, item.skillName, item.skillArgs ?? '');
return;
}
this.harness.withInteractiveAgent(item.agentId ?? MAIN_AGENT_ID, () => {
this.sendMessageInternal(session, item.text, {
parts: item.parts,
Expand Down Expand Up @@ -1547,6 +1562,44 @@ export class KimiTUI {
return;
}
if (!this.validateMediaCapabilities(rewrite)) return;
// Compacting (or deferred input): queue behind it — visible and recallable.
// Slash-skill items are not Ctrl-S steerable (steering would inject the
// literal text, not an activation) — see editor-keyboard.ts.
// A running turn queues the activation too, unless the skill declared
// `allow-activation-while-busy` in its frontmatter: a plain skill steered
// into an in-flight turn would hijack the task at the next step boundary,
// so only coordination-style skills (e.g. /tower) may interrupt.
const turnRunning = this.state.appState.streamingPhase !== 'idle';
if (
this.deferUserMessages ||
this.state.appState.isCompacting ||
(turnRunning && !this.skillsActivatableWhileBusy.has(skillName))
) {
const args = rewrite.text.trim();
this.state.queuedMessages.push({
text: `/${skillName}${args.length > 0 ? ` ${args}` : ''}`,
agentId: this.harness.interactiveAgentId,
mode: 'skill',
skillName,
skillArgs: rewrite.text,
});
this.track('input_queue');
this.updateQueueDisplay();
this.state.ui.requestRender();
return;
}
if (turnRunning) {
// The skill opted into busy activation: fire immediately. The engine
// steers the activation into the running turn (see
// SkillManager.recordActivation), so the command takes effect at the
// next step boundary instead of waiting for the turn to end. No
// beginSessionRequest — the live pane belongs to the running turn.
void session.activateSkill(skillName, rewrite.text).catch((error: unknown) => {
const message = formatErrorMessage(error);
this.showError(`Skill "${skillName}" failed: ${message}`);
});
return;
}
this.beginSessionRequest();
void session.activateSkill(skillName, rewrite.text).catch((error: unknown) => {
const message = formatErrorMessage(error);
Expand Down
8 changes: 7 additions & 1 deletion apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,14 @@ export interface QueuedMessage {
readonly parts?: readonly PromptPart[];
readonly imageAttachmentIds?: readonly number[];
/** `bash` for a `!` shell command queued while another command is running;
* `skill` for a slash-skill activation queued while the session is busy;
* undefined (=`prompt`) for a normal message. */
readonly mode?: 'prompt' | 'bash';
readonly mode?: 'prompt' | 'bash' | 'skill';
/** Set when mode === 'skill': the skill to activate when the item drains.
* `text` then holds the display/recall string (`/name args`). */
readonly skillName?: string;
/** Set when mode === 'skill': the raw (media-rewritten) args to activate with. */
readonly skillArgs?: string;
}

/**
Expand Down
14 changes: 8 additions & 6 deletions apps/kimi-code/test/tui/commands/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ describe('resolveSlashCommandInput', () => {
});
});

it('resolves skill commands and blocks them while busy', () => {
it('resolves skill commands and keeps them resolvable while busy (queued downstream)', () => {
const skillCommandMap = new Map([['skill:review', 'review']]);

expect(resolve('/skill:review src/app.ts', { skillCommandMap })).toEqual({
Expand All @@ -205,13 +205,14 @@ describe('resolveSlashCommandInput', () => {
args: 'src/app.ts',
});
expect(resolve('/skill:review src/app.ts', { skillCommandMap, isStreaming: true })).toEqual({
kind: 'blocked',
kind: 'skill',
commandName: 'skill:review',
reason: 'streaming',
skillName: 'review',
args: 'src/app.ts',
});
});

it('resolves unprefixed built-in skill commands and blocks them while busy', () => {
it('resolves unprefixed built-in skill commands and keeps them resolvable while busy', () => {
const skillCommandMap = new Map([['mcp-config', 'mcp-config']]);

expect(resolve('/mcp-config', { skillCommandMap })).toEqual({
Expand All @@ -221,9 +222,10 @@ describe('resolveSlashCommandInput', () => {
args: '',
});
expect(resolve('/mcp-config', { skillCommandMap, isCompacting: true })).toEqual({
kind: 'blocked',
kind: 'skill',
commandName: 'mcp-config',
reason: 'compacting',
skillName: 'mcp-config',
args: '',
});
});

Expand Down
112 changes: 112 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2584,6 +2584,118 @@ command = "vim"
expect(harness.track).toHaveBeenCalledWith('input_queue', undefined);
});

it('fires a slash-skill activation immediately while a turn is streaming when the skill opts in (engine steers it in)', async () => {
const session = makeSession({
listSkills: vi.fn(async () => [
{
name: 'tower',
description: 'multi-agent tower mode',
path: 'builtin://tower',
source: 'builtin',
type: 'inline',
allowActivationWhileBusy: true,
},
]),
});
const { driver } = await makeDriver(session);
await (
driver as unknown as { refreshSkillCommands(s: unknown): Promise<void> }
).refreshSkillCommands(session);
driver.state.appState.streamingPhase = 'waiting';

driver.handleUserInput('/tower refactor auth and ui');

expect(session.activateSkill).toHaveBeenCalledWith('tower', 'refactor auth and ui');
expect(session.prompt).not.toHaveBeenCalled();
expect(driver.state.queuedMessages).toEqual([]);
// The live pane keeps belonging to the running turn — no fresh waiting phase.
expect(driver.state.appState.streamingPhase).toBe('waiting');
});

it('queues a slash-skill activation while a turn is streaming when the skill does not opt in', async () => {
const session = makeSession({
listSkills: vi.fn(async () => [
{
name: 'review',
description: 'review the current change',
path: 'builtin://review',
source: 'builtin',
type: 'inline',
},
]),
});
const { driver, harness } = await makeDriver(session);
await (
driver as unknown as { refreshSkillCommands(s: unknown): Promise<void> }
).refreshSkillCommands(session);
driver.state.appState.streamingPhase = 'waiting';
harness.track.mockClear();

driver.handleUserInput('/review src/auth');

expect(session.activateSkill).not.toHaveBeenCalled();
expect(driver.state.queuedMessages).toEqual([
{
text: '/review src/auth',
agentId: 'main',
mode: 'skill',
skillName: 'review',
skillArgs: 'src/auth',
},
]);
expect(harness.track).toHaveBeenCalledWith('input_queue', undefined);

// Turn ends: the drain re-enters sendSkillActivation, which now fires.
driver.state.appState.streamingPhase = 'idle';
const queued = driver.state.queuedMessages[0]!;
driver.state.queuedMessages = [];
driver.sendQueuedMessage(session, queued);

expect(session.activateSkill).toHaveBeenCalledWith('review', 'src/auth');
});

it('queues a slash-skill activation while compacting and activates it on drain', async () => {
const session = makeSession({
listSkills: vi.fn(async () => [
{
name: 'tower',
description: 'multi-agent tower mode',
path: 'builtin://tower',
source: 'builtin',
type: 'inline',
},
]),
});
const { driver, harness } = await makeDriver(session);
await (
driver as unknown as { refreshSkillCommands(s: unknown): Promise<void> }
).refreshSkillCommands(session);
driver.state.appState.isCompacting = true;
harness.track.mockClear();

driver.handleUserInput('/tower refactor auth and ui');

expect(session.activateSkill).not.toHaveBeenCalled();
expect(driver.state.queuedMessages).toEqual([
{
text: '/tower refactor auth and ui',
agentId: 'main',
mode: 'skill',
skillName: 'tower',
skillArgs: 'refactor auth and ui',
},
]);
expect(driver.state.queueContainer.children.length).toBeGreaterThan(0);
expect(harness.track).toHaveBeenCalledWith('input_queue', undefined);

driver.state.appState.isCompacting = false;
const queued = driver.state.queuedMessages[0]!;
driver.state.queuedMessages = [];
driver.sendQueuedMessage(session, queued);

expect(session.activateSkill).toHaveBeenCalledWith('tower', 'refactor auth and ui');
});

it('steers fresh input while a goal is active even when the streaming phase is idle', async () => {
const { driver, session } = await makeDriver();
driver.state.appState.goal = makeActiveGoalSnapshot();
Expand Down
25 changes: 22 additions & 3 deletions apps/vis/server/src/lib/agent-record-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
// Do NOT add local interfaces that duplicate upstream shapes.

export type {
AgentRecord,
AgentRecordEvents,
AgentRecordOf,
AgentConfigUpdateData,
CompactionBeginData,
CompactionResult,
Expand All @@ -30,7 +28,28 @@ export type { Message, ContentPart, ToolCall, TokenUsage } from '@moonshot-ai/ko
// Local bindings for the upstream types referenced by the vis-only DTOs
// below. The `export type { … }` re-export above forwards the names to
// consumers but does NOT bring them into this module's scope.
import type { AgentRecord, BackgroundTaskInfo } from '@moonshot-ai/agent-core';
import type {
AgentRecord as UpstreamAgentRecord,
BackgroundTaskInfo,
} from '@moonshot-ai/agent-core';

/**
* The wire record union vis projects, widened with the v2-engine tower-mode
* records (`tower_mode.enter` / `tower_mode.exit`, empty payloads). The
* upstream v1 union is frozen ahead of its deprecation and does not carry
* them; the local widening keeps the context projector's exhaustiveness
* check covering tower session wires.
*/
export type AgentRecord =
| UpstreamAgentRecord
| { readonly type: 'tower_mode.enter'; readonly time?: number }
| { readonly type: 'tower_mode.exit'; readonly time?: number };

/** Extract one record kind from the (locally widened) union. */
export type AgentRecordOf<K extends AgentRecord['type']> = Extract<
AgentRecord,
{ readonly type: K }
>;

/**
* Persistent representation of a cron task.
Expand Down
3 changes: 3 additions & 0 deletions apps/vis/server/src/lib/context-projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,9 @@ export function projectContext(
case 'swarm_mode.exit':
swarm = { active: false };
break;
case 'tower_mode.enter':
case 'tower_mode.exit':
break;
// Kinds that don't affect the projected timeline / derived state,
// including the observability records (request trace — `llm.*`,
// `mcp.tools_discovered`), which are never part of context state:
Expand Down
Loading
Loading