diff --git a/cli/README.md b/cli/README.md index a3979b79..a867f1a0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -114,6 +114,20 @@ See `src/configuration.ts` for all options. - `HAPI_WORKTREE_PATH` - Full worktree path. - `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 Data is stored in `~/.hapi/` (or `$HAPI_HOME`): diff --git a/cli/src/agent/hapiSessionEnv.ts b/cli/src/agent/hapiSessionEnv.ts new file mode 100644 index 00000000..03d89435 --- /dev/null +++ b/cli/src/agent/hapiSessionEnv.ts @@ -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; +} diff --git a/cli/src/agent/sessionFactory.test.ts b/cli/src/agent/sessionFactory.test.ts index cf6fcb01..386abe52 100644 --- a/cli/src/agent/sessionFactory.test.ts +++ b/cli/src/agent/sessionFactory.test.ts @@ -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 { return { @@ -91,6 +97,7 @@ describe('bootstrapExistingSession', () => { sessionSyncClientMock.mockReset() notifyRunnerSessionStartedMock.mockClear() readSettingsMock.mockReset() + delete process.env[HAPI_SESSION_ID_ENV] }) 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(process.env[HAPI_SESSION_ID_ENV]).toBe('hapi-session-1') expect(result.workingDirectory).toBe('/tmp/project') expect(sessionSyncClientMock).toHaveBeenCalledWith(session) expect(sessionClient.updateMetadata).toHaveBeenCalledOnce() @@ -210,6 +218,33 @@ describe('bootstrapLazySession', () => { sessionSyncClientMock.mockReset() notifyRunnerSessionStartedMock.mockClear() 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 () => { @@ -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') + }) +}) diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index a425421f..cc80cf33 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -12,8 +12,11 @@ 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 = { @@ -174,6 +177,8 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis const session = api.sessionSyncClient(sessionInfo) + exportHapiSessionEnv(sessionInfo.id) + await reportSessionStarted(sessionInfo.id, metadata) return { @@ -254,6 +259,10 @@ export async function bootstrapLazySession(options: SessionBootstrapOptions): Pr 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) } }) @@ -306,6 +315,9 @@ export async function bootstrapExistingSession(options: { const session = api.sessionSyncClient(sessionInfo) session.updateMetadata(buildUpdatedMetadata) + + exportHapiSessionEnv(sessionInfo.id) + await reportSessionStarted(sessionInfo.id, metadata) return { diff --git a/cli/src/codex/utils/buildHapiMcpBridge.test.ts b/cli/src/codex/utils/buildHapiMcpBridge.test.ts index 57ea9bf6..9a8086ea 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.test.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ApiSessionClient } from '@/api/apiSession' +import { HAPI_SESSION_ID_ENV } from '@/agent/hapiSessionEnv' const harness = vi.hoisted(() => ({ startOptions: null as unknown, - cliArgs: [] as string[] + cliArgs: [] as string[], + materialize: vi.fn(async () => true) })) vi.mock('@/claude/utils/startHappyServer', () => ({ @@ -28,12 +30,28 @@ vi.mock('@/utils/spawnHappyCLI', () => ({ import { buildHapiMcpBridge } from './buildHapiMcpBridge' -describe('buildHapiMcpBridge skill lookup config', () => { - const client = {} as ApiSessionClient +function createClient(options?: { pending?: boolean; sessionId?: string }): 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(() => { harness.startOptions = null 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 () => { @@ -42,7 +60,7 @@ describe('buildHapiMcpBridge skill lookup config', () => { flavor: 'opencode' } - const bridge = await buildHapiMcpBridge(client, { skillLookup }) + const bridge = await buildHapiMcpBridge(createClient(), { skillLookup }) expect(harness.startOptions).toEqual({ emitTitleSummary: undefined, @@ -62,11 +80,31 @@ describe('buildHapiMcpBridge skill lookup config', () => { }) 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(bridge.mcpServers.hapi.tools).toEqual({ 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() + }) }) diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 52342e05..5a84e28f 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -8,6 +8,7 @@ import { startHappyServer } from '@/claude/utils/startHappyServer'; import { getHappyCliCommand } from '@/utils/spawnHappyCLI'; import type { ApiSessionClient } from '@/api/apiSession'; +import { exportHapiSessionEnv } from '@/agent/hapiSessionEnv'; /** * MCP server entry configuration. @@ -56,11 +57,25 @@ export interface HapiMcpBridgeOptions { * * This is the single source of truth for MCP bridge setup, * 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( client: ApiSessionClient, options: HapiMcpBridgeOptions = {} ): Promise { + 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, { emitTitleSummary: options.emitTitleSummary, skillLookup: options.skillLookup diff --git a/scripts/tooling/hapi-display-image.mjs b/scripts/tooling/hapi-display-image.mjs index c2670585..5a7681e6 100644 --- a/scripts/tooling/hapi-display-image.mjs +++ b/scripts/tooling/hapi-display-image.mjs @@ -6,7 +6,15 @@ * endpoint, not the session hook server on another loopback port in the same process. * * Usage: + * # inside a wrapped session (self-targets via $HAPI_SESSION_ID — no list): + * bun scripts/tooling/hapi-display-image.mjs [title] + * # explicit self: + * bun scripts/tooling/hapi-display-image.mjs self [title] + * # explicit other session: * bun scripts/tooling/hapi-display-image.mjs [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' @@ -16,16 +24,41 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ const HAPI_HOST = process.env.HAPI_HOST ?? 'http://localhost:3006' const SETTINGS = process.env.HAPI_SETTINGS ?? `${process.env.HOME}/.hapi/settings.json` -const sessionArg = process.argv[2] -const imagePath = process.argv[3] -const title = process.argv[4] +const SELF_TOKENS = new Set(['self', '@self', '@me', 'current', '-']) -if (!sessionArg || !imagePath) { - console.error('usage: hapi-display-image.mjs [title]') +function isFile(p) { + try { + return lstatSync(p).isFile() + } catch { + return false + } +} + +// Arg shapes (backward compatible): +// [title] → self-target current session +// [title] → self-target, explicit +// [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 [|self] [title]') + console.error(' or: HAPI_SESSION_ID= hapi-display-image.mjs [title]') process.exit(2) } -if (!lstatSync(imagePath).isFile()) { +if (!isFile(imagePath)) { console.error(`not a file: ${imagePath}`) process.exit(2) } @@ -45,21 +78,66 @@ if (!authRes.ok) { process.exit(3) } const { token: jwt } = await authRes.json() +const authHeaders = { Authorization: `Bearer ${jwt}` } -const sessionsRes = await fetch(`${HAPI_HOST}/api/sessions?limit=500`, { - headers: { Authorization: `Bearer ${jwt}` }, -}) -const sessionsBody = await sessionsRes.json() -const sessions = sessionsBody.sessions ?? sessionsBody -const session = sessions.find((s) => s.id.startsWith(sessionArg)) -if (!session) { - console.error(`no session for prefix ${sessionArg}`) - process.exit(4) +async function fetchSessionDetail(sessionId) { + const detailRes = await fetch(`${HAPI_HOST}/api/sessions/${encodeURIComponent(sessionId)}`, { + headers: authHeaders, + }) + if (!detailRes.ok) { + return null + } + 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 , 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}`) + 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 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) }