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
+94 -16
View File
@@ -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 <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]
*
* 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 <session-id-prefix> <image-path> [title]')
function isFile(p) {
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)
}
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 <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}`)
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)
}