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>
This commit is contained in:
HeavyGee
2026-07-24 10:56:59 +08:00
committed by GitHub
co-authored by Cursor Debian
parent 0834dc098c
commit af8d160364
7 changed files with 270 additions and 22 deletions
+14
View File
@@ -114,6 +114,20 @@ See `src/configuration.ts` for all options.
- `HAPI_WORKTREE_PATH` - Full worktree path. - `HAPI_WORKTREE_PATH` - Full worktree path.
- `HAPI_WORKTREE_CREATED_AT` - Creation timestamp (ms). - `HAPI_WORKTREE_CREATED_AT` - Creation timestamp (ms).
### Set for the wrapped agent
- `HAPI_SESSION_ID` - The hub session id for the current run, exported into the wrapped agent/CLI child environment at spawn for every flavor (claude / codex / cursor / gemini / opencode / kimi / grok / pi), both runner-spawned and locally started sessions. Agents can read it to self-target "this chat" over the hub REST API or shell helpers without listing `/api/sessions`. Prefer the MCP `display_image` tool for inline media when it is available; use `HAPI_SESSION_ID` for hub REST / shell tooling where MCP is not.
Lazy Codex (terminal) sessions export the id only after the hub row is materialized, which happens when the MCP bridge starts — before the agent process is spawned — so path-only self-targeting does not race a missing hub row.
Example (shell fallback when MCP is unavailable) — path-only, self-targets the current session:
```bash
bun scripts/tooling/hapi-display-image.mjs /absolute/path/to/image.png "optional title"
```
Explicit other session (prefix or full uuid) still works; that path may list sessions.
## Storage ## Storage
Data is stored in `~/.hapi/` (or `$HAPI_HOME`): Data is stored in `~/.hapi/` (or `$HAPI_HOME`):
+28
View File
@@ -0,0 +1,28 @@
/**
* Canonical env var name exported into the wrapped agent / CLI child process so
* it can self-target its own hub session (REST, shell helpers) without listing
* `/api/sessions`. See tiann/hapi#1119.
*/
export const HAPI_SESSION_ID_ENV = 'HAPI_SESSION_ID';
/**
* Publish the hub session id into `process.env` so every downstream agent spawn
* inherits it. HAPI runs one hub session per CLI process (the runner forks a
* fresh `hapi` child per session, and local invocations are 1:1), and every
* flavor's agent spawn derives its child env from `process.env` — so setting it
* here covers claude / codex / cursor / gemini / opencode / kimi / grok / pi at
* once, including future flavors, without touching each launcher.
*
* Prefer the MCP `display_image` tool for inline media when it is available;
* `HAPI_SESSION_ID` is the deterministic fallback for hub REST and shell tooling.
*
* For lazy Codex sessions the id must only be exported after the hub row is
* materialized — exporting the provisional id early makes GET /api/sessions/:id
* fail until materialize completes.
*/
export function exportHapiSessionEnv(sessionId: string): void {
if (!sessionId) {
return;
}
process.env[HAPI_SESSION_ID_ENV] = sessionId;
}
+64 -1
View File
@@ -50,7 +50,13 @@ vi.mock('@/ui/logger', () => ({
} }
})) }))
import { bootstrapExistingSession, bootstrapLazySession, buildSessionMetadata } from './sessionFactory' import {
HAPI_SESSION_ID_ENV,
bootstrapExistingSession,
bootstrapLazySession,
bootstrapSession,
buildSessionMetadata
} from './sessionFactory'
function createSession(): Session { function createSession(): Session {
return { return {
@@ -91,6 +97,7 @@ describe('bootstrapExistingSession', () => {
sessionSyncClientMock.mockReset() sessionSyncClientMock.mockReset()
notifyRunnerSessionStartedMock.mockClear() notifyRunnerSessionStartedMock.mockClear()
readSettingsMock.mockReset() readSettingsMock.mockReset()
delete process.env[HAPI_SESSION_ID_ENV]
}) })
it('loads an existing HAPI session and reports it to the runner', async () => { it('loads an existing HAPI session and reports it to the runner', async () => {
@@ -110,6 +117,7 @@ describe('bootstrapExistingSession', () => {
}) })
expect(result.sessionInfo.id).toBe('hapi-session-1') expect(result.sessionInfo.id).toBe('hapi-session-1')
expect(process.env[HAPI_SESSION_ID_ENV]).toBe('hapi-session-1')
expect(result.workingDirectory).toBe('/tmp/project') expect(result.workingDirectory).toBe('/tmp/project')
expect(sessionSyncClientMock).toHaveBeenCalledWith(session) expect(sessionSyncClientMock).toHaveBeenCalledWith(session)
expect(sessionClient.updateMetadata).toHaveBeenCalledOnce() expect(sessionClient.updateMetadata).toHaveBeenCalledOnce()
@@ -210,6 +218,33 @@ describe('bootstrapLazySession', () => {
sessionSyncClientMock.mockReset() sessionSyncClientMock.mockReset()
notifyRunnerSessionStartedMock.mockClear() notifyRunnerSessionStartedMock.mockClear()
readSettingsMock.mockReset() readSettingsMock.mockReset()
delete process.env[HAPI_SESSION_ID_ENV]
})
it('does not export HAPI_SESSION_ID until the hub row is materialized', async () => {
const pendingClient = { isPending: () => true }
sessionSyncClientMock.mockReturnValue(pendingClient)
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
const result = await bootstrapLazySession({
flavor: 'codex',
startedBy: 'terminal',
workingDirectory: '/tmp/project',
agentState: { controlledByUser: false }
})
expect(process.env[HAPI_SESSION_ID_ENV]).toBeUndefined()
expect(result.sessionInfo.id).toMatch(/^[0-9a-f-]{36}$/)
const [, options] = sessionSyncClientMock.mock.calls[0]
const materialized = createSession()
materialized.id = result.sessionInfo.id
options.onMaterialized(materialized, {
metadata: result.metadata,
agentState: { controlledByUser: false }
})
expect(process.env[HAPI_SESSION_ID_ENV]).toBe(result.sessionInfo.id)
}) })
it('does not persist a machine or session until materialization', async () => { it('does not persist a machine or session until materialization', async () => {
@@ -264,3 +299,31 @@ describe('bootstrapLazySession', () => {
) )
}) })
}) })
describe('bootstrapSession HAPI_SESSION_ID export', () => {
beforeEach(() => {
getOrCreateSessionMock.mockReset()
getOrCreateMachineMock.mockReset()
sessionSyncClientMock.mockReset()
notifyRunnerSessionStartedMock.mockClear()
readSettingsMock.mockReset()
delete process.env[HAPI_SESSION_ID_ENV]
})
it('exports the hub session id so spawned agents inherit it', async () => {
const session = createSession()
session.id = 'hub-session-42'
getOrCreateSessionMock.mockResolvedValue(session)
getOrCreateMachineMock.mockResolvedValue({ id: 'machine-1' })
sessionSyncClientMock.mockReturnValue({ isPending: () => false })
readSettingsMock.mockResolvedValue({ machineId: 'machine-1' })
const result = await bootstrapSession({
flavor: 'claude',
workingDirectory: '/tmp/project'
})
expect(result.sessionInfo.id).toBe('hub-session-42')
expect(process.env[HAPI_SESSION_ID_ENV]).toBe('hub-session-42')
})
})
+12
View File
@@ -12,8 +12,11 @@ import { logger } from '@/ui/logger'
import { runtimePath } from '@/projectPath' import { runtimePath } from '@/projectPath'
import { getInvokedCwd } from '@/utils/invokedCwd' import { getInvokedCwd } from '@/utils/invokedCwd'
import { readWorktreeEnv } from '@/utils/worktreeEnv' import { readWorktreeEnv } from '@/utils/worktreeEnv'
import { exportHapiSessionEnv } from '@/agent/hapiSessionEnv'
import packageJson from '../../package.json' import packageJson from '../../package.json'
export { HAPI_SESSION_ID_ENV, exportHapiSessionEnv } from '@/agent/hapiSessionEnv'
export type SessionStartedBy = 'runner' | 'terminal' export type SessionStartedBy = 'runner' | 'terminal'
export type SessionBootstrapOptions = { export type SessionBootstrapOptions = {
@@ -174,6 +177,8 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis
const session = api.sessionSyncClient(sessionInfo) const session = api.sessionSyncClient(sessionInfo)
exportHapiSessionEnv(sessionInfo.id)
await reportSessionStarted(sessionInfo.id, metadata) await reportSessionStarted(sessionInfo.id, metadata)
return { return {
@@ -254,6 +259,10 @@ export async function bootstrapLazySession(options: SessionBootstrapOptions): Pr
return materialized return materialized
}, },
onMaterialized: (materialized, snapshot) => { 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) void reportSessionStarted(materialized.id, snapshot.metadata ?? metadata)
} }
}) })
@@ -306,6 +315,9 @@ export async function bootstrapExistingSession(options: {
const session = api.sessionSyncClient(sessionInfo) const session = api.sessionSyncClient(sessionInfo)
session.updateMetadata(buildUpdatedMetadata) session.updateMetadata(buildUpdatedMetadata)
exportHapiSessionEnv(sessionInfo.id)
await reportSessionStarted(sessionInfo.id, metadata) await reportSessionStarted(sessionInfo.id, metadata)
return { return {
+43 -5
View File
@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ApiSessionClient } from '@/api/apiSession' import type { ApiSessionClient } from '@/api/apiSession'
import { HAPI_SESSION_ID_ENV } from '@/agent/hapiSessionEnv'
const harness = vi.hoisted(() => ({ const harness = vi.hoisted(() => ({
startOptions: null as unknown, startOptions: null as unknown,
cliArgs: [] as string[] cliArgs: [] as string[],
materialize: vi.fn(async () => true)
})) }))
vi.mock('@/claude/utils/startHappyServer', () => ({ vi.mock('@/claude/utils/startHappyServer', () => ({
@@ -28,12 +30,28 @@ vi.mock('@/utils/spawnHappyCLI', () => ({
import { buildHapiMcpBridge } from './buildHapiMcpBridge' import { buildHapiMcpBridge } from './buildHapiMcpBridge'
describe('buildHapiMcpBridge skill lookup config', () => { function createClient(options?: { pending?: boolean; sessionId?: string }): ApiSessionClient {
const client = {} as ApiSessionClient let pending = options?.pending ?? false
return {
sessionId: options?.sessionId ?? 'hub-session-1',
isPending: () => pending,
materialize: async () => {
const ok = await harness.materialize()
if (ok) {
pending = false
}
return ok
}
} as unknown as ApiSessionClient
}
describe('buildHapiMcpBridge skill lookup config', () => {
beforeEach(() => { beforeEach(() => {
harness.startOptions = null harness.startOptions = null
harness.cliArgs = [] harness.cliArgs = []
harness.materialize.mockReset()
harness.materialize.mockResolvedValue(true)
delete process.env[HAPI_SESSION_ID_ENV]
}) })
it('forwards the enabled HTTP tool through STDIO and auto-approves it', async () => { it('forwards the enabled HTTP tool through STDIO and auto-approves it', async () => {
@@ -42,7 +60,7 @@ describe('buildHapiMcpBridge skill lookup config', () => {
flavor: 'opencode' flavor: 'opencode'
} }
const bridge = await buildHapiMcpBridge(client, { skillLookup }) const bridge = await buildHapiMcpBridge(createClient(), { skillLookup })
expect(harness.startOptions).toEqual({ expect(harness.startOptions).toEqual({
emitTitleSummary: undefined, emitTitleSummary: undefined,
@@ -62,11 +80,31 @@ describe('buildHapiMcpBridge skill lookup config', () => {
}) })
it('does not expose skill_lookup for native-skill bridge callers', async () => { it('does not expose skill_lookup for native-skill bridge callers', async () => {
const bridge = await buildHapiMcpBridge(client) const bridge = await buildHapiMcpBridge(createClient())
expect(harness.cliArgs.at(-1)).toBe('change_title,display_image') expect(harness.cliArgs.at(-1)).toBe('change_title,display_image')
expect(bridge.mcpServers.hapi.tools).toEqual({ expect(bridge.mcpServers.hapi.tools).toEqual({
change_title: { approval_mode: 'approve' } change_title: { approval_mode: 'approve' }
}) })
}) })
it('materializes pending lazy sessions before starting the MCP server', async () => {
const client = createClient({ pending: true, sessionId: 'lazy-session-1' })
await buildHapiMcpBridge(client)
expect(harness.materialize).toHaveBeenCalledOnce()
expect(process.env[HAPI_SESSION_ID_ENV]).toBe('lazy-session-1')
expect(client.isPending()).toBe(false)
})
it('fails closed when pending materialization fails', async () => {
harness.materialize.mockResolvedValue(false)
const client = createClient({ pending: true, sessionId: 'lazy-session-fail' })
await expect(buildHapiMcpBridge(client)).rejects.toThrow(
'Failed to materialize HAPI session lazy-session-fail before MCP bridge start'
)
expect(process.env[HAPI_SESSION_ID_ENV]).toBeUndefined()
})
}) })
+15
View File
@@ -8,6 +8,7 @@
import { startHappyServer } from '@/claude/utils/startHappyServer'; import { startHappyServer } from '@/claude/utils/startHappyServer';
import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; import { getHappyCliCommand } from '@/utils/spawnHappyCLI';
import type { ApiSessionClient } from '@/api/apiSession'; import type { ApiSessionClient } from '@/api/apiSession';
import { exportHapiSessionEnv } from '@/agent/hapiSessionEnv';
/** /**
* MCP server entry configuration. * MCP server entry configuration.
@@ -56,11 +57,25 @@ export interface HapiMcpBridgeOptions {
* *
* This is the single source of truth for MCP bridge setup, * This is the single source of truth for MCP bridge setup,
* used by both local and remote launchers. * used by both local and remote launchers.
*
* Lazy Codex sessions stay pending until first materialization. We materialize
* here (before startHappyServer / agent spawn) so:
* - the hub row exists for REST self-targeting via HAPI_SESSION_ID
* - hapiMcpUrl from startHappyServer is persisted to the hub, not only local pending state
*/ */
export async function buildHapiMcpBridge( export async function buildHapiMcpBridge(
client: ApiSessionClient, client: ApiSessionClient,
options: HapiMcpBridgeOptions = {} options: HapiMcpBridgeOptions = {}
): Promise<HapiMcpBridge> { ): Promise<HapiMcpBridge> {
if (client.isPending()) {
const materialized = await client.materialize();
if (!materialized) {
throw new Error(`Failed to materialize HAPI session ${client.sessionId} before MCP bridge start`);
}
}
// Belt-and-suspenders: onMaterialized already exports; keep env set for non-lazy too.
exportHapiSessionEnv(client.sessionId);
const happyServer = await startHappyServer(client, { const happyServer = await startHappyServer(client, {
emitTitleSummary: options.emitTitleSummary, emitTitleSummary: options.emitTitleSummary,
skillLookup: options.skillLookup skillLookup: options.skillLookup
+92 -14
View File
@@ -6,7 +6,15 @@
* endpoint, not the session hook server on another loopback port in the same process. * endpoint, not the session hook server on another loopback port in the same process.
* *
* Usage: * Usage:
* # inside a wrapped session (self-targets via $HAPI_SESSION_ID — no list):
* bun scripts/tooling/hapi-display-image.mjs <image-path> [title]
* # explicit self:
* bun scripts/tooling/hapi-display-image.mjs self <image-path> [title]
* # explicit other session:
* bun scripts/tooling/hapi-display-image.mjs <session-id-prefix> <image-path> [title] * bun scripts/tooling/hapi-display-image.mjs <session-id-prefix> <image-path> [title]
*
* Self-resolution (tiann/hapi#1119): $HAPI_SESSION_ID → GET /api/sessions/:id directly.
* Prefer the MCP display_image tool when available; this script is the shell fallback.
*/ */
import { readFileSync, lstatSync } from 'node:fs' import { readFileSync, lstatSync } from 'node:fs'
@@ -16,16 +24,41 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
const HAPI_HOST = process.env.HAPI_HOST ?? 'http://localhost:3006' const HAPI_HOST = process.env.HAPI_HOST ?? 'http://localhost:3006'
const SETTINGS = process.env.HAPI_SETTINGS ?? `${process.env.HOME}/.hapi/settings.json` const SETTINGS = process.env.HAPI_SETTINGS ?? `${process.env.HOME}/.hapi/settings.json`
const sessionArg = process.argv[2] const SELF_TOKENS = new Set(['self', '@self', '@me', 'current', '-'])
const imagePath = process.argv[3]
const title = process.argv[4]
if (!sessionArg || !imagePath) { function isFile(p) {
console.error('usage: hapi-display-image.mjs <session-id-prefix> <image-path> [title]') try {
return lstatSync(p).isFile()
} catch {
return false
}
}
// Arg shapes (backward compatible):
// <image> [title] → self-target current session
// <self-token> <image> [title] → self-target, explicit
// <session-id-prefix> <image> [title] → explicit session
const args = process.argv.slice(2)
let sessionArg
let imagePath
let title
if (args.length > 0 && isFile(args[0]) && !SELF_TOKENS.has(args[0])) {
sessionArg = null
imagePath = args[0]
title = args[1]
} else {
sessionArg = args[0]
imagePath = args[1]
title = args[2]
}
if (!imagePath) {
console.error('usage: hapi-display-image.mjs [<session-id-prefix>|self] <image-path> [title]')
console.error(' or: HAPI_SESSION_ID=<uuid> hapi-display-image.mjs <image-path> [title]')
process.exit(2) process.exit(2)
} }
if (!lstatSync(imagePath).isFile()) { if (!isFile(imagePath)) {
console.error(`not a file: ${imagePath}`) console.error(`not a file: ${imagePath}`)
process.exit(2) process.exit(2)
} }
@@ -45,21 +78,66 @@ if (!authRes.ok) {
process.exit(3) process.exit(3)
} }
const { token: jwt } = await authRes.json() const { token: jwt } = await authRes.json()
const authHeaders = { Authorization: `Bearer ${jwt}` }
const sessionsRes = await fetch(`${HAPI_HOST}/api/sessions?limit=500`, { async function fetchSessionDetail(sessionId) {
headers: { Authorization: `Bearer ${jwt}` }, const detailRes = await fetch(`${HAPI_HOST}/api/sessions/${encodeURIComponent(sessionId)}`, {
}) headers: authHeaders,
const sessionsBody = await sessionsRes.json() })
const sessions = sessionsBody.sessions ?? sessionsBody if (!detailRes.ok) {
const session = sessions.find((s) => s.id.startsWith(sessionArg)) return null
if (!session) { }
const detailBody = await detailRes.json()
return detailBody.session ?? detailBody
}
async function listSessions() {
const sessionsRes = await fetch(`${HAPI_HOST}/api/sessions?limit=500`, {
headers: authHeaders,
})
const sessionsBody = await sessionsRes.json()
return sessionsBody.sessions ?? sessionsBody
}
let session
const wantsSelf = !sessionArg || SELF_TOKENS.has(sessionArg)
const hapiSessionId = process.env.HAPI_SESSION_ID?.trim()
if (wantsSelf) {
if (!hapiSessionId) {
console.error(
'cannot self-resolve session: $HAPI_SESSION_ID is not set. '
+ 'Pass an explicit <session-id-prefix>, or run inside a HAPI-wrapped agent session.',
)
process.exit(4)
}
// Preferred path (#1119): direct GET, no /api/sessions list.
session = await fetchSessionDetail(hapiSessionId)
if (!session) {
console.error(`GET /api/sessions/${hapiSessionId} failed (HAPI_SESSION_ID set but hub has no such row)`)
process.exit(4)
}
} else {
// Explicit id/prefix: full uuid → direct GET; otherwise list + prefix match.
const looksFull = /^[0-9a-f-]{36}$/i.test(sessionArg)
if (looksFull) {
session = await fetchSessionDetail(sessionArg)
}
if (!session) {
const sessions = await listSessions()
const listed = sessions.find((s) => typeof s.id === 'string' && s.id.startsWith(sessionArg))
if (!listed) {
console.error(`no session for prefix ${sessionArg}`) console.error(`no session for prefix ${sessionArg}`)
process.exit(4) process.exit(4)
}
// List summaries may omit hapiMcpUrl; detail fetch always has it when present.
session = await fetchSessionDetail(listed.id) ?? listed
}
} }
const mcpUrl = session.metadata?.hapiMcpUrl const mcpUrl = session.metadata?.hapiMcpUrl
if (!mcpUrl) { if (!mcpUrl) {
console.error('session has no hapiMcpUrl metadata (restart session CLI after MCP fix lands)') console.error('session has no hapiMcpUrl metadata (restart session CLI after MCP server start)')
process.exit(5) process.exit(5)
} }