mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
co-authored by
Cursor
Debian
parent
0834dc098c
commit
af8d160364
@@ -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`):
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}` }
|
||||||
|
|
||||||
|
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`, {
|
const sessionsRes = await fetch(`${HAPI_HOST}/api/sessions?limit=500`, {
|
||||||
headers: { Authorization: `Bearer ${jwt}` },
|
headers: authHeaders,
|
||||||
})
|
})
|
||||||
const sessionsBody = await sessionsRes.json()
|
const sessionsBody = await sessionsRes.json()
|
||||||
const sessions = sessionsBody.sessions ?? sessionsBody
|
return sessionsBody.sessions ?? sessionsBody
|
||||||
const session = sessions.find((s) => s.id.startsWith(sessionArg))
|
}
|
||||||
|
|
||||||
|
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) {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user