Files
hapi/cli/src/agent/sessionFactory.ts
T
af8d160364 feat(cli): export HAPI_SESSION_ID into wrapped agent env (self-targeting) (#1121)
* feat(cli): export HAPI_SESSION_ID into wrapped agent env

Publish the hub session id into process.env at session bootstrap so every
downstream agent spawn inherits it. HAPI runs one hub session per CLI process
(the runner forks a fresh hapi child per session; local is 1:1) and every
flavor's agent spawn derives its child env from process.env, so a single seam
covers claude / codex / cursor / gemini / opencode / kimi / grok / pi -
runner-spawned and local - plus future flavors, without touching each launcher.

Agents can read HAPI_SESSION_ID to self-target their own hub session over REST
or shell helpers without listing /api/sessions. Prefer the MCP display_image
tool for inline media when available; HAPI_SESSION_ID is the deterministic
fallback for non-MCP tooling.

Closes #1119

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(scripts): self-target hapi-display-image via HAPI_SESSION_ID

Teach the in-tree shell helper to use $HAPI_SESSION_ID for path-only /
self invocations: GET /api/sessions/:id directly instead of listing
/api/sessions. Explicit session prefixes keep the previous list path.

Gives #1119 a tangible now benefit - the tool that forced the wasteful
list-and-reverse-lookup dance no longer needs it inside a wrapped session.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): defer HAPI_SESSION_ID export until lazy Codex materializes

The provisional lazy-session id was exported at bootstrap before the hub
row existed, so path-only self-targeting (GET /api/sessions/:id) could
404 while materialization was still pending. Export on onMaterialized
instead, and await materialize in buildHapiMcpBridge before starting the
MCP server / spawning Codex so the agent inherits an id the hub can
resolve (and so hapiMcpUrl is persisted, not only local pending state).

Addresses Codex review Major on #1121.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Debian <heavygee@oos-linux.in.lockhouse>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 10:56:59 +08:00

333 lines
12 KiB
TypeScript

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 { notifyRunnerSessionStarted } from '@/runner/controlClient'
import { readSettings } from '@/persistence'
import { configuration } from '@/configuration'
import { logger } from '@/ui/logger'
import { runtimePath } from '@/projectPath'
import { getInvokedCwd } from '@/utils/invokedCwd'
import { readWorktreeEnv } from '@/utils/worktreeEnv'
import { exportHapiSessionEnv } from '@/agent/hapiSessionEnv'
import packageJson from '../../package.json'
export { HAPI_SESSION_ID_ENV, exportHapiSessionEnv } from '@/agent/hapiSessionEnv'
export type SessionStartedBy = 'runner' | 'terminal'
export type SessionBootstrapOptions = {
flavor: string
startedBy?: SessionStartedBy
workingDirectory?: string
tag?: string
agentState?: AgentState | null
model?: string
modelReasoningEffort?: string
effort?: string
metadataOverrides?: Partial<Metadata>
}
export type SessionBootstrapResult = {
api: ApiClient
session: ApiSessionClient
sessionInfo: Session
metadata: Metadata
machineId: string
startedBy: SessionStartedBy
workingDirectory: string
}
export function buildMachineMetadata(options?: { workspaceRoots?: string[] }): MachineMetadata {
return {
host: process.env.HAPI_HOSTNAME || os.hostname(),
platform: os.platform(),
happyCliVersion: packageJson.version,
homeDir: os.homedir(),
happyHomeDir: configuration.happyHomeDir,
happyLibDir: runtimePath(),
workspaceRoots: options?.workspaceRoots
}
}
export function buildSessionMetadata(options: {
flavor: string
startedBy: SessionStartedBy
workingDirectory: string
machineId: string
now?: number
metadataOverrides?: Partial<Metadata>
}): Metadata {
const happyLibDir = runtimePath()
const worktreeInfo = readWorktreeEnv()
const now = options.now ?? Date.now()
return {
path: options.workingDirectory,
host: process.env.HAPI_HOSTNAME || os.hostname(),
version: packageJson.version,
os: os.platform(),
machineId: options.machineId,
homeDir: os.homedir(),
happyHomeDir: configuration.happyHomeDir,
happyLibDir,
happyToolsDir: resolve(happyLibDir, 'tools', 'unpacked'),
startedFromRunner: options.startedBy === 'runner',
hostPid: process.pid,
startedBy: options.startedBy,
lifecycleState: 'running',
lifecycleStateSince: now,
flavor: options.flavor,
capabilities: {
terminal: true
},
worktree: worktreeInfo ?? undefined,
...options.metadataOverrides
}
}
function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Partial<Metadata> {
if (!metadata) return {}
const preserved: Partial<Metadata> = {}
if (metadata.name !== undefined) preserved.name = metadata.name
if (metadata.summary !== undefined) preserved.summary = metadata.summary
if (metadata.claudeSessionId !== undefined) preserved.claudeSessionId = metadata.claudeSessionId
if (metadata.codexSessionId !== undefined) preserved.codexSessionId = metadata.codexSessionId
if (metadata.codexSourceSessionId !== undefined) preserved.codexSourceSessionId = metadata.codexSourceSessionId
if (metadata.geminiSessionId !== undefined) preserved.geminiSessionId = metadata.geminiSessionId
if (metadata.opencodeSessionId !== undefined) preserved.opencodeSessionId = metadata.opencodeSessionId
if (metadata.grokSessionId !== undefined) preserved.grokSessionId = metadata.grokSessionId
if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId
if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol
if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId
if (metadata.piSessionId !== undefined) preserved.piSessionId = metadata.piSessionId
if (metadata.preferredPermissionMode !== undefined) preserved.preferredPermissionMode = metadata.preferredPermissionMode
if (metadata.tools !== undefined) preserved.tools = metadata.tools
if (metadata.slashCommands !== undefined) preserved.slashCommands = metadata.slashCommands
if (metadata.worktree !== undefined) preserved.worktree = metadata.worktree
// Preserve cached Pi model list so the web can show models immediately
// on inactive-session view without waiting for an RPC round-trip.
if (metadata.piAvailableModels !== undefined) preserved.piAvailableModels = metadata.piAvailableModels
// Preserve provider-qualified Pi model selection (disambiguates duplicate modelIds).
if (metadata.piSelectedModel !== undefined) preserved.piSelectedModel = metadata.piSelectedModel
return preserved
}
async function getMachineIdOrExit(): Promise<string> {
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<void> {
try {
logger.debug(`[START] Reporting session ${sessionId} to runner`)
const result = await notifyRunnerSessionStarted(sessionId, metadata)
if (result?.error) {
logger.debug(`[START] Failed to report to runner (may not be running):`, result.error)
} else {
logger.debug(`[START] Reported session ${sessionId} to runner`)
}
} catch (error) {
logger.debug('[START] Failed to report to runner (may not be running):', error)
}
}
export async function bootstrapSession(options: SessionBootstrapOptions): Promise<SessionBootstrapResult> {
const workingDirectory = options.workingDirectory ?? getInvokedCwd()
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,
metadataOverrides: options.metadataOverrides
})
const sessionInfo = await api.getOrCreateSession({
tag: sessionTag,
metadata,
state: agentState,
model: options.model,
modelReasoningEffort: options.modelReasoningEffort,
effort: options.effort
})
const session = api.sessionSyncClient(sessionInfo)
exportHapiSessionEnv(sessionInfo.id)
await reportSessionStarted(sessionInfo.id, metadata)
return {
api,
session,
sessionInfo,
metadata,
machineId,
startedBy,
workingDirectory
}
}
export async function bootstrapLazySession(options: SessionBootstrapOptions): Promise<SessionBootstrapResult> {
const workingDirectory = options.workingDirectory ?? getInvokedCwd()
const startedBy = options.startedBy ?? 'terminal'
if (startedBy !== 'terminal') {
throw new Error('Lazy session bootstrap is only supported for terminal sessions')
}
const api = await ApiClient.create()
const machineId = await getMachineIdOrExit()
const machineMetadata = buildMachineMetadata()
const metadata = buildSessionMetadata({
flavor: options.flavor,
startedBy,
workingDirectory,
machineId,
metadataOverrides: options.metadataOverrides
})
const agentState = options.agentState === undefined ? {} : options.agentState
const now = Date.now()
const requestedId = randomUUID()
const sessionTag = options.tag ?? randomUUID()
const sessionInfo: Session = {
id: requestedId,
namespace: 'pending',
seq: 0,
createdAt: now,
updatedAt: now,
active: false,
activeAt: now,
metadata,
metadataVersion: 0,
agentState,
agentStateVersion: 0,
thinking: false,
thinkingAt: now,
todos: [],
model: options.model ?? null,
modelReasoningEffort: options.modelReasoningEffort ?? null,
effort: options.effort ?? null,
serviceTier: null,
permissionMode: undefined,
collaborationMode: undefined
}
const session = api.sessionSyncClient(sessionInfo, {
materialize: async (snapshot, signal) => {
const materialized = await api.getOrCreateSession({
id: requestedId,
tag: sessionTag,
metadata: snapshot.metadata ?? metadata,
state: snapshot.agentState,
model: options.model,
modelReasoningEffort: options.modelReasoningEffort,
effort: options.effort,
machine: {
id: machineId,
metadata: machineMetadata
},
timeoutMs: 10_000,
signal
})
if (materialized.id !== requestedId) {
throw new Error(`Hub returned unexpected session id ${materialized.id}`)
}
return materialized
},
onMaterialized: (materialized, snapshot) => {
// Export only after the hub row exists. Exporting the provisional id at
// bootstrap lets agents inherit HAPI_SESSION_ID before GET /api/sessions/:id
// can resolve (and before hapiMcpUrl is persisted) — #1119 / PR #1121 Major.
exportHapiSessionEnv(materialized.id)
void reportSessionStarted(materialized.id, snapshot.metadata ?? metadata)
}
})
return {
api,
session,
sessionInfo,
metadata,
machineId,
startedBy,
workingDirectory
}
}
export async function bootstrapExistingSession(options: {
sessionId: string
flavor: string
startedBy?: SessionStartedBy
workingDirectory: string
metadataOverrides?: Partial<Metadata>
}): Promise<SessionBootstrapResult> {
const startedBy = options.startedBy ?? 'terminal'
const api = await ApiClient.create()
const machineId = await getMachineIdOrExit()
await api.getOrCreateMachine({
machineId,
metadata: buildMachineMetadata()
})
const sessionInfo = await api.getSession(options.sessionId)
const baseMetadata = buildSessionMetadata({
flavor: options.flavor,
startedBy,
workingDirectory: options.workingDirectory,
machineId
})
const metadata = {
...baseMetadata,
...pickExistingSessionMetadata(sessionInfo.metadata),
...options.metadataOverrides
}
const buildUpdatedMetadata = (current: Metadata): Metadata => ({
...baseMetadata,
...pickExistingSessionMetadata(current),
...options.metadataOverrides
})
const session = api.sessionSyncClient(sessionInfo)
session.updateMetadata(buildUpdatedMetadata)
exportHapiSessionEnv(sessionInfo.id)
await reportSessionStarted(sessionInfo.id, metadata)
return {
api,
session,
sessionInfo,
metadata,
machineId,
startedBy,
workingDirectory: options.workingDirectory
}
}