From 89b6fc34f702418f3df652e0721c00a78b2d256e Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 3 Jan 2026 15:34:49 +0800 Subject: [PATCH] refactor(cli): unify session bootstrap --- cli/src/agent/runners/runAgentSession.ts | 68 ++--------- cli/src/agent/sessionFactory.ts | 143 +++++++++++++++++++++++ cli/src/claude/runClaude.ts | 85 ++------------ cli/src/codex/runCodex.ts | 70 ++--------- cli/src/daemon/run.ts | 17 +-- 5 files changed, 172 insertions(+), 211 deletions(-) create mode 100644 cli/src/agent/sessionFactory.ts diff --git a/cli/src/agent/runners/runAgentSession.ts b/cli/src/agent/runners/runAgentSession.ts index 3b938520..e28ecbb2 100644 --- a/cli/src/agent/runners/runAgentSession.ts +++ b/cli/src/agent/runners/runAgentSession.ts @@ -1,26 +1,15 @@ -import os from 'node:os'; -import { randomUUID } from 'node:crypto'; -import { resolve } from 'node:path'; - -import { ApiClient } from '@/api/api'; -import type { AgentState, Metadata } from '@/api/types'; +import type { AgentState } from '@/api/types'; import { logger } from '@/ui/logger'; -import packageJson from '../../../package.json'; -import { readSettings } from '@/persistence'; -import { configuration } from '@/configuration'; -import { runtimePath } from '@/projectPath'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; import { AgentRegistry } from '@/agent/AgentRegistry'; import { convertAgentMessage } from '@/agent/messageConverter'; import { PermissionAdapter } from '@/agent/permissionAdapter'; import type { AgentBackend, PromptContent } from '@/agent/types'; -import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; -import { initialMachineMetadata } from '@/daemon/run'; import { startHappyServer } from '@/claude/utils/startHappyServer'; import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; -import { readWorktreeEnv } from '@/utils/worktreeEnv'; +import { bootstrapSession } from '@/agent/sessionFactory'; function emitReadyIfIdle(props: { queueSize: () => number; @@ -38,56 +27,15 @@ export async function runAgentSession(opts: { agentType: string; startedBy?: 'daemon' | 'terminal'; }): Promise { - const sessionTag = randomUUID(); - const api = await ApiClient.create(); - - const settings = await readSettings(); - const machineId = settings?.machineId; - if (!machineId) { - console.error(`[START] No machine ID found in settings. Please report this issue on ${packageJson.bugs}`); - process.exit(1); - } - - await api.getOrCreateMachine({ - machineId, - metadata: initialMachineMetadata - }); - - let state: AgentState = { + const initialState: AgentState = { controlledByUser: false }; - - const worktreeInfo = readWorktreeEnv(); - const metadata: Metadata = { - path: process.cwd(), - host: os.hostname(), - version: packageJson.version, - os: os.platform(), - machineId, - homeDir: os.homedir(), - happyHomeDir: configuration.happyHomeDir, - happyLibDir: runtimePath(), - happyToolsDir: resolve(runtimePath(), 'tools', 'unpacked'), - startedFromDaemon: opts.startedBy === 'daemon', - hostPid: process.pid, - startedBy: opts.startedBy || 'terminal', - lifecycleState: 'running', - lifecycleStateSince: Date.now(), + const { session } = await bootstrapSession({ flavor: opts.agentType, - worktree: worktreeInfo ?? undefined - }; - - const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); - const session = api.sessionSyncClient(response); - - try { - const result = await notifyDaemonSessionStarted(response.id, metadata); - if (result.error) { - logger.debug(`[START] Failed to report session to daemon: ${result.error}`); - } - } catch (error) { - logger.debug('[START] Failed to report session to daemon', error); - } + startedBy: opts.startedBy ?? 'terminal', + workingDirectory: process.cwd(), + agentState: initialState + }); session.updateAgentState((currentState) => ({ ...currentState, diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts new file mode 100644 index 00000000..dcd22637 --- /dev/null +++ b/cli/src/agent/sessionFactory.ts @@ -0,0 +1,143 @@ +import os from 'node:os' +import { randomUUID } from 'node:crypto' +import { resolve } from 'node:path' + +import { ApiClient } from '@/api/api' +import type { ApiSessionClient } from '@/api/apiSession' +import type { AgentState, MachineMetadata, Metadata, Session } from '@/api/types' +import { notifyDaemonSessionStarted } from '@/daemon/controlClient' +import { readSettings } from '@/persistence' +import { configuration } from '@/configuration' +import { logger } from '@/ui/logger' +import { runtimePath } from '@/projectPath' +import { readWorktreeEnv } from '@/utils/worktreeEnv' +import packageJson from '../../package.json' + +export type SessionStartedBy = 'daemon' | 'terminal' + +export type SessionBootstrapOptions = { + flavor: string + startedBy?: SessionStartedBy + workingDirectory?: string + tag?: string + agentState?: AgentState | null +} + +export type SessionBootstrapResult = { + api: ApiClient + session: ApiSessionClient + sessionInfo: Session + metadata: Metadata + machineId: string + startedBy: SessionStartedBy + workingDirectory: string +} + +export function buildMachineMetadata(): MachineMetadata { + return { + host: os.hostname(), + platform: os.platform(), + happyCliVersion: packageJson.version, + homeDir: os.homedir(), + happyHomeDir: configuration.happyHomeDir, + happyLibDir: runtimePath() + } +} + +export function buildSessionMetadata(options: { + flavor: string + startedBy: SessionStartedBy + workingDirectory: string + machineId: string + now?: number +}): Metadata { + const happyLibDir = runtimePath() + const worktreeInfo = readWorktreeEnv() + const now = options.now ?? Date.now() + + return { + path: options.workingDirectory, + host: os.hostname(), + version: packageJson.version, + os: os.platform(), + machineId: options.machineId, + homeDir: os.homedir(), + happyHomeDir: configuration.happyHomeDir, + happyLibDir, + happyToolsDir: resolve(happyLibDir, 'tools', 'unpacked'), + startedFromDaemon: options.startedBy === 'daemon', + hostPid: process.pid, + startedBy: options.startedBy, + lifecycleState: 'running', + lifecycleStateSince: now, + flavor: options.flavor, + worktree: worktreeInfo ?? undefined + } +} + +async function getMachineIdOrExit(): Promise { + const settings = await readSettings() + const machineId = settings?.machineId + if (!machineId) { + console.error(`[START] No machine ID found in settings, which is unexpected since authAndSetupMachineIfNeeded should have created it. Please report this issue on ${packageJson.bugs}`) + process.exit(1) + } + logger.debug(`Using machineId: ${machineId}`) + return machineId +} + +async function reportSessionStarted(sessionId: string, metadata: Metadata): Promise { + try { + logger.debug(`[START] Reporting session ${sessionId} to daemon`) + const result = await notifyDaemonSessionStarted(sessionId, metadata) + if (result?.error) { + logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error) + } else { + logger.debug(`[START] Reported session ${sessionId} to daemon`) + } + } catch (error) { + logger.debug('[START] Failed to report to daemon (may not be running):', error) + } +} + +export async function bootstrapSession(options: SessionBootstrapOptions): Promise { + const workingDirectory = options.workingDirectory ?? process.cwd() + const startedBy = options.startedBy ?? 'terminal' + const sessionTag = options.tag ?? randomUUID() + const agentState = options.agentState === undefined ? {} : options.agentState + + const api = await ApiClient.create() + + const machineId = await getMachineIdOrExit() + await api.getOrCreateMachine({ + machineId, + metadata: buildMachineMetadata() + }) + + const metadata = buildSessionMetadata({ + flavor: options.flavor, + startedBy, + workingDirectory, + machineId + }) + + const sessionInfo = await api.getOrCreateSession({ + tag: sessionTag, + metadata, + state: agentState + }) + + const session = api.sessionSyncClient(sessionInfo) + + await reportSessionStarted(sessionInfo.id, metadata) + + return { + api, + session, + sessionInfo, + metadata, + machineId, + startedBy, + workingDirectory + } +} diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 83d6d064..a1d26651 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -1,30 +1,19 @@ -import os from 'node:os'; -import { randomUUID } from 'node:crypto'; - -import { ApiClient } from '@/api/api'; import { logger } from '@/ui/logger'; import { restoreTerminalState } from '@/ui/terminalState'; import { loop } from '@/claude/loop'; -import { AgentState, Metadata, SessionModelMode } from '@/api/types'; -import packageJson from '../../package.json'; -import { readSettings } from '@/persistence'; +import { AgentState, SessionModelMode } from '@/api/types'; import { EnhancedMode, PermissionMode } from './loop'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; import { extractSDKMetadataAsync } from '@/claude/sdk/metadataExtractor'; import { parseSpecialCommand } from '@/parsers/specialCommands'; import { getEnvironmentInfo } from '@/ui/doctor'; -import { configuration } from '@/configuration'; -import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; -import { initialMachineMetadata } from '@/daemon/run'; import { startHappyServer } from '@/claude/utils/startHappyServer'; import { startHookServer } from '@/claude/utils/startHookServer'; import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/claude/utils/generateHookSettings'; import { registerKillSessionHandler } from './registerKillSessionHandler'; -import { runtimePath } from '../projectPath'; -import { resolve } from 'node:path'; import type { Session } from './session'; -import { readWorktreeEnv } from '@/utils/worktreeEnv'; +import { bootstrapSession } from '@/agent/sessionFactory'; export interface StartOptions { model?: string @@ -38,7 +27,6 @@ export interface StartOptions { export async function runClaude(options: StartOptions = {}): Promise { const workingDirectory = process.cwd(); - const sessionTag = randomUUID(); const startedBy = options.startedBy ?? 'terminal'; // Log environment info at startup @@ -53,69 +41,21 @@ export async function runClaude(options: StartOptions = {}): Promise { // throw new Error('Daemon-spawned sessions cannot use local/interactive mode'); } - // Create session service - const api = await ApiClient.create(); - - // Create a new session - let state: AgentState = {}; - - // Get machine ID from settings (should already be set up) - const settings = await readSettings(); - let machineId = settings?.machineId - if (!machineId) { - console.error(`[START] No machine ID found in settings, which is unexpected since authAndSetupMachineIfNeeded should have created it. Please report this issue on ${packageJson.bugs}`); - process.exit(1); - } - logger.debug(`Using machineId: ${machineId}`); - - // Create machine if it doesn't exist - await api.getOrCreateMachine({ - machineId, - metadata: initialMachineMetadata - }); - - const worktreeInfo = readWorktreeEnv(); - let metadata: Metadata = { - path: workingDirectory, - host: os.hostname(), - version: packageJson.version, - os: os.platform(), - machineId: machineId, - homeDir: os.homedir(), - happyHomeDir: configuration.happyHomeDir, - happyLibDir: runtimePath(), - happyToolsDir: resolve(runtimePath(), 'tools', 'unpacked'), - startedFromDaemon: startedBy === 'daemon', - hostPid: process.pid, - startedBy, - // Initialize lifecycle state - lifecycleState: 'running', - lifecycleStateSince: Date.now(), + const initialState: AgentState = {}; + const { api, session, sessionInfo } = await bootstrapSession({ flavor: 'claude', - worktree: worktreeInfo ?? undefined - }; - const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); - logger.debug(`Session created: ${response.id}`); - - // Always report to daemon if it exists - try { - logger.debug(`[START] Reporting session ${response.id} to daemon`); - const result = await notifyDaemonSessionStarted(response.id, metadata); - if (result.error) { - logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); - } else { - logger.debug(`[START] Reported session ${response.id} to daemon`); - } - } catch (error) { - logger.debug('[START] Failed to report to daemon (may not be running):', error); - } + startedBy, + workingDirectory, + agentState: initialState + }); + logger.debug(`Session created: ${sessionInfo.id}`); // Extract SDK metadata in background and update session when ready extractSDKMetadataAsync(async (sdkMetadata) => { logger.debug('[start] SDK metadata extracted, updating session:', sdkMetadata); try { // Update session metadata with tools and slash commands - api.sessionSyncClient(response).updateMetadata((currentMetadata) => ({ + session.updateMetadata((currentMetadata) => ({ ...currentMetadata, tools: sdkMetadata.tools, slashCommands: sdkMetadata.slashCommands @@ -126,9 +66,6 @@ export async function runClaude(options: StartOptions = {}): Promise { } }); - // Create realtime session - const session = api.sessionSyncClient(response); - // Start HAPI MCP server const happyServer = await startHappyServer(session); logger.debug(`[START] HAPI MCP server started at ${happyServer.url}`); @@ -168,7 +105,7 @@ export async function runClaude(options: StartOptions = {}): Promise { // Print log file path const logPath = logger.logFilePath; - logger.infoDeveloper(`Session: ${response.id}`); + logger.infoDeveloper(`Session: ${sessionInfo.id}`); logger.infoDeveloper(`Logs: ${logPath}`); // Set initial agent state diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index e4fac665..16d7cb15 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -1,24 +1,13 @@ -import os from 'node:os'; -import { randomUUID } from 'node:crypto'; -import { resolve } from 'node:path'; - -import { ApiClient } from '@/api/api'; import { logger } from '@/ui/logger'; import { restoreTerminalState } from '@/ui/terminalState'; import { loop, type EnhancedMode, type PermissionMode } from './loop'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; -import { readSettings } from '@/persistence'; -import { configuration } from '@/configuration'; -import { notifyDaemonSessionStarted } from '@/daemon/controlClient'; -import { initialMachineMetadata } from '@/daemon/run'; import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; -import type { AgentState, Metadata } from '@/api/types'; -import packageJson from '../../package.json'; -import { runtimePath } from '@/projectPath'; +import type { AgentState } from '@/api/types'; import type { CodexSession } from './session'; import { parseCodexCliOverrides } from './utils/codexCliOverrides'; -import { readWorktreeEnv } from '@/utils/worktreeEnv'; +import { bootstrapSession } from '@/agent/sessionFactory'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; @@ -28,64 +17,19 @@ export async function runCodex(opts: { permissionMode?: PermissionMode; }): Promise { const workingDirectory = process.cwd(); - const sessionTag = randomUUID(); const startedBy = opts.startedBy ?? 'terminal'; logger.debug(`[codex] Starting with options: startedBy=${startedBy}`); - const api = await ApiClient.create(); - - const settings = await readSettings(); - const machineId = settings?.machineId; - if (!machineId) { - console.error(`[START] No machine ID found in settings, which is unexpected since authAndSetupMachineIfNeeded should have created it. Please report this issue on ${packageJson.bugs}`); - process.exit(1); - } - logger.debug(`Using machineId: ${machineId}`); - - await api.getOrCreateMachine({ - machineId, - metadata: initialMachineMetadata - }); - let state: AgentState = { controlledByUser: false }; - - const worktreeInfo = readWorktreeEnv(); - const metadata: Metadata = { - path: workingDirectory, - host: os.hostname(), - version: packageJson.version, - os: os.platform(), - machineId: machineId, - homeDir: os.homedir(), - happyHomeDir: configuration.happyHomeDir, - happyLibDir: runtimePath(), - happyToolsDir: resolve(runtimePath(), 'tools', 'unpacked'), - startedFromDaemon: startedBy === 'daemon', - hostPid: process.pid, - startedBy, - lifecycleState: 'running', - lifecycleStateSince: Date.now(), + const { api, session } = await bootstrapSession({ flavor: 'codex', - worktree: worktreeInfo ?? undefined - }; - - const response = await api.getOrCreateSession({ tag: sessionTag, metadata, state }); - const session = api.sessionSyncClient(response); - - try { - logger.debug(`[START] Reporting session ${response.id} to daemon`); - const result = await notifyDaemonSessionStarted(response.id, metadata); - if (result.error) { - logger.debug(`[START] Failed to report to daemon (may not be running):`, result.error); - } else { - logger.debug(`[START] Reported session ${response.id} to daemon`); - } - } catch (error) { - logger.debug('[START] Failed to report to daemon (may not be running):', error); - } + startedBy, + workingDirectory, + agentState: state + }); const startingMode: 'local' | 'remote' = startedBy === 'daemon' ? 'remote' : 'local'; diff --git a/cli/src/daemon/run.ts b/cli/src/daemon/run.ts index 3b3bc5f7..1056173c 100644 --- a/cli/src/daemon/run.ts +++ b/cli/src/daemon/run.ts @@ -3,11 +3,10 @@ import os from 'os'; import { ApiClient } from '@/api/api'; import { TrackedSession } from './types'; -import { MachineMetadata, DaemonState, Metadata } from '@/api/types'; +import { DaemonState, Metadata } from '@/api/types'; import { SpawnSessionOptions, SpawnSessionResult } from '@/modules/common/registerCommonHandlers'; import { logger } from '@/ui/logger'; import { authAndSetupMachineIfNeeded } from '@/ui/auth'; -import { configuration } from '@/configuration'; import packageJson from '../../package.json'; import { getEnvironmentInfo } from '@/ui/doctor'; import { spawnHappyCLI } from '@/utils/spawnHappyCLI'; @@ -18,17 +17,7 @@ import { cleanupDaemonState, getInstalledCliMtimeMs, isDaemonRunningCurrentlyIns import { startDaemonControlServer } from './controlServer'; import { createWorktree, removeWorktree, type WorktreeInfo } from './worktree'; import { join } from 'path'; -import { runtimePath } from '@/projectPath'; - -// Prepare initial metadata -export const initialMachineMetadata: MachineMetadata = { - host: os.hostname(), - platform: os.platform(), - happyCliVersion: packageJson.version, - homeDir: os.homedir(), - happyHomeDir: configuration.happyHomeDir, - happyLibDir: runtimePath() -}; +import { buildMachineMetadata } from '@/agent/sessionFactory'; export async function startDaemon(): Promise { // We don't have cleanup function at the time of server construction @@ -536,7 +525,7 @@ export async function startDaemon(): Promise { // Get or create machine const machine = await api.getOrCreateMachine({ machineId, - metadata: initialMachineMetadata, + metadata: buildMachineMetadata(), daemonState: initialDaemonState }); logger.debug(`[DAEMON RUN] Machine registered: ${machine.id}`);