Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ Before commit/push/PR: use the **`pre-push-review`** skill (`~/.cursor/skills/pr
| Modify message handling | `hub/src/sync/messageService.ts` |
| Add notification type | `hub/src/notifications/` |
| Add shared type | `shared/src/types.ts`, `shared/src/schemas.ts` |
| Paint tappable URLs (Cursor only) | MCP `display_links` (Cursor sessions) or `hapi display-links` / `scripts/tooling/hapi-display-links.mjs` — construct hrefs by concatenation (`"tia"+"nn"`), never type landmine hosts in prose |

## Important patterns

Expand Down
2 changes: 1 addition & 1 deletion cli/src/agent/hapiSessionEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const HAPI_SESSION_ID_ENV = 'HAPI_SESSION_ID';
* here covers claude / codex / copilot / 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;
* Prefer the MCP `display_image` tool when available; Cursor sessions also get `display_links`.
* `HAPI_SESSION_ID` is the deterministic fallback for hub REST and shell tooling.
* To discover / read / message another session, prefer MCP `list_peers` /
* `inspect_peer` / `ping_peer` (or `hapi ping-peer --list` / `inspect-peer` /
Expand Down
1 change: 1 addition & 0 deletions cli/src/agent/runners/runAgentSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ describe('runAgentSession', () => {
await running

expect(harness.startHappyServerOptions).toEqual({
enableDisplayLinks: false,
skillLookup: {
workingDirectory: '/tmp/project',
flavor: 'acp'
Expand Down
1 change: 1 addition & 0 deletions cli/src/agent/runners/runAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export async function runAgentSession(opts: {
const permissionAdapter = new PermissionAdapter(session, backend, () => currentPermissionMode);

const happyServer = await startHappyServer(session, {
enableDisplayLinks: opts.agentType === 'cursor',
skillLookup: {
workingDirectory,
flavor: opts.agentType
Expand Down
64 changes: 56 additions & 8 deletions cli/src/claude/utils/startHappyServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,25 @@ describe('startHappyServer skill_lookup', () => {
await rm(sandboxDir, { recursive: true, force: true })
})

async function connect(enableSkillLookup = true): Promise<Client> {
async function connect(enableSkillLookup = true, extra: { enableDisplayLinks?: boolean; flavor?: string } = {}): Promise<Client> {
sendAgentMessage = vi.fn()
const sessionClient = {
updateMetadata: vi.fn(),
sendAgentMessage,
sendClaudeSessionMessage: vi.fn()
} as unknown as ApiSessionClient
const server = await startHappyServer(sessionClient, enableSkillLookup
? {
skillLookup: {
workingDirectory,
flavor: 'opencode'
const { flavor, enableDisplayLinks } = extra
const server = await startHappyServer(sessionClient, {
...(enableSkillLookup
? {
skillLookup: {
workingDirectory,
flavor: flavor ?? 'opencode'
}
}
}
: {})
: {}),
...(enableDisplayLinks !== undefined ? { enableDisplayLinks } : {}),
})
stopServer = server.stop

client = new Client(
Expand Down Expand Up @@ -116,6 +120,7 @@ describe('startHappyServer skill_lookup', () => {
'inspect_peer',
'list_peers'
])
expect(tools.tools.map((tool) => tool.name)).not.toContain('display_links')
})

it('displays audio through display_media and emits a generated media message', async () => {
Expand All @@ -138,6 +143,49 @@ describe('startHappyServer skill_lookup', () => {
}))
})

it('does not expose display_links for non-cursor flavors', async () => {
const mcp = await connect(true, { flavor: 'opencode' })
const tools = await mcp.listTools()
expect(tools.tools.map((tool) => tool.name)).not.toContain('display_links')
})

it('exposes display_links for cursor flavor', async () => {
const mcp = await connect(true, { flavor: 'cursor' })
const tools = await mcp.listTools()
expect(tools.tools.map((tool) => tool.name)).toContain('display_links')
})

it('paints display_links via sendAgentMessage with concatenated href bytes', async () => {
const mcp = await connect(false, { enableDisplayLinks: true })
const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516'

const result = await mcp.callTool({
name: 'display_links',
arguments: { urls: [{ href, title: 'Issue 1516' }] }
}) as ToolResult

expect(result.isError).toBe(false)
expect(result.content?.[0]?.text).toContain('Displayed 1 link')
expect(sendAgentMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'display-links',
urls: [{ href: 'https://github.com/tiann/hapi/issues/1516', title: 'Issue 1516' }],
}))
const payload = sendAgentMessage.mock.calls[0]?.[0] as { urls: Array<{ href: string }> }
expect(payload.urls[0]?.href).toBe(href)
expect(payload.urls[0]?.href).not.toContain('tian/hapi')
})

it('rejects javascript hrefs without emitting an agent message', async () => {
const mcp = await connect(false, { enableDisplayLinks: true })
const result = await mcp.callTool({
name: 'display_links',
arguments: { urls: [{ href: 'javascript:alert(1)' }] }
}) as ToolResult

expect(result.isError).toBe(true)
expect(sendAgentMessage).not.toHaveBeenCalled()
})

it('does not expose change_title when native ACP titles are enabled', async () => {
const sessionClient = {
updateMetadata: vi.fn(),
Expand Down
79 changes: 74 additions & 5 deletions cli/src/claude/utils/startHappyServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import {
registerGeneratedImage,
} from "@/modules/common/generatedImages";
import type { InlineMediaSource } from "@/modules/common/inlineMediaSource";
import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from "@/modules/common/displayImagePrompt";
import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_LINKS_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from "@/modules/common/displayImagePrompt";
import { buildDisplayLinksPayload, parseDisplayLinksInput } from "@hapi/protocol";
import { resolveSkill } from "@/modules/common/skills";
import {
INSPECT_PEER_TOOL_DESCRIPTION,
Expand All @@ -31,12 +32,24 @@ import { PingPeerError, formatInspectPeerReport, formatPeerSessionsList, inspect
type StartHappyServerOptions = {
emitTitleSummary?: boolean;
enableChangeTitle?: boolean;
/**
* Cursor-only (#1516): doubled-letter URL recall is a Cursor-routed failure mode.
* Defaults on when skillLookup.flavor === 'cursor'.
*/
enableDisplayLinks?: boolean;
skillLookup?: {
workingDirectory: string;
flavor: string;
};
};

function resolveEnableDisplayLinks(options: StartHappyServerOptions): boolean {
if (options.enableDisplayLinks !== undefined) {
return options.enableDisplayLinks;
}
return options.skillLookup?.flavor === 'cursor';
}

/** Registered on the MCP server, but never pre-approved via Claude --allowedTools. */
const CLAUDE_MANUAL_APPROVAL_HAPI_TOOLS = new Set([
'display_media',
Expand All @@ -61,7 +74,8 @@ function createHapiMcpServer(
client: ApiSessionClient,
emitTitleSummary: boolean,
enableChangeTitle: boolean,
skillLookup: StartHappyServerOptions['skillLookup']
skillLookup: StartHappyServerOptions['skillLookup'],
enableDisplayLinks: boolean
): McpServer {
const handler = async (title: string) => {
logger.debug('[hapiMCP] Changing title to:', title);
Expand Down Expand Up @@ -108,6 +122,16 @@ function createHapiMcpServer(
title: z.string().trim().min(1).max(255).optional().describe('Optional display title or filename'),
});

const displayLinksInputSchema: z.ZodTypeAny = z.object({
urls: z.array(z.union([
z.object({
href: z.string().describe('http(s) URL to paint. Construct by concatenation for landmine strings (tia+nn), never copy from model prose.'),
title: z.string().trim().min(1).max(255).optional().describe('Optional link label shown on the card'),
}),
z.string(),
])).min(1).max(20).describe('One or more http(s) URLs to paint as tappable cards'),
});

const pingPeerInputSchema: z.ZodTypeAny = z.object({
sessionIdPrefix: z.string().trim().min(1).describe(SESSION_ID_PREFIX_PARAM_DESCRIPTION),
message: z.string().min(1).describe('Message text to deliver to the target session'),
Expand Down Expand Up @@ -291,6 +315,46 @@ function createHapiMcpServer(
}
});

if (enableDisplayLinks) {
mcp.registerTool<any, any>('display_links', {
description: `Paint clickable http(s) URL cards into the current HAPI chat without a fake user turn. Cursor-only: other flavors type URLs fine. ${DISPLAY_LINKS_PROMPT_CURSOR}`,
title: 'Display Links',
inputSchema: displayLinksInputSchema,
}, async (args: { urls: Array<{ href: string; title?: string } | string> }) => {
logger.debug('[hapiMCP] Display links:', args.urls);

try {
const urls = parseDisplayLinksInput(args.urls);
client.sendAgentMessage(buildDisplayLinksPayload({
urls,
id: randomUUID(),
}));

return {
content: [
{
type: 'text' as const,
text: `Displayed ${urls.length} link${urls.length === 1 ? '' : 's'}`,
},
],
isError: false,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.debug('[hapiMCP] Failed to display links:', message);
return {
content: [
{
type: 'text' as const,
text: `Failed to display links: ${message}`,
},
],
isError: true,
};
}
});
}

mcp.registerTool<any, any>('ping_peer', {
description: PING_PEER_TOOL_DESCRIPTION,
title: 'Ping Peer Session',
Expand Down Expand Up @@ -475,11 +539,12 @@ function readMcpSessionId(req: IncomingMessage): string | undefined {
export async function startHappyServer(client: ApiSessionClient, options: StartHappyServerOptions = {}) {
const emitTitleSummary = options.emitTitleSummary ?? true;
const enableChangeTitle = options.enableChangeTitle ?? true;
const enableDisplayLinks = resolveEnableDisplayLinks(options);
const transports = new Map<string, StreamableHTTPServerTransport>();
const mcps = new Map<string, McpServer>();

const createMcpTransport = () => {
const mcp = createHapiMcpServer(client, emitTitleSummary, enableChangeTitle, options.skillLookup);
const mcp = createHapiMcpServer(client, emitTitleSummary, enableChangeTitle, options.skillLookup, enableDisplayLinks);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
Expand Down Expand Up @@ -534,8 +599,12 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH
}));

const toolNames = enableChangeTitle
? ['change_title', 'display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer']
: ['display_image', 'display_video', 'display_media', 'list_peers', 'ping_peer', 'inspect_peer'];
? ['change_title', 'display_image', 'display_video', 'display_media']
: ['display_image', 'display_video', 'display_media'];
if (enableDisplayLinks) {
toolNames.push('display_links');
}
toolNames.push('list_peers', 'ping_peer', 'inspect_peer');
if (options.skillLookup) {
toolNames.push('skill_lookup');
}
Expand Down
23 changes: 22 additions & 1 deletion cli/src/codex/happyMcpStdioBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,15 @@ describe('runHappyMcpStdioBridge tool forwarding', () => {
'--url',
'http://127.0.0.1:43006',
'--tools',
'change_title,display_image,display_video,display_media,skill_lookup'
'change_title,display_image,display_video,display_media,display_links,skill_lookup'
])

expect([...harness.tools.keys()]).toEqual([
'change_title',
'display_image',
'display_video',
'display_media',
'display_links',
'skill_lookup'
])

Expand All @@ -87,6 +88,26 @@ describe('runHappyMcpStdioBridge tool forwarding', () => {
expect([...harness.tools.keys()]).toEqual(['change_title', 'display_image', 'display_video'])
})

it('forwards display_links arguments unchanged', async () => {
await runHappyMcpStdioBridge([
'--url',
'http://127.0.0.1:43006',
'--tools',
'display_links'
])

const handler = harness.tools.get('display_links')
const href = 'https://github.com/tia' + 'nn' + '/hapi/issues/1516'
await expect(handler?.({ urls: [{ href, title: 'Issue 1516' }] })).resolves.toEqual({
content: [{ type: 'text', text: 'forwarded' }],
isError: false
})
expect(harness.callTool).toHaveBeenCalledWith({
name: 'display_links',
arguments: { urls: [{ href, title: 'Issue 1516' }] }
})
})

it('forwards display_media arguments unchanged', async () => {
await runHappyMcpStdioBridge([
'--url',
Expand Down
35 changes: 34 additions & 1 deletion cli/src/codex/happyMcpStdioBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* HAPI MCP STDIO Bridge
*
* Minimal STDIO MCP server exposing HAPI tools such as `change_title`, `display_image`, `display_video`, `display_media`, `list_peers`, `ping_peer`, and `inspect_peer`.
* `display_links` is Cursor-only and is registered only when `--tools` includes it.
* On invocation it forwards the tool call to an existing HAPI HTTP MCP server
* using the StreamableHTTPClientTransport.
*
Expand All @@ -16,7 +17,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { z } from 'zod';
import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from '@/modules/common/displayImagePrompt';
import { DISPLAY_IMAGE_PROMPT_CURSOR, DISPLAY_LINKS_PROMPT_CURSOR, DISPLAY_MEDIA_PROMPT_CURSOR, DISPLAY_VIDEO_PROMPT_CURSOR } from '@/modules/common/displayImagePrompt';
import {
INSPECT_PEER_TOOL_DESCRIPTION,
PING_PEER_TOOL_DESCRIPTION,
Expand Down Expand Up @@ -201,6 +202,38 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
message: z.string().min(1).describe('Message text to deliver to the target session'),
});

const displayLinksInputSchema: z.ZodTypeAny = z.object({
urls: z.array(z.union([
z.object({
href: z.string().describe('http(s) URL to paint. Construct by concatenation for landmine strings (tia+nn), never copy from model prose.'),
title: z.string().trim().min(1).max(255).optional().describe('Optional link label shown on the card'),
}),
z.string(),
])).min(1).max(20).describe('One or more http(s) URLs to paint as tappable cards'),
});

if (toolNames.has('display_links')) {
server.registerTool<any, any>(
'display_links',
{
description: `Paint clickable http(s) URL cards into the current HAPI chat. ${DISPLAY_LINKS_PROMPT_CURSOR}`,
title: 'Display Links',
inputSchema: displayLinksInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
return await client.callTool({ name: 'display_links', arguments: args }) as any;
} catch (error) {
return {
content: [{ type: 'text' as const, text: `Failed to display links: ${error instanceof Error ? error.message : String(error)}` }],
isError: true,
};
}
}
);
}

if (toolNames.has('ping_peer')) {
server.registerTool<any, any>(
'ping_peer',
Expand Down
Loading
Loading