Files
hapi/scripts/tooling/hapi-display-image.mjs
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

155 lines
5.3 KiB
JavaScript

#!/usr/bin/env bun
/**
* Post a local image inline to a HAPI session via the session CLI's display_image MCP tool.
*
* Uses session.metadata.hapiMcpUrl (published at MCP server start) so we hit the MCP
* 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'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
const HAPI_HOST = process.env.HAPI_HOST ?? 'http://localhost:3006'
const SETTINGS = process.env.HAPI_SETTINGS ?? `${process.env.HOME}/.hapi/settings.json`
const SELF_TOKENS = new Set(['self', '@self', '@me', 'current', '-'])
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 (!isFile(imagePath)) {
console.error(`not a file: ${imagePath}`)
process.exit(2)
}
const token = process.env.CLI_API_TOKEN ?? JSON.parse(readFileSync(SETTINGS, 'utf8')).cliApiToken
if (!token) {
console.error('missing CLI_API_TOKEN env and no cliApiToken in settings')
process.exit(2)
}
const authRes = await fetch(`${HAPI_HOST}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessToken: token }),
})
if (!authRes.ok) {
console.error('auth failed', authRes.status)
process.exit(3)
}
const { token: jwt } = await authRes.json()
const authHeaders = { Authorization: `Bearer ${jwt}` }
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 server start)')
process.exit(5)
}
console.error(`hapi-display-image: session=${session.id} mcp=${mcpUrl}`)
const client = new Client({ name: 'hapi-display-image', version: '1.0.0' }, { capabilities: {} })
const transport = new StreamableHTTPClientTransport(new URL(mcpUrl))
await client.connect(transport)
const result = await client.callTool({
name: 'display_image',
arguments: { path: imagePath, title: title ?? undefined },
})
await client.close()
console.log(JSON.stringify(result, null, 2))