diff --git a/cli/src/daemon/controlClient.ts b/cli/src/daemon/controlClient.ts index d061753e..d88325b3 100644 --- a/cli/src/daemon/controlClient.ts +++ b/cli/src/daemon/controlClient.ts @@ -6,10 +6,31 @@ import { logger } from '@/ui/logger'; import { clearDaemonState, readDaemonState } from '@/persistence'; import { Metadata } from '@/api/types'; -import { projectPath } from '@/projectPath'; -import { readFileSync } from 'fs'; -import { join } from 'path'; -import { configuration } from '@/configuration'; +import packageJson from '../../package.json'; +import { existsSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { isBunCompiled, projectPath } from '@/projectPath'; + +export function getInstalledCliMtimeMs(): number | undefined { + if (isBunCompiled()) { + try { + return statSync(process.execPath).mtimeMs; + } catch { + return undefined; + } + } + + const packageJsonPath = join(projectPath(), 'package.json'); + if (!existsSync(packageJsonPath)) { + return undefined; + } + + try { + return statSync(packageJsonPath).mtimeMs; + } catch { + return undefined; + } +} async function daemonPost(path: string, body?: any): Promise<{ error?: string } | any> { const state = await readDaemonState(); @@ -154,11 +175,13 @@ export async function isDaemonRunningCurrentlyInstalledHappyVersion(): Promise { onHappySessionWebhook }); + const startedWithCliMtimeMs = getInstalledCliMtimeMs(); + // Write initial daemon state (no lock needed for state file) const fileState: DaemonLocallyPersistedState = { pid: process.pid, httpPort: controlPort, startTime: new Date().toLocaleString(), startedWithCliVersion: packageJson.version, + startedWithCliMtimeMs, daemonLogPath: logger.logFilePath }; writeDaemonState(fileState); @@ -476,10 +478,10 @@ export async function startDaemon(): Promise { } // Check if daemon needs update - // If version on disk is different from the one in package.json - we need to restart - // BIG if - does this get updated from underneath us on npm upgrade? - const projectVersion = JSON.parse(readFileSync(join(projectPath(), 'package.json'), 'utf-8')).version; - if (projectVersion !== configuration.currentCliVersion) { + const installedCliMtimeMs = getInstalledCliMtimeMs(); + if (typeof installedCliMtimeMs === 'number' && + typeof startedWithCliMtimeMs === 'number' && + installedCliMtimeMs !== startedWithCliMtimeMs) { logger.debug('[DAEMON RUN] Daemon is outdated, triggering self-restart with latest version, clearing heartbeat interval'); clearInterval(restartOnStaleVersionAndHeartbeat); @@ -521,6 +523,7 @@ export async function startDaemon(): Promise { httpPort: controlPort, startTime: fileState.startTime, startedWithCliVersion: packageJson.version, + startedWithCliMtimeMs, lastHeartbeat: new Date().toLocaleString(), daemonLogPath: fileState.daemonLogPath }; diff --git a/cli/src/index.ts b/cli/src/index.ts index 4dc6f19f..4def06ee 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -52,6 +52,11 @@ import { withBunRuntimeEnv } from './utils/bunRuntime' return process.argv.slice(1) })() + if (args.includes('-v') || args.includes('--version')) { + console.log(`hapi version: ${packageJson.version}`) + process.exit(0) + } + // Check if first argument is a subcommand const subcommand = args[0] @@ -79,10 +84,7 @@ import { withBunRuntimeEnv } from './utils/bunRuntime' await ensureRuntimeAssets() - // If --version is passed - do not log, its likely daemon inquiring about our version - if (!args.includes('--version')) { - logger.debug('Starting hapi CLI with args: ', process.argv) - } + logger.debug('Starting hapi CLI with args: ', process.argv) if (subcommand === 'doctor') { // Check for clean subcommand @@ -318,7 +320,6 @@ ${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('hapi doctor cl // Parse command line arguments for main command const options: StartOptions = {} let showHelp = false - let showVersion = false const unknownArgs: string[] = [] // Collect unknown args to pass through to claude for (let i = 0; i < args.length; i++) { @@ -328,10 +329,6 @@ ${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('hapi doctor cl showHelp = true // Also pass through to claude unknownArgs.push(arg) - } else if (arg === '-v' || arg === '--version') { - showVersion = true - // Also pass through to claude (will show after our version) - unknownArgs.push(arg) } else if (arg === '--hapi-starting-mode') { options.startingMode = z.enum(['local', 'remote']).parse(args[++i]) } else if (arg === '--yolo') { @@ -404,16 +401,6 @@ ${chalk.bold.cyan('Claude Code Options (from `claude --help`):')} process.exit(0) } - // Show version - if (showVersion) { - console.log(`hapi version: ${packageJson.version}`) - const versionOnly = args.every((value) => value === '-v' || value === '--version') - if (versionOnly) { - process.exit(0) - } - // Continue to pass --version to Claude Code when other args are present. - } - // Normal flow - auth and machine setup await initializeToken(); await authAndSetupMachineIfNeeded(); diff --git a/cli/src/persistence.ts b/cli/src/persistence.ts index 20961a88..6032fe55 100644 --- a/cli/src/persistence.ts +++ b/cli/src/persistence.ts @@ -35,6 +35,7 @@ export interface DaemonLocallyPersistedState { httpPort: number; startTime: string; startedWithCliVersion: string; + startedWithCliMtimeMs?: number; lastHeartbeat?: string; daemonLogPath?: string; } diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index 671e2377..268e2ba5 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -476,13 +476,17 @@ export class SyncEngine { return { host, platform, happyCliVersion, displayName, ...data } })() + const storedActiveAt = stored.activeAt ?? stored.createdAt + const existingActiveAt = existing?.activeAt ?? 0 + const useStoredActivity = storedActiveAt > existingActiveAt + const machine: Machine = { id: stored.id, seq: stored.seq, createdAt: stored.createdAt, updatedAt: stored.updatedAt, - active: existing?.active ?? stored.active, - activeAt: existing?.activeAt ?? (stored.activeAt ?? stored.createdAt), + active: useStoredActivity ? stored.active : (existing?.active ?? stored.active), + activeAt: useStoredActivity ? storedActiveAt : (existingActiveAt || storedActiveAt), metadata, metadataVersion: stored.metadataVersion, daemonState: stored.daemonState,