fix: restore interactive OpenAI REPL startup

This commit is contained in:
Kevin
2026-04-01 05:16:40 +08:00
parent 651335f682
commit 747be9c2f3
11 changed files with 61 additions and 47 deletions

View File

@@ -25,6 +25,7 @@
"@opentelemetry/sdk-trace-node": "^2.6.1",
"@opentelemetry/semantic-conventions": "^1.40.0",
"ajv": "^8.17.0",
"auto-bind": "^5.0.1",
"axios": "^1.14.0",
"bidi-js": "^1.0.3",
"chalk": "^5.4.0",
@@ -350,6 +351,8 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="],
"axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],

View File

@@ -32,6 +32,7 @@
"@opentelemetry/sdk-trace-node": "^2.6.1",
"@opentelemetry/semantic-conventions": "^1.40.0",
"ajv": "^8.17.0",
"auto-bind": "^5.0.1",
"axios": "^1.14.0",
"bidi-js": "^1.0.3",
"chalk": "^5.4.0",

View File

@@ -106,7 +106,6 @@ const result = await Bun.build({
'plist',
'cacache',
'fuse',
'auto-bind',
'code-excerpt',
'stack-utils',
]) {

View File

@@ -1,6 +1,5 @@
import React, { PureComponent, type ReactNode } from 'react';
import { updateLastInteractionTime } from '../../bootstrap/state.js';
import { logForDebugging } from '../../utils/debug.js';
import { stopCapturingEarlyInput } from '../../utils/earlyInput.js';
import { isEnvTruthy } from '../../utils/envUtils.js';
import { isMouseClicksDisabled } from '../../utils/fullscreen.js';
@@ -204,6 +203,7 @@ export default class App extends PureComponent<Props, State> {
}
}
override componentDidCatch(error: Error) {
logError(error);
this.handleExit(error);
}
handleSetRawMode = (isEnabled: boolean): void => {

View File

@@ -4,7 +4,7 @@ import noop from 'lodash-es/noop.js';
import throttle from 'lodash-es/throttle.js';
import React, { type ReactNode } from 'react';
import type { FiberRoot } from 'react-reconciler';
import { ConcurrentRoot } from 'react-reconciler/constants.js';
import { LegacyRoot } from 'react-reconciler/constants.js';
import { onExit } from 'signal-exit';
import { flushInteractionTime } from 'src/bootstrap/state.js';
import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js';
@@ -177,6 +177,19 @@ export default class Ink {
x: number;
y: number;
} | null = null;
private reportRenderError = (label: string, error: unknown): void => {
const message =
error instanceof Error ? `${error.name}: ${error.message}` : String(error);
logForDebugging(`[Ink:${label}] ${message}`, {
level: 'error',
});
logError(error);
try {
this.options.stderr.write(`[Ink:${label}] ${message}\n`);
} catch {
// Best-effort fallback only.
}
};
constructor(private readonly options: Options) {
autoBind(this);
if (this.options.patchConsole) {
@@ -259,13 +272,26 @@ export default class Ink {
// @ts-expect-error @types/react-reconciler@0.32.3 declares 11 args with transitionCallbacks,
// but react-reconciler 0.33.0 source only accepts 10 args (no transitionCallbacks)
this.container = reconciler.createContainer(this.rootNode, ConcurrentRoot, null, false, null, 'id', noop,
// onUncaughtError
noop,
// onCaughtError
noop,
// onRecoverableError
noop // onDefaultTransitionIndicator
this.container = reconciler.createContainer(
this.rootNode,
LegacyRoot,
null,
false,
null,
'id',
noop,
// onUncaughtError
error => {
this.reportRenderError('uncaught', error);
},
// onCaughtError
error => {
this.reportRenderError('caught', error);
},
// onRecoverableError
error => {
this.reportRenderError('recoverable', error);
}, // onDefaultTransitionIndicator
);
if ("production" === 'development') {
reconciler.injectIntoDevTools({
@@ -1440,6 +1466,7 @@ export default class Ink {
this.cursorDeclaration = decl;
};
render(node: ReactNode): void {
logForDebugging('[Ink:render] start');
this.currentNode = node;
const tree = <App stdin={this.options.stdin} stdout={this.options.stdout} stderr={this.options.stderr} exitOnCtrlC={this.options.exitOnCtrlC} onExit={this.unmount} terminalColumns={this.terminalColumns} terminalRows={this.terminalRows} selection={this.selection} onSelectionChange={this.notifySelectionChange} onClickAt={this.dispatchClick} onHoverAt={this.dispatchHover} getHyperlinkAt={this.getHyperlinkAt} onOpenHyperlink={this.openHyperlink} onMultiClick={this.handleMultiClick} onSelectionDrag={this.handleSelectionDrag} onStdinResume={this.reassertTerminalModes} onCursorDeclaration={this.setCursorDeclaration} dispatchKeyboardEvent={this.dispatchKeyboardEvent}>
<TerminalWriteProvider value={this.writeRaw}>
@@ -1447,10 +1474,8 @@ export default class Ink {
</TerminalWriteProvider>
</App>;
// @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler
reconciler.updateContainerSync(tree, this.container, null, noop);
// @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler
reconciler.flushSyncWork();
reconciler.updateContainer(tree, this.container, null, noop);
logForDebugging('[Ink:render] updateContainer complete');
}
unmount(error?: Error | number | null): void {
if (this.isUnmounted) {

View File

@@ -905,14 +905,7 @@ async function run(): Promise<CommanderCommand> {
// Use preAction hook to run initialization only when executing a command,
// not when displaying help. This avoids the need for env variable signaling.
program.hook('preAction', async thisCommand => {
profileCheckpoint('preAction_start');
// Await async subprocess loads started at module evaluation (lines 12-20).
// Nearly free — subprocesses complete during the ~135ms of imports above.
// Must resolve before init() which triggers the first settings read
// (applySafeConfigEnvironmentVariables → getSettingsForSource('policySettings')
// → isRemoteManagedSettingsEligible → sync keychain reads otherwise ~65ms).
await Promise.all([ensureMdmSettingsLoaded(), ensureKeychainPrefetchCompleted()]);
profileCheckpoint('preAction_after_mdm');
await init();
profileCheckpoint('preAction_after_init');
@@ -2236,10 +2229,8 @@ async function run(): Promise<CommanderCommand> {
event: 'startup' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
durationMs: Math.round(process.uptime() * 1000)
});
logForDebugging('[STARTUP] Running showSetupScreens()...');
const setupScreensStart = Date.now();
const onboardingShown = await showSetupScreens(root, permissionMode, allowDangerouslySkipPermissions, commands, enableClaudeInChrome, devChannels);
logForDebugging(`[STARTUP] showSetupScreens() completed in ${Date.now() - setupScreensStart}ms`);
// Now that trust is established and GrowthBook has auth headers,
// resolve the --remote-control / --rc entitlement gate.
@@ -2296,9 +2287,6 @@ async function run(): Promise<CommanderCommand> {
});
}
// Validate that the active token's org matches forceLoginOrgUUID (if set
// in managed settings). Runs after onboarding so managed settings and
// login state are fully loaded.
const orgValidation = await validateForceLoginOrg();
if (!orgValidation.valid) {
await exitWithError(root, orgValidation.message);
@@ -2320,14 +2308,12 @@ async function run(): Promise<CommanderCommand> {
// Must be after inline plugins are set (if any) so --plugin-dir LSP servers are included.
initializeLspServerManager();
// Show settings validation errors after trust is established
// MCP config errors don't block settings from loading, so exclude them
if (!isNonInteractiveSession) {
const {
errors
} = getSettingsWithErrors();
const nonMcpErrors = errors.filter(e => !e.mcpErrorMetadata);
if (nonMcpErrors.length > 0) {
if (nonMcpErrors.length > 0 && !isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) {
await launchInvalidSettingsDialog(root, {
settingsErrors: nonMcpErrors,
onExit: () => gracefulShutdownSync(1)
@@ -2336,8 +2322,6 @@ async function run(): Promise<CommanderCommand> {
}
// Check quota status, fast mode, passes eligibility, and bootstrap data
// after trust is established. These make API calls which could trigger
// apiKeyHelper execution.
// --bare / SIMPLE: skip — these are cache-warms for the REPL's
// first-turn responsiveness (quota, passes, fastMode, bootstrap data). Fast
// mode doesn't apply to the Agent SDK anyway (see getFastModeUnavailableReason).
@@ -2377,11 +2361,9 @@ async function run(): Promise<CommanderCommand> {
void refreshExampleCommands(); // Pre-fetch example commands (runs git log, no API call)
}
// Resolve MCP configs (started early, overlaps with setup/trust dialog work)
const {
servers: existingMcpConfigs
} = await mcpConfigPromise;
logForDebugging(`[STARTUP] MCP configs resolved in ${mcpConfigResolvedMs}ms (awaited at +${Date.now() - mcpConfigStart}ms)`);
// CLI flag (--mcp-config) should override file-based configs, matching settings precedence
const allMcpConfigs = {
...existingMcpConfigs,
@@ -3041,9 +3023,8 @@ async function run(): Promise<CommanderCommand> {
}
const initialTools = mcpTools;
// Increment numStartups synchronously — first-render readers like
// shouldShowEffortCallout (via useState initializer) need the updated
// value before setImmediate fires. Defer only telemetry.
// Keep startup bookkeeping best-effort so restricted environments can
// still reach the REPL even when the global config is not writable.
saveGlobalConfig(current => ({
...current,
numStartups: (current.numStartups ?? 0) + 1
@@ -3758,10 +3739,6 @@ async function run(): Promise<CommanderCommand> {
});
}
} else {
// Pass unresolved hooks promise to REPL so it can render immediately
// instead of blocking ~500ms waiting for SessionStart hooks to finish.
// REPL will inject hook messages when they resolve and await them before
// the first API call so the model always sees hook context.
const pendingHookMessages = hooksPromise && hookMessages.length === 0 ? hooksPromise : undefined;
profileCheckpoint('action_after_hooks');
maybeActivateProactive(options);

View File

@@ -596,6 +596,7 @@ export function REPL({
sshSession,
thinkingConfig
}: Props): React.ReactNode {
logForDebugging('[REPL:render] function entry');
const isRemoteSession = !!remoteSessionConfig;
// Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+

View File

@@ -832,6 +832,14 @@ export function saveGlobalConfig(
writeThroughGlobalConfigCache(written)
}
} catch (error) {
const code = getErrnoCode(error)
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
logForDebugging(
`Skipping global config write due to permission error: ${error}`,
{ level: 'error' },
)
return
}
logForDebugging(`Failed to save config with lock: ${error}`, {
level: 'error',
})