Skip to content

Commit 63e96c7

Browse files
committed
fix: harden async/leak paths and complete i18n across TUI, SDK, and engine
1 parent f13ad13 commit 63e96c7

32 files changed

Lines changed: 334 additions & 93 deletions

File tree

apps/kimi-code/src/cli/options.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,13 @@ export function validateOptions(
8888
throw new OptionConflictError(t('cli.errors.cannotCombinePromptAndPlan'));
8989
}
9090
if (opts.agent !== undefined && opts.agent.trim().length === 0) {
91-
throw new OptionConflictError('Agent cannot be empty.');
91+
throw new OptionConflictError(t('cli.errors.agentEmpty'));
9292
}
9393
if (opts.agentFiles.length > 1) {
9494
throw new OptionConflictError(t('cli.errors.agentFileOnlyOnce'));
9595
}
9696
if (opts.agentFiles.some((file) => file.trim().length === 0)) {
97-
throw new OptionConflictError('Agent file path cannot be empty.');
97+
throw new OptionConflictError(t('cli.errors.agentFilePathEmpty'));
9898
}
9999
if (opts.agent !== undefined && opts.agentFiles.length > 0) {
100100
throw new OptionConflictError('Cannot combine --agent with --agent-file.');

apps/kimi-code/src/cli/run-prompt.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ import type { CLIOptions } from './options';
3535
* cleanup is still bounded by `timeoutMs`, so this can't hang the run forever.
3636
*/
3737
export async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promise<void> {
38+
// A non-positive timeout would make the timer fire immediately and silently
39+
// skip waiting entirely — reject clearly-invalid values instead.
40+
if (timeoutMs < 0) {
41+
throw new RangeError(`raceWithTimeout: timeoutMs must be >= 0, got ${timeoutMs}`);
42+
}
3843
let timedOut = false;
3944
let timer: ReturnType<typeof setTimeout> | undefined;
4045
// Attach the catch eagerly (synchronously) so `promise` is always consumed and
@@ -109,6 +114,8 @@ export function installPromptTerminationCleanup(
109114
try {
110115
await cleanup();
111116
} finally {
117+
// Exit regardless of cleanup outcome; the cleanup rejection propagates to
118+
// the (already-exiting) caller.
112119
promptProcess.exit(signalExitCode(signal));
113120
}
114121
};
@@ -129,5 +136,6 @@ export function signalExitCode(signal: NodeJS.Signals): number {
129136
if (signal === 'SIGINT') return 128 + os.constants.signals.SIGINT;
130137
if (signal === 'SIGHUP') return 128 + os.constants.signals.SIGHUP;
131138
if (signal === 'SIGTERM') return 128 + os.constants.signals.SIGTERM;
132-
return 143;
139+
// Fall back to the SIGTERM convention (128 + 15) for any unhandled signal.
140+
return 128 + (os.constants.signals.SIGTERM ?? 15);
133141
}

apps/kimi-code/src/i18n/locales/en.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@
122122
"cannotCombineContinueAndSession": "Cannot combine --continue, --session.",
123123
"cannotCombineYoloAndAuto": "Cannot combine --yolo with --auto.",
124124
"agentOnlyOnce": "--agent may only be specified once.",
125-
"agentFileOnlyOnce": "--agent-file may only be specified once."
125+
"agentFileOnlyOnce": "--agent-file may only be specified once.",
126+
"agentEmpty": "Agent cannot be empty.",
127+
"agentFilePathEmpty": "Agent file path cannot be empty."
126128
}
127129
},
128130
"startup": {
@@ -1728,6 +1730,9 @@
17281730
"bgAgentStarted": "{{subject}} started in background",
17291731
"bgAgentCompleted": "{{subject}} completed in background",
17301732
"bgAgentFailed": "{{subject}} failed in background",
1733+
"bgAgentLost": "{{subject}} lost in background",
1734+
"bgAgentKilled": "{{subject}} stopped",
1735+
"bgAgentTimedOut": "{{subject}} timed out",
17311736
"mcpStatusFailed": "{{count}} failed",
17321737
"mcpStatusNeedsAuth": "{{count}} need auth",
17331738
"mcpStatusConnecting": "{{count}} connecting",

apps/kimi-code/src/i18n/locales/en.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ export default {
139139
cannotCombineYoloAndAuto: 'Cannot combine --yolo with --auto.',
140140
agentOnlyOnce: '--agent may only be specified once.',
141141
agentFileOnlyOnce: '--agent-file may only be specified once.',
142+
agentEmpty: 'Agent cannot be empty.',
143+
agentFilePathEmpty: 'Agent file path cannot be empty.',
142144
},
143145
},
144146
startup: {
@@ -1879,6 +1881,9 @@ export default {
18791881
bgAgentStarted: '{{subject}} started in background',
18801882
bgAgentCompleted: '{{subject}} completed in background',
18811883
bgAgentFailed: '{{subject}} failed in background',
1884+
bgAgentLost: '{{subject}} lost in background',
1885+
bgAgentKilled: '{{subject}} stopped',
1886+
bgAgentTimedOut: '{{subject}} timed out',
18821887
// tui/utils/mcp-server-status.ts
18831888
mcpStatusFailed: '{{count}} failed',
18841889
mcpStatusNeedsAuth: '{{count}} need auth',

apps/kimi-code/src/i18n/locales/zh.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@
122122
"cannotCombineContinueAndSession": "--continue 不能与 --session 同时使用。",
123123
"cannotCombineYoloAndAuto": "--yolo 不能与 --auto 同时使用。",
124124
"agentOnlyOnce": "--agent 只能指定一次。",
125-
"agentFileOnlyOnce": "--agent-file 只能指定一次。"
125+
"agentFileOnlyOnce": "--agent-file 只能指定一次。",
126+
"agentEmpty": "Agent 不能为空。",
127+
"agentFilePathEmpty": "Agent 文件路径不能为空。"
126128
}
127129
},
128130
"startup": {
@@ -1728,6 +1730,9 @@
17281730
"bgAgentStarted": "{{subject}} 已在后台启动",
17291731
"bgAgentCompleted": "{{subject}} 已在后台完成",
17301732
"bgAgentFailed": "{{subject}} 在后台失败",
1733+
"bgAgentLost": "{{subject}} 在后台失联",
1734+
"bgAgentKilled": "{{subject}} 已停止",
1735+
"bgAgentTimedOut": "{{subject}} 在后台超时",
17311736
"mcpStatusFailed": "{{count}} 个失败",
17321737
"mcpStatusNeedsAuth": "{{count}} 个需要认证",
17331738
"mcpStatusConnecting": "{{count}} 个连接中",

apps/kimi-code/src/i18n/locales/zh.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ export default {
137137
cannotCombineYoloAndAuto: '--yolo 不能与 --auto 同时使用。',
138138
agentOnlyOnce: '--agent 只能指定一次。',
139139
agentFileOnlyOnce: '--agent-file 只能指定一次。',
140+
agentEmpty: 'Agent 不能为空。',
141+
agentFilePathEmpty: 'Agent 文件路径不能为空。',
140142
},
141143
},
142144
startup: {
@@ -1835,6 +1837,9 @@ export default {
18351837
bgAgentStarted: '{{subject}} 已在后台启动',
18361838
bgAgentCompleted: '{{subject}} 已在后台完成',
18371839
bgAgentFailed: '{{subject}} 在后台失败',
1840+
bgAgentLost: '{{subject}} 在后台失联',
1841+
bgAgentKilled: '{{subject}} 已停止',
1842+
bgAgentTimedOut: '{{subject}} 在后台超时',
18381843
// tui/utils/mcp-server-status.ts
18391844
mcpStatusFailed: '{{count}} 个失败',
18401845
mcpStatusNeedsAuth: '{{count}} 个需要认证',

apps/kimi-code/src/tui/controllers/editor-keyboard.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export class EditorKeyboardController {
102102
return entry.slice(1);
103103
}
104104
editor.setInputMode('prompt');
105-
return undefined;
105+
return;
106106
};
107107

108108
// Save/restore the input mode alongside pi-tui's history draft. Without
@@ -314,12 +314,15 @@ export class EditorKeyboardController {
314314
) {
315315
return;
316316
}
317-
host.state.queuedMessages = queued.filter((m) => m.mode === 'bash');
318-
if (!editorIsBash) editor.setText('');
319317
const session = host.session;
320318
if (host.state.appState.model.trim().length === 0 || session === undefined) {
321319
host.showError(getLlmNotSetMessage());
322320
} else {
321+
// Mutate the queue/editor only after the guard passes, so an
322+
// early-return here never drops the user's queued non-bash items or
323+
// the draft text.
324+
host.state.queuedMessages = queued.filter((m) => m.mode === 'bash');
325+
if (!editorIsBash) editor.setText('');
323326
host.steerMessage(session, items);
324327
}
325328
}

apps/kimi-code/src/tui/controllers/session-event-handler.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
SessionMetaUpdatedEvent,
88
TokenUsage,
99
} from '@moonshot-ai/kimi-code-sdk';
10+
import { log } from '@moonshot-ai/kimi-code-sdk';
1011

1112
/** Narrowed event shapes consumed by the private handlers below. */
1213
type StatusUpdatedEvent = Extract<Event, { type: 'agent.status.updated' }>;
@@ -747,6 +748,7 @@ export class SessionEventHandler {
747748
kind: 'assistant',
748749
renderMode: 'markdown',
749750
content: buildGoalCompletionMessage(event.snapshot),
751+
goalCompletionData: true,
750752
});
751753
state.ui.requestRender();
752754
return;
@@ -806,6 +808,15 @@ export class SessionEventHandler {
806808
}
807809
this.goalCompletionTurnEnded = false;
808810
})
811+
.catch((error) => {
812+
// An unexpected failure must not surface as an unhandled rejection;
813+
// reset the pending state so a later turn can retry the promotion.
814+
this.queuedGoalPromotionPending = false;
815+
this.goalCompletionTurnEnded = false;
816+
log.error('Failed to promote queued goal', {
817+
error: error instanceof Error ? error.message : String(error),
818+
});
819+
})
809820
.finally(() => {
810821
this.queuedGoalPromotionInFlight = false;
811822
this.scheduleQueuedGoalPromotion();

apps/kimi-code/src/tui/controllers/session-replay.ts

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type { TUIState } from '../tui-state';
1818
import type {
1919
AppState,
2020
BackgroundAgentMetadata,
21+
BackgroundAgentStatusPhase,
2122
ToolResultBlockData,
2223
TranscriptEntry,
2324
} from '../types';
@@ -513,14 +514,15 @@ export class SessionReplayRenderer {
513514
});
514515
return;
515516
case 'completion':
516-
this.host.appendTranscriptEntry(
517-
replayEntry(
517+
this.host.appendTranscriptEntry({
518+
...replayEntry(
518519
context,
519520
'assistant',
520521
buildGoalCompletionMessage(record.snapshot),
521522
'markdown',
522523
),
523-
);
524+
goalCompletionData: true,
525+
});
524526
return;
525527
case 'lifecycle': {
526528
const lifecycleChange: GoalReplayLifecycleChange = { ...change, kind: 'lifecycle' };
@@ -735,26 +737,15 @@ export class SessionReplayRenderer {
735737
? undefined
736738
: task.thinkingEffort,
737739
};
738-
let status = formatBackgroundAgentTranscript(
739-
origin.status === 'completed' ? 'completed' : 'failed',
740-
meta,
741-
);
742-
if (origin.status === 'lost') {
743-
status = {
744-
...status,
745-
headline: status.headline.replace(' failed in background', ' lost in background'),
746-
};
747-
} else if (origin.status === 'killed') {
748-
status = {
749-
...status,
750-
headline: status.headline.replace(' failed in background', ' stopped'),
751-
};
752-
} else if (origin.status === 'timed_out') {
753-
status = {
754-
...status,
755-
headline: status.headline.replace(' failed in background', ' timed out'),
756-
};
757-
}
740+
const phase: BackgroundAgentStatusPhase =
741+
origin.status === 'completed' ||
742+
origin.status === 'failed' ||
743+
origin.status === 'lost' ||
744+
origin.status === 'killed' ||
745+
origin.status === 'timed_out'
746+
? origin.status
747+
: 'failed';
748+
const status = formatBackgroundAgentTranscript(phase, meta);
758749
this.host.appendTranscriptEntry({
759750
...replayEntry(context, 'status', status.headline, 'plain'),
760751
detail: status.detail,

apps/kimi-code/src/tui/controllers/tasks-browser.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,10 @@ export class TasksBrowserController {
345345
return;
346346
}
347347
const current = state.tasksBrowser;
348-
if (current === undefined || current !== browser) return;
348+
// Re-validate after the await: a second concurrent open may have already
349+
// attached a viewer while we were fetching, and overwriting it here would
350+
// leak its poll timer and leave its UI children dangling.
351+
if (current === undefined || current !== browser || current.viewer !== undefined) return;
349352

350353
const info = this.host.backgroundTasks.get(taskId);
351354
const viewer = new TaskOutputViewer(

0 commit comments

Comments
 (0)