Skip to content
Draft
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/session-occupancy-lock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

When the same session is open in multiple clients, sending from a second one now shows an occupancy prompt with retry/cancel, or reloads updates made elsewhere first.
166 changes: 130 additions & 36 deletions apps/kimi-code/src/cli/v2/run-v2-print.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,23 @@ import {
IEventDispatcher,
IHostFileSystem,
IOAuthToolkit,
ISessionContext,
ISessionIndex,
ISessionLockService,
ISessionManager,
ITelemetryService,
IWorkspaceInstanceManager,
IAppendLogStore,
PRINT_MAX_TURNS_DEFAULT,
PRINT_WAIT_CEILING_S_DEFAULT,
applyPrintModeConfigDefaults,
beginSessionRun,
bootstrap,
closeSessionById,
createCloudAppender,
ensureMainAgent,
ErrorCodes,
isError2,
resumeSessionById,
logSeed,
parseAgentFileText,
Expand All @@ -50,6 +57,7 @@ import {
resolveLoggingConfig,
resolvePrintBackgroundMode,
setClampedTimeout,
type Error2,
type Event2,
type IAgentScopeHandle,
type ISessionScopeHandle,
Expand Down Expand Up @@ -286,7 +294,7 @@ export async function runV2Print(
// Best-effort: a broken mcp.json or trust store must not fail the run.
}

const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
let resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
restorePermission = resolved.restorePermission;
quiesceAgents = async () => {
releaseQuiescence = await quiesceSessionAgents(resolved.session, resolved.agent);
Expand All @@ -302,27 +310,69 @@ export async function runV2Print(
}

const goalCreate = parseHeadlessGoalCreate(opts.prompt!);
if (goalCreate !== undefined) {
await runNativeGoal(
app,
resolved.session,
resolved.agent,
goalCreate,
resolved.goalModel,
outputFormat,
stdout,
stderr,
);
} else {
await runNativeTurn(
app,
resolved.session,
resolved.agent,
opts.prompt!,
outputFormat,
stdout,
stderr,
const reloadAfterStale = async (): Promise<void> => {
stderr.write(
`Session "${resolved.session.id}" was updated by another client; reloading.\n`,
);
await closeSessionById(app.accessor, resolved.session.id);
const reloaded = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
resolved = reloaded;
restorePermission = reloaded.restorePermission;
Comment on lines +317 to +320

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the permission mode across stale print reloads

When a resumed print-mode session hits session.stale, resolveNativeSession has already changed its permission mode to auto and saved a closure that restores the previous mode. This closes the old scope without invoking that closure, then replaces restorePermission with the reloaded scope's closure; because closing drains the old wire, the reload observes auto as its previous mode. A one-off headless run can therefore silently leave future interactive clients in auto mode. Restore the original override before closing, or perform stale preflight before applying the temporary permission change.

AGENTS.md reference: AGENTS.md:L122-L124

Useful? React with 👍 / 👎.

quiesceAgents = async () => {
releaseQuiescence = await quiesceSessionAgents(reloaded.session, reloaded.agent);
};
flushWires = () => flushSessionWires(reloaded.session, reloaded.agent);
};
const conflictOf = (error: unknown): 'occupied' | 'stale' | undefined => {
if (!isError2(error)) return undefined;
if (error.code === ErrorCodes.SESSION_OCCUPIED) return 'occupied';
if (error.code === ErrorCodes.SESSION_STALE) return 'stale';
return undefined;
};
const runTurnOnce = async (): Promise<void> => {
if (goalCreate !== undefined) {
await ensureNativeGoal(resolved.agent, goalCreate, resolved.goalModel);
await runNativeGoalTurn(
app,
resolved.session,
resolved.agent,
goalCreate.objective,
outputFormat,
stdout,
stderr,
);
} else {
await runNativeTurn(app, resolved.session, resolved.agent, opts.prompt!, outputFormat, stdout, stderr);
}
};
try {
preflightSessionRun(app, resolved.session);
} catch (error) {
if (conflictOf(error) === 'occupied') {
reportSessionOccupied(error as Error2, outputFormat, stdout, stderr);
return;
}
if (conflictOf(error) === undefined) throw error;
await reloadAfterStale();
}
try {
await runTurnOnce();
} catch (error) {
if (conflictOf(error) === 'occupied') {
reportSessionOccupied(error as Error2, outputFormat, stdout, stderr);
return;
}
if (conflictOf(error) === undefined) throw error;
await reloadAfterStale();
try {
await runTurnOnce();
} catch (retryError) {
if (conflictOf(retryError) === 'occupied') {
reportSessionOccupied(retryError as Error2, outputFormat, stdout, stderr);
return;
}
throw retryError;
}
}
writeResumeHint(resolved.session.id, outputFormat, stdout, stderr);

Expand Down Expand Up @@ -637,22 +687,60 @@ async function runNativeTurn(
}
}

async function runNativeGoal(
app: Scope,
session: ISessionScopeHandle,
agent: IAgentScopeHandle,
goal: HeadlessGoalCreate,
model: string | undefined,
const PRINT_EXIT_SESSION_OCCUPIED = 4;

function reportSessionOccupied(
error: Error2,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): void {
const message = error.message;
if (outputFormat === 'stream-json') {
stdout.write(
`${JSON.stringify({ type: 'error', code: 'session.occupied', message, details: error.details })}\n`,
);
} else {
stderr.write(`${message}\n`);
}
process.exitCode = PRINT_EXIT_SESSION_OCCUPIED;
}

function preflightSessionRun(app: Scope, session: ISessionScopeHandle): void {
const ctx = session.accessor.get(ISessionContext);
beginSessionRun(
app.accessor.get(ISessionLockService),
app.accessor.get(IAppendLogStore),
ctx,
session.accessor.get(IAgentLifecycleService),
);
}

async function ensureNativeGoal(
agent: IAgentScopeHandle,
goal: HeadlessGoalCreate,
model: string | undefined,
): Promise<void> {
requireConfiguredModel(model);
const goalService = agent.accessor.get(IAgentGoalService);
const existing = goalService.getGoal().goal;
if (existing?.status === 'active' && existing.objective === goal.objective) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Limit goal deduplication to stale retries

For a resumed print session that already has an active goal X, this unconditional same-objective return changes both existing commands: /goal X used to fail with goal.already_exists, while /goal replace X used to clear the old goal and create a new goal ID with reset accounting; both now run another turn against the existing goal. Restrict this idempotence check to the post-stale retry that created that exact goal rather than applying it on the initial request.

AGENTS.md reference: AGENTS.md:L102-L104

Useful? React with 👍 / 👎.

await goalService.createGoal({
objective: goal.objective,
replace: goal.replace,
});
}

async function runNativeGoalTurn(
app: Scope,
session: ISessionScopeHandle,
agent: IAgentScopeHandle,
objective: string,
outputFormat: PromptOutputFormat,
stdout: PromptOutput,
stderr: PromptOutput,
): Promise<void> {
const goalService = agent.accessor.get(IAgentGoalService);
let completedSnapshot: { readonly status: string } | null = null;
const subscription = agent.accessor.get(IEventBus).subscribe((event: Event2<any>) => {
if (event.type === 'goal.updated') {
Expand All @@ -662,18 +750,24 @@ async function runNativeGoal(
}
}
});
let turnError: unknown;
try {
await runNativeTurn(app, session, agent, goal.objective, outputFormat, stdout, stderr);
await runNativeTurn(app, session, agent, objective, outputFormat, stdout, stderr);
} catch (error) {
turnError = error;
throw error;
} finally {
subscription.dispose();
const snapshot = completedSnapshot ?? goalService.getGoal().goal;
if (outputFormat === 'stream-json') {
stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`);
} else {
stderr.write(`${formatGoalSummaryText(snapshot)}\n`);
}
if (snapshot !== null && snapshot.status !== 'complete') {
process.exitCode = goalExitCode(snapshot.status);
if (!isError2(turnError) || turnError.code !== ErrorCodes.SESSION_STALE) {
const snapshot = completedSnapshot ?? goalService.getGoal().goal;
if (outputFormat === 'stream-json') {
stdout.write(`${JSON.stringify(goalSummaryJson(snapshot))}\n`);
} else {
stderr.write(`${formatGoalSummaryText(snapshot)}\n`);
}
if (snapshot !== null && snapshot.status !== 'complete') {
process.exitCode = goalExitCode(snapshot.status);
}
}
}
}
Expand Down
23 changes: 22 additions & 1 deletion apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ import type { ThemeName } from '#/tui/theme';
import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme';
import { NO_ACTIVE_SESSION_MESSAGE, TUI_MODE_RESTART_NOTICE } from '../constant/kimi-tui';
import { formatErrorMessage } from '../utils/event-payload';
import {
isSessionOccupiedError,
isSessionStaleError,
reloadSessionAfterStale,
} from '../utils/session-lock-errors';
import { setMarkdownMermaidMode, type MermaidRenderMode } from '../utils/markdown-options';
import {
effectiveModelForHost,
Expand Down Expand Up @@ -124,7 +129,23 @@ export async function handleCompactCommand(host: SlashCommandHost, args: string)
return;
}
const customInstruction = args.trim() || undefined;
await session.compact({ instruction: customInstruction });
try {
await session.compact({ instruction: customInstruction });
} catch (error) {
if (isSessionOccupiedError(error)) {
host.showError(formatErrorMessage(error));
return;
}
if (isSessionStaleError(error)) {
await reloadSessionAfterStale(
host,
session.id,
'Loaded updates from another client. Review and run /compact again.',
);
return;
}
throw error;
}
}

export async function handleEditorCommand(host: SlashCommandHost, args: string): Promise<void> {
Expand Down
17 changes: 17 additions & 0 deletions apps/kimi-code/src/tui/commands/undo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import { UserMessageComponent } from '../components/messages/user-message';
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import type { TranscriptEntry } from '../types';
import { formatErrorMessage } from '../utils/event-payload';
import {
isSessionOccupiedError,
isSessionStaleError,
reloadSessionAfterStale,
} from '../utils/session-lock-errors';
import { getTranscriptComponentEntry } from '../utils/transcript-component-metadata';
import { nextTranscriptId } from '../utils/transcript-id';
import type { SlashCommandHost } from './dispatch';
Expand Down Expand Up @@ -102,6 +107,18 @@ async function undoByCount(host: SlashCommandHost, count: number): Promise<boole
showUndoLimitStatus(host, formatUndoLimitMessage(limit.requestedCount, limit));
return false;
}
if (isSessionOccupiedError(error)) {
host.showError(formatErrorMessage(error));
return false;
}
if (isSessionStaleError(error)) {
await reloadSessionAfterStale(
host,
session.id,
'Loaded updates from another client. Review and run /undo again.',
);
return false;
}
const message = formatErrorMessage(error);
host.showError(`Failed to undo: ${message}`);
return false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {
Container,
matchesKey,
Key,
truncateToWidth,
visibleWidth,
type Focusable,
} from '@moonshot-ai/pi-tui';
import { SELECT_POINTER } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import { SearchableList } from '#/tui/utils/searchable-list';

export type SessionOccupiedAction = 'retry' | 'cancel';

interface SessionOccupiedOption {
readonly value: SessionOccupiedAction;
readonly label: string;
readonly description?: string;
}

const OPTIONS: readonly SessionOccupiedOption[] = [
{ value: 'retry', label: 'Retry', description: 'send again now' },
{ value: 'cancel', label: 'Cancel', description: 'get your message back' },
];

export interface SessionOccupiedDialogOptions {
readonly message: string;
readonly onSelect: (action: SessionOccupiedAction) => void;
readonly onCancel: () => void;
}

export class SessionOccupiedDialogComponent extends Container implements Focusable {
focused = false;
private readonly opts: SessionOccupiedDialogOptions;
private readonly list: SearchableList<SessionOccupiedOption>;

constructor(opts: SessionOccupiedDialogOptions) {
super();
this.opts = opts;
this.list = new SearchableList({
items: OPTIONS,
toSearchText: (o) => o.label,
initialIndex: 0,
searchable: false,
});
}

handleInput(data: string): void {
if (matchesKey(data, Key.escape)) {
this.opts.onCancel();
return;
}
if (matchesKey(data, Key.enter)) {
const chosen = this.list.selected();
if (chosen !== undefined) this.opts.onSelect(chosen.value);
return;
}
this.list.handleKey(data);
}

override render(width: number): string[] {
const view = this.list.view();
const lines: string[] = [
currentTheme.fg('primary', '─'.repeat(width)),
currentTheme.boldFg('primary', ` ${this.opts.message}`),
currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc cancel'),
'',
];

const maxLabelWidth = Math.max(...OPTIONS.map((o) => visibleWidth(o.label)));
for (let i = view.page.start; i < view.page.end; i++) {
const opt = view.items[i]!;
const isSelected = i === view.selectedIndex;
const pointer = isSelected ? SELECT_POINTER : ' ';
let line = currentTheme.fg(isSelected ? 'primary' : 'textDim', ` ${pointer} `);
line += isSelected
? currentTheme.boldFg('primary', opt.label)
: currentTheme.fg('text', opt.label);
if (opt.description !== undefined) {
const gap = maxLabelWidth - visibleWidth(opt.label) + 4;
line += ' '.repeat(gap) + currentTheme.fg('textMuted', opt.description);
}
lines.push(line);
}

lines.push('');
lines.push(currentTheme.fg('primary', '─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width));
}
}
Loading
Loading