feat: rich composer session @-mentions + inspect_peer (#1228)

* feat(web): feature-flagged rich composer for inline session @ mentions

Custom segmented contenteditable (not TipTap) inserts caret-local session
atoms from the existing @ picker and serializes to markdown links on send.
Textarea path remains default until flag parity dogfood.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): rich composer mention boundary + #1215 refs

Treat U+FFFC mirror atoms as word boundaries so @ after a session
token still opens autocomplete. Point comments at Fixes #1215.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): peer-stack e2e for rich composer session @ mentions (#1215)

Smoke: flag on, @ picker inserts inline session atom chip (not prose dump).
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): preserve newlines in rich composer Enter-newline mode

Chromium splits contenteditable on Enter into block divs; serialize those
as \\n and insert <br> when parent leaves Enter unhandled (Shift+Enter /
enter-inserts-newline).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): show @ badge when rich composer mentions flag is on

Dogfood was invisible: flag-off looks like a normal textarea, and flag-on
had no chrome. Surface a small @ badge when enabled.

* fix(web): rich session composer on by default (not a user setting)

The plan dual-path was an engineering kill-switch, not an opt-in. Default
to the segmented composer; only richMentions=0 disables. Drop the flag
badge and record a peer-stack motion proof covering chips + baseline UX.

* fix(web): make rich composer Shift+Enter create a visible newline

Trailing <br>+empty text node was a silent no-op at EOL. Use
insertLineBreak (ZWSP pad fallback), assert real \\n in peer e2e.

* feat(web): hover tooltips on rich composer session chips

Show full title, status, short id, and path on chip hover via a portal
bubble fed by live useSessions lookup (drafts fall back to title + id).

* fix(web): dismiss rich composer chip tooltips on mouse leave

contenteditable pointerout/relatedTarget was flaky so tips stuck after
leaving the chip. Hit-test on pointermove, clear on prose/input/leave.

* fix(web): address cold-review Blocker/Majors on rich composer

Exclude peer e2e from default Playwright; force plain-text paste; restore
newline hard-stop in findActiveWord; fix root-anchored selection mapping
and nested-block serialize; cover with unit tests.

* chore: drop accidental .cursor files from rich-composer tip

* fix(web): close remaining cold-review gaps on rich composer

Drop absolute peer e2e tooling imports, prove chip→markdown send, and
harden paste/EOL/focus/tooltip/Enter edges before Meta rematerialize.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: absorb soup playwright.config union for clean remat

Keep fork peer-stack timeouts/annotated-video wiring and add testIgnore
for e2e/peer so the next driver rematerialize does not conflict.

Co-authored-by: Cursor <cursoragent@cursor.com>

* revert: drop fork playwright tooling from upstreamable tip

Peer-stack annotated-video + HAPI_PEER wiring stay on fork main / soup.
Product tip only needs testIgnore for e2e/peer (see docs/tooling/peer-stack.md).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): fix rich composer Shift+Enter double newline and paste space

Prefer manual newline+pad over execCommand insertLineBreak, and stop
applying autocomplete trailing-space on paste/drop paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): pad EOL Shift+Enter after Range.insertNode split

insertNode always leaves an empty text sibling, so !nextSibling never
saw EOL; detect meaningful trailing content and cover with jsdom tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): drop custom onDrop from rich composer

Intercepting drop without caretRangeFromPoint landed text at EOF or
no-oped in-editor moves. Native CE drop is enough for #1215; paste
still forces plain text.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): sidebar-parity tooltips on rich composer session chips

Reuse SessionRowSummary (flavor, thinking/attention, schedule, todos,
relative ago, path) for chip hover so the tip matches the session list.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: keep peer-stack e2e off the upstreamable tip

Peer specs and playwright.peer.config stay on fork main per
docs/tooling/peer-stack.md; default config still testIgnore's e2e/peer.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: cite sessions with UUID wire + inspect_peer for agent/overseer

Rich composer chips already serialize to [title](/sessions/<id>); flush
before send so the agent prompt never gets title-only chip text. Add
inspect_peer (MCP + hapi inspect-peer) as the read twin of ping_peer so
that same id is immediately usable for overseer/agent peer lookup, with
system-prompt glue from citations to inspect/ping.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): gate inspect_peer behind permission approval

Cross-session history reads need the same prompt path as ping_peer:
keep inspect_peer off Claude --allowedTools and treat it as sensitive
in ACP/OpenCode read-only mode so prompt injection cannot silently
enumerate peer transcripts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: clarify playwright peer testIgnore is upstream-safe

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep session UUIDs on rich composer copy/cut/paste

Copy/cut write wire markdown so chips do not collapse to @title-only
clipboard text; paste reparses session links back into atoms.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-31 19:42:56 +08:00
committed by GitHub
co-authored by Cursor
parent c7afc50321
commit bc543dc494
35 changed files with 2937 additions and 273 deletions
+2 -2
View File
@@ -15,8 +15,8 @@ export const HAPI_SESSION_ID_ENV = 'HAPI_SESSION_ID';
*
* 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.
* To message another session, prefer MCP `ping_peer` or `hapi ping-peer` - do not
* reinvent JWT+curl.
* To read or message another session, prefer MCP `inspect_peer` / `ping_peer`
* (or `hapi inspect-peer` / `hapi ping-peer`) - do not reinvent JWT+curl.
*
* 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
@@ -74,7 +74,7 @@ vi.mock('@/claude/utils/startHappyServer', () => ({
harness.startHappyServerOptions = options
return {
url: 'http://127.0.0.1:1234',
toolNames: ['change_title', 'display_image', 'ping_peer', 'skill_lookup'],
toolNames: ['change_title', 'display_image', 'ping_peer', 'inspect_peer', 'skill_lookup'],
stop: harness.stopServer
}
})
@@ -167,7 +167,7 @@ describe('runAgentSession', () => {
'--url',
'http://127.0.0.1:1234',
'--tools',
'change_title,display_image,ping_peer,skill_lookup'
'change_title,display_image,ping_peer,inspect_peer,skill_lookup'
])
expect(harness.newSessionOptions).toMatchObject({
cwd: '/tmp/project',
+10 -4
View File
@@ -108,7 +108,8 @@ describe('startHappyServer skill_lookup', () => {
expect(tools.tools.map((tool) => tool.name)).toEqual([
'change_title',
'display_image',
'ping_peer'
'ping_peer',
'inspect_peer'
])
})
@@ -126,18 +127,23 @@ describe('startHappyServer skill_lookup', () => {
await mcp.connect(new StreamableHTTPClientTransport(new URL(server.url)))
const tools = await mcp.listTools()
expect(server.toolNames).toEqual(['display_image', 'ping_peer'])
expect(tools.tools.map((tool) => tool.name)).toEqual(['display_image', 'ping_peer'])
expect(server.toolNames).toEqual(['display_image', 'ping_peer', 'inspect_peer'])
expect(tools.tools.map((tool) => tool.name)).toEqual([
'display_image',
'ping_peer',
'inspect_peer'
])
})
})
describe('toClaudeAllowedHapiMcpTools', () => {
it('keeps ping_peer registered but out of Claude --allowedTools', () => {
it('keeps ping_peer and inspect_peer registered but out of Claude --allowedTools', () => {
expect(toClaudeAllowedHapiMcpTools([
'change_title',
'display_image',
'ping_peer',
'inspect_peer',
'skill_lookup'
])).toEqual([
'mcp__hapi__change_title',
+56 -7
View File
@@ -14,7 +14,7 @@ import { ApiSessionClient } from "@/api/apiSession";
import { randomUUID } from "node:crypto";
import { detectImageMimeType, registerGeneratedImage } from "@/modules/common/generatedImages";
import { resolveSkill } from "@/modules/common/skills";
import { PingPeerError, pingPeer } from "@/modules/pingPeer/pingPeer";
import { PingPeerError, formatInspectPeerReport, inspectPeer, pingPeer } from "@/modules/pingPeer/pingPeer";
type StartHappyServerOptions = {
emitTitleSummary?: boolean;
@@ -26,11 +26,12 @@ type StartHappyServerOptions = {
};
/** Registered on the MCP server, but never pre-approved via Claude --allowedTools. */
const CLAUDE_MANUAL_APPROVAL_HAPI_TOOLS = new Set(['ping_peer']);
const CLAUDE_MANUAL_APPROVAL_HAPI_TOOLS = new Set(['ping_peer', 'inspect_peer']);
/**
* Map HAPI MCP tool names to Claude `--allowedTools` entries.
* Keeps `ping_peer` off the auto-allow list so resume+inject still prompts.
* Keeps `ping_peer` / `inspect_peer` off the auto-allow list so cross-session
* write (resume+inject) and read (peer histories) still prompt.
*/
export function toClaudeAllowedHapiMcpTools(toolNames: string[]): string[] {
return toolNames
@@ -77,11 +78,20 @@ function createHapiMcpServer(
const pingPeerInputSchema: z.ZodTypeAny = z.object({
sessionIdPrefix: z.string().trim().min(1).describe(
'Target HAPI session id or unique id prefix (another session - not this chat)'
'Target HAPI session id or unique id prefix (another session - not this chat). Prefer the full UUID from a [title](/sessions/<id>) citation.'
),
message: z.string().min(1).describe('Message text to deliver to the target session'),
});
const inspectPeerInputSchema: z.ZodTypeAny = z.object({
sessionIdPrefix: z.string().trim().min(1).describe(
'Target HAPI session id or unique id prefix. Prefer the full UUID from a [title](/sessions/<id>) citation.'
),
messageLimit: z.number().int().min(1).max(100).optional().describe(
'Recent message page size (default 30, max 100). Text snippets only.'
),
});
const skillLookupInputSchema: z.ZodTypeAny = z.object({
name: z.string().trim().min(1).max(128).describe('Exact skill name shown by HAPI skill autocomplete'),
});
@@ -184,7 +194,7 @@ function createHapiMcpServer(
});
mcp.registerTool<any, any>('ping_peer', {
description: 'Send a message to another HAPI session (peer handoff / nudge). Resolves by session id prefix, resumes if inactive, then POSTs the message on the same hub/namespace. Prefer this (or `hapi ping-peer`) over reinventing JWT+curl. Targets another session - not the current chat.',
description: 'Send a message to another HAPI session (peer handoff / nudge). Resolves by session id prefix, resumes if inactive, then POSTs the message on the same hub/namespace. Prefer this (or `hapi ping-peer`) over reinventing JWT+curl. Targets another session - not the current chat. When the user cites [title](/sessions/<id>), pass that <id> as sessionIdPrefix.',
title: 'Ping Peer Session',
inputSchema: pingPeerInputSchema,
}, async (args: { sessionIdPrefix: string; message: string }) => {
@@ -222,6 +232,45 @@ function createHapiMcpServer(
}
});
mcp.registerTool<any, any>('inspect_peer', {
description: 'Read another HAPI session (metadata + recent message text). Resolves by session id / prefix on the same hub/namespace. Read-only: does not resume. Prefer this (or `hapi inspect-peer`) over JWT+curl. When the user cites [title](/sessions/<id>), pass that <id> as sessionIdPrefix.',
title: 'Inspect Peer Session',
inputSchema: inspectPeerInputSchema,
}, async (args: { sessionIdPrefix: string; messageLimit?: number }) => {
logger.debug('[hapiMCP] inspect_peer:', args.sessionIdPrefix);
try {
const result = await inspectPeer({
sessionIdPrefix: args.sessionIdPrefix,
messageLimit: args.messageLimit,
});
return {
content: [
{
type: 'text' as const,
text: formatInspectPeerReport(result),
},
],
isError: false,
};
} catch (error) {
const message = error instanceof PingPeerError
? error.message
: error instanceof Error
? error.message
: String(error);
logger.debug('[hapiMCP] inspect_peer failed:', message);
return {
content: [
{
type: 'text' as const,
text: `Failed to inspect peer: ${message}`,
},
],
isError: true,
};
}
});
if (skillLookup) {
mcp.registerTool<any, any>('skill_lookup', {
description: 'Load a HAPI skill by exact name. When a user message starts with $name, call this tool with that name before acting.',
@@ -342,8 +391,8 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH
}));
const toolNames = enableChangeTitle
? ['change_title', 'display_image', 'ping_peer']
: ['display_image', 'ping_peer'];
? ['change_title', 'display_image', 'ping_peer', 'inspect_peer']
: ['display_image', 'ping_peer', 'inspect_peer'];
if (options.skillLookup) {
toolNames.push('skill_lookup');
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { shouldIncludeCoAuthoredBy } from "./claudeSettings";
const BASE_SYSTEM_PROMPT = (() => trimIdent(`
Use the title tool sparingly. For a new chat, call the tool "mcp__hapi__change_title" once after the user's initial request is clear, and set a concise task title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading.
When you create or find a local image file that the user should see, call the tool "mcp__hapi__display_image" with the image path so HAPI can show it inline.
To message another HAPI session (peer handoff / nudge), call "mcp__hapi__ping_peer" with sessionIdPrefix and message - do not reinvent JWT+curl. Shell fallback: \`hapi ping-peer <prefix> <message>\`.
When the user cites another HAPI session as [title](/sessions/<id>) (or a bare /sessions/<id>), extract that <id>. Call "mcp__hapi__inspect_peer" with sessionIdPrefix=<id> to read that session's metadata and recent messages. Call "mcp__hapi__ping_peer" with sessionIdPrefix=<id> and a message to nudge or hand off. Do not reinvent JWT+curl. Shell fallbacks: \`hapi inspect-peer <id>\` / \`hapi ping-peer <id> <message>\`.
`))();
/**
+9 -3
View File
@@ -53,13 +53,14 @@ describe('runHappyMcpStdioBridge tool forwarding', () => {
'--url',
'http://127.0.0.1:43006',
'--tools',
'change_title,display_image,ping_peer,skill_lookup'
'change_title,display_image,ping_peer,inspect_peer,skill_lookup'
])
expect([...harness.tools.keys()]).toEqual([
'change_title',
'display_image',
'ping_peer',
'inspect_peer',
'skill_lookup'
])
@@ -80,9 +81,14 @@ describe('runHappyMcpStdioBridge tool forwarding', () => {
'--url',
'http://127.0.0.1:43006',
'--tools',
'change_title,display_image,ping_peer'
'change_title,display_image,ping_peer,inspect_peer'
])
expect([...harness.tools.keys()]).toEqual(['change_title', 'display_image', 'ping_peer'])
expect([...harness.tools.keys()]).toEqual([
'change_title',
'display_image',
'ping_peer',
'inspect_peer'
])
})
})
+37 -3
View File
@@ -17,7 +17,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { z } from 'zod';
const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'ping_peer'];
const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'ping_peer', 'inspect_peer'];
function parseArgs(argv: string[]): { url: string | null; toolNames: Set<string> } {
let url: string | null = null;
@@ -135,7 +135,7 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
const pingPeerInputSchema: z.ZodTypeAny = z.object({
sessionIdPrefix: z.string().trim().min(1).describe(
'Target HAPI session id or unique id prefix (another session - not this chat)'
'Target HAPI session id or unique id prefix (another session - not this chat). Prefer the full UUID from a [title](/sessions/<id>) citation.'
),
message: z.string().min(1).describe('Message text to deliver to the target session'),
});
@@ -144,7 +144,7 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
server.registerTool<any, any>(
'ping_peer',
{
description: 'Send a message to another HAPI session (peer handoff / nudge). Resolves by session id prefix, resumes if inactive, then POSTs the message on the same hub/namespace. Prefer this (or `hapi ping-peer`) over reinventing JWT+curl. Targets another session - not the current chat.',
description: 'Send a message to another HAPI session (peer handoff / nudge). Resolves by session id prefix, resumes if inactive, then POSTs the message on the same hub/namespace. Prefer this (or `hapi ping-peer`) over reinventing JWT+curl. Targets another session - not the current chat. When the user cites [title](/sessions/<id>), pass that <id> as sessionIdPrefix.',
title: 'Ping Peer Session',
inputSchema: pingPeerInputSchema,
},
@@ -165,6 +165,40 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise<void> {
);
}
const inspectPeerInputSchema: z.ZodTypeAny = z.object({
sessionIdPrefix: z.string().trim().min(1).describe(
'Target HAPI session id or unique id prefix. Prefer the full UUID from a [title](/sessions/<id>) citation.'
),
messageLimit: z.number().int().min(1).max(100).optional().describe(
'Recent message page size (default 30, max 100). Text snippets only.'
),
});
if (toolNames.has('inspect_peer')) {
server.registerTool<any, any>(
'inspect_peer',
{
description: 'Read another HAPI session (metadata + recent message text). Resolves by session id / prefix on the same hub/namespace. Read-only: does not resume. Prefer this (or `hapi inspect-peer`) over JWT+curl. When the user cites [title](/sessions/<id>), pass that <id> as sessionIdPrefix.',
title: 'Inspect Peer Session',
inputSchema: inspectPeerInputSchema,
},
async (args: Record<string, unknown>) => {
try {
const client = await ensureHttpClient();
const response = await client.callTool({ name: 'inspect_peer', arguments: args });
return response as any;
} catch (error) {
return {
content: [
{ type: 'text' as const, text: `Failed to inspect peer: ${error instanceof Error ? error.message : String(error)}` },
],
isError: true,
};
}
}
);
}
const skillLookupInputSchema: z.ZodTypeAny = z.object({
name: z.string().trim().min(1).max(128).describe('Exact skill name shown by HAPI skill autocomplete'),
});
@@ -14,8 +14,8 @@ vi.mock('@/claude/utils/startHappyServer', () => ({
return {
url: 'http://127.0.0.1:43006/',
toolNames: options.skillLookup
? ['change_title', 'display_image', 'ping_peer', 'skill_lookup']
: ['change_title', 'display_image', 'ping_peer'],
? ['change_title', 'display_image', 'ping_peer', 'inspect_peer', 'skill_lookup']
: ['change_title', 'display_image', 'ping_peer', 'inspect_peer'],
stop: vi.fn()
}
})
@@ -71,7 +71,7 @@ describe('buildHapiMcpBridge skill lookup config', () => {
'--url',
'http://127.0.0.1:43006/',
'--tools',
'change_title,display_image,ping_peer,skill_lookup'
'change_title,display_image,ping_peer,inspect_peer,skill_lookup'
])
expect(bridge.mcpServers.hapi.tools).toEqual({
change_title: { approval_mode: 'approve' },
@@ -82,7 +82,7 @@ describe('buildHapiMcpBridge skill lookup config', () => {
it('does not expose skill_lookup for native-skill bridge callers', async () => {
const bridge = await buildHapiMcpBridge(createClient())
expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,ping_peer')
expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,ping_peer,inspect_peer')
expect(bridge.mcpServers.hapi.tools).toEqual({
change_title: { approval_mode: 'approve' }
})
+3 -2
View File
@@ -95,8 +95,9 @@ export async function buildHapiMcpBridge(
approval_mode: 'approve'
};
}
// ping_peer is registered on the HTTP MCP server / stdio bridge, but is not
// auto-approved: it targets another session (resume + inject message).
// ping_peer / inspect_peer are registered on the HTTP MCP server / stdio
// bridge, but are not auto-approved: they target another session (resume +
// inject, or read peer histories).
if (options.skillLookup) {
tools.skill_lookup = {
approval_mode: 'approve'
+1 -1
View File
@@ -19,7 +19,7 @@ export const TITLE_INSTRUCTION = trimIdent(`
Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording.
Rename only when the user's primary objective changes substantially and the existing title would be misleading.
When you create or find a local image file that the user should see, call functions.hapi__display_image with the image path. If that exact tool name is unavailable, use an equivalent alias such as hapi__display_image, mcp__hapi__display_image, or hapi_display_image.
To message another HAPI session (peer handoff / nudge), call functions.hapi__ping_peer with sessionIdPrefix and message - do not reinvent JWT+curl. Shell fallback: hapi ping-peer <prefix> <message>.
When the user cites another HAPI session as [title](/sessions/<id>) (or a bare /sessions/<id>), extract that <id>. Call functions.hapi__inspect_peer with sessionIdPrefix=<id> to read metadata and recent messages; call functions.hapi__ping_peer with sessionIdPrefix=<id> and a message to nudge or hand off. Prefer these over JWT+curl. Shell fallbacks: hapi inspect-peer <id> / hapi ping-peer <id> <message>.
`);
/**
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { PingPeerError } from '@/modules/pingPeer/pingPeer'
import { parseInspectPeerArgs } from './inspectPeer'
describe('parseInspectPeerArgs', () => {
it('parses session id and optional limit', () => {
expect(parseInspectPeerArgs(['7d55ed21-8a9f-4309-b4f8-30069df36b4b', '--limit', '20'])).toEqual({
help: false,
sessionIdPrefix: '7d55ed21-8a9f-4309-b4f8-30069df36b4b',
messageLimit: 20
})
})
it('parses --limit= form and help', () => {
expect(parseInspectPeerArgs(['--help'])).toEqual({ help: true })
expect(parseInspectPeerArgs(['aaaa', '--limit=5']).messageLimit).toBe(5)
})
it('rejects unknown flags', () => {
expect(() => parseInspectPeerArgs(['aaaa', '--resume'])).toThrow(PingPeerError)
})
})
+116
View File
@@ -0,0 +1,116 @@
import chalk from 'chalk'
import { initializeToken } from '@/ui/tokenInit'
import {
PingPeerError,
exitCodeForPingPeerError,
formatInspectPeerReport,
inspectPeer
} from '@/modules/pingPeer/pingPeer'
import type { CommandDefinition } from './types'
type ParsedInspectPeerArgs = {
help: boolean
sessionIdPrefix?: string
messageLimit?: number
}
function showHelp(): void {
console.log(`
${chalk.bold('hapi inspect-peer')} - Read another HAPI session's metadata + recent messages
${chalk.bold('Usage:')}
hapi inspect-peer <session-id-or-prefix>
hapi inspect-peer <session-id-or-prefix> --limit 50
${chalk.bold('Notes:')}
Read-only twin of ping-peer. Prefer this (or MCP inspect_peer) over JWT+curl.
Resolves by id prefix (8 chars OK; full UUID best). Same hub token/namespace.
Does NOT resume inactive sessions.
When a user cites [title](/sessions/<id>), pass that <id> here.
${chalk.bold('Env:')}
HAPI_API_URL / CLI_API_TOKEN (or ~/.hapi/settings.json via \`hapi auth login\`)
`)
}
export function parseInspectPeerArgs(args: string[]): ParsedInspectPeerArgs {
const result: ParsedInspectPeerArgs = { help: false }
for (let i = 0; i < args.length; i++) {
const arg = args[i]!
if (arg === '--help' || arg === '-h') {
result.help = true
continue
}
if (arg === '--limit') {
const value = args[++i]
if (!value) {
throw new PingPeerError('bad_args', '--limit requires a number')
}
result.messageLimit = Number(value)
continue
}
if (arg.startsWith('--limit=')) {
result.messageLimit = Number(arg.slice('--limit='.length))
continue
}
if (arg.startsWith('-')) {
throw new PingPeerError('bad_args', `unexpected flag: ${arg}`)
}
if (!result.sessionIdPrefix) {
result.sessionIdPrefix = arg
continue
}
throw new PingPeerError('bad_args', `unexpected arg: ${arg}`)
}
if (result.messageLimit !== undefined && !Number.isFinite(result.messageLimit)) {
throw new PingPeerError('bad_args', '--limit must be a number')
}
return result
}
export async function handleInspectPeerCommand(args: string[]): Promise<void> {
const parsed = parseInspectPeerArgs(args)
if (parsed.help) {
showHelp()
return
}
await initializeToken()
if (!parsed.sessionIdPrefix) {
showHelp()
throw new PingPeerError('bad_args', 'missing session id; usage: hapi inspect-peer <session-id>')
}
const result = await inspectPeer({
sessionIdPrefix: parsed.sessionIdPrefix,
messageLimit: parsed.messageLimit
})
console.log(formatInspectPeerReport(result))
}
export const inspectPeerCommand: CommandDefinition = {
name: 'inspect-peer',
requiresRuntimeAssets: false,
run: async ({ commandArgs }) => {
try {
await handleInspectPeerCommand(commandArgs)
} catch (error) {
if (error instanceof PingPeerError) {
console.error(chalk.red('hapi inspect-peer:'), error.message)
process.exit(exitCodeForPingPeerError(error))
}
console.error(
chalk.red('hapi inspect-peer:'),
error instanceof Error ? error.message : 'Unknown error'
)
if (process.env.DEBUG) {
console.error(error)
}
process.exit(1)
}
}
}
+3 -1
View File
@@ -16,6 +16,7 @@ import { mcpCommand } from './mcp'
import { notifyCommand } from './notify'
import { hubCommand } from './hub'
import { pingPeerCommand } from './pingPeer'
import { inspectPeerCommand } from './inspectPeer'
import type { CommandContext, CommandDefinition } from './types'
// Gemini CLI was sunset (Google stopped serving the consumer Gemini CLI on
@@ -52,7 +53,8 @@ const COMMANDS: CommandDefinition[] = [
resumeCommand,
runnerCommand,
notifyCommand,
pingPeerCommand
pingPeerCommand,
inspectPeerCommand
]
const commandMap = new Map<string, CommandDefinition>()
@@ -61,3 +61,23 @@ describe('resolveToolAutoApprovalDecision ping_peer', () => {
expect(resolveToolAutoApprovalDecision('read-only', 'grep', 'call-2')).toBe('approved')
})
})
describe('resolveToolAutoApprovalDecision inspect_peer', () => {
it.each([
'inspect_peer',
'mcp__hapi__inspect_peer',
'hapi_inspect_peer',
'Inspect Peer Session'
])('does not auto-approve %s in default mode', (toolName) => {
expect(resolveToolAutoApprovalDecision('default', toolName, 'call-1')).toBeNull()
})
it.each([
'inspect_peer',
'mcp__hapi__inspect_peer',
'hapi_inspect_peer',
'Inspect Peer Session'
])('does not auto-approve %s in read-only mode', (toolName) => {
expect(resolveToolAutoApprovalDecision('read-only', toolName, 'call-1')).toBeNull()
})
})
@@ -32,12 +32,18 @@ const AUTO_APPROVE_EXACT_TOOL_NAMES = new Set([
'happy__skill_lookup',
'mcp__hapi__skill_lookup'
]);
// ping_peer intentionally omitted from always-approve: it can resume another
// session and inject a prompt into a peer, so permission modes must still gate
// it (Codex PR #1195). Treat it as write-like in read-only so ACP titles such as
// "Ping Peer Session" also require approval.
// ping_peer / inspect_peer intentionally omitted from always-approve: they can
// resume+inject into another session or read peer histories, so permission
// modes must still gate them. Treat both as write-like in read-only so ACP
// titles such as "Ping Peer Session" / "Inspect Peer Session" also require
// approval.
const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'save_memory'];
const SENSITIVE_TOOL_NAME_HINTS = ['ping_peer', 'ping peer'];
const SENSITIVE_TOOL_NAME_HINTS = [
'ping_peer',
'ping peer',
'inspect_peer',
'inspect peer',
];
const AUTO_APPROVE_WRITE_TOOL_HINTS = [
'write',
'edit',
@@ -0,0 +1,255 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
PingPeerError,
formatInspectPeerReport,
inspectPeer,
type PingPeerSessionSummary
} from './pingPeer'
type MockResponse = {
status: number
data: unknown
}
function createHttpMock(handlers: {
post?: (url: string, body?: unknown) => MockResponse | Promise<MockResponse>
get?: (url: string, config?: { params?: Record<string, unknown> }) => MockResponse | Promise<MockResponse>
}) {
return {
post: vi.fn(async (url: string, body?: unknown) => {
if (!handlers.post) {
throw new Error(`unexpected POST ${url}`)
}
return handlers.post(url, body)
}),
get: vi.fn(async (url: string, config?: { params?: Record<string, unknown> }) => {
if (!handlers.get) {
throw new Error(`unexpected GET ${url}`)
}
return handlers.get(url, config)
})
}
}
describe('inspectPeer', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads metadata and recent messages without calling resume', async () => {
const sessionId = '7d55ed21-8a9f-4309-b4f8-30069df36b4b'
const http = createHttpMock({
post: (url) => {
if (url.endsWith('/api/auth')) {
return { status: 200, data: { token: 'jwt' } }
}
throw new Error(`unexpected POST ${url}`)
},
get: (url, config) => {
if (url.endsWith('/api/sessions')) {
return {
status: 200,
data: {
sessions: [{
id: sessionId,
active: false,
updatedAt: 1_700_000_000_000,
metadata: {
name: 'hub runner version governance',
flavor: 'cursor',
path: '/home/heavygee/coding/hapi'
}
} satisfies PingPeerSessionSummary]
}
}
}
if (url.endsWith(`/api/sessions/${sessionId}`)) {
return {
status: 200,
data: {
session: {
id: sessionId,
active: false,
thinking: false,
updatedAt: 1_700_000_000_000,
metadata: {
name: 'hub runner version governance',
flavor: 'cursor',
path: '/home/heavygee/coding/hapi',
lifecycleState: 'archived'
}
}
}
}
}
if (url.endsWith(`/api/sessions/${sessionId}/messages`)) {
expect(config?.params).toEqual({ limit: 30 })
return {
status: 200,
data: {
messages: [
{
id: 'm1',
createdAt: 1_700_000_000_100,
content: {
role: 'user',
content: { text: 'status on runner versions?' }
}
},
{
id: 'm2',
createdAt: 1_700_000_000_200,
content: {
role: 'agent',
content: {
type: 'codex',
data: {
type: 'message',
message: 'Looking into it.'
}
}
}
}
],
page: { hasMore: false }
}
}
}
throw new Error(`unexpected GET ${url}`)
}
})
const result = await inspectPeer({
sessionIdPrefix: sessionId,
accessToken: 'tok',
apiUrl: 'http://hub.test',
http: http as never
})
expect(result.sessionId).toBe(sessionId)
expect(result.name).toBe('hub runner version governance')
expect(result.active).toBe(false)
expect(result.flavor).toBe('cursor')
expect(result.path).toBe('/home/heavygee/coding/hapi')
expect(result.messages).toHaveLength(2)
expect(result.messages[0]).toMatchObject({
role: 'user',
text: 'status on runner versions?'
})
expect(result.messages[1]?.text).toContain('Looking into it.')
// Read-only: never resume
expect(http.post).toHaveBeenCalledTimes(1)
expect(http.post.mock.calls[0]![0]).toContain('/api/auth')
expect(http.post.mock.calls.some((call) => String(call[0]).includes('/resume'))).toBe(false)
})
it('respects messageLimit and refuses empty prefix', async () => {
await expect(inspectPeer({
sessionIdPrefix: ' ',
accessToken: 'tok',
apiUrl: 'http://hub.test',
http: createHttpMock({}) as never
})).rejects.toMatchObject({ code: 'bad_args' } satisfies Partial<PingPeerError>)
const sessionId = 'aaaaaaaa-1111-1111-1111-111111111111'
const http = createHttpMock({
post: (url) => {
if (url.endsWith('/api/auth')) {
return { status: 200, data: { token: 'jwt' } }
}
throw new Error(`unexpected POST ${url}`)
},
get: (url, config) => {
if (url.endsWith('/api/sessions')) {
return {
status: 200,
data: { sessions: [{ id: sessionId, active: true, metadata: { name: 'A' } }] }
}
}
if (url.endsWith(`/api/sessions/${sessionId}`)) {
return {
status: 200,
data: { session: { id: sessionId, active: true, metadata: { name: 'A' } } }
}
}
if (url.endsWith(`/api/sessions/${sessionId}/messages`)) {
expect(config?.params).toEqual({ limit: 5 })
return { status: 200, data: { messages: [], page: {} } }
}
throw new Error(`unexpected GET ${url}`)
}
})
await inspectPeer({
sessionIdPrefix: 'aaaaaaaa',
messageLimit: 5,
accessToken: 'tok',
apiUrl: 'http://hub.test',
http: http as never
})
})
it('clamps messageLimit to 1..100', async () => {
const sessionId = 'bbbbbbbb-3333-3333-3333-333333333333'
const http = createHttpMock({
post: (url) => {
if (url.endsWith('/api/auth')) {
return { status: 200, data: { token: 'jwt' } }
}
throw new Error(`unexpected POST ${url}`)
},
get: (url, config) => {
if (url.endsWith('/api/sessions')) {
return {
status: 200,
data: { sessions: [{ id: sessionId, active: true, metadata: { name: 'C' } }] }
}
}
if (url.endsWith(`/api/sessions/${sessionId}`)) {
return {
status: 200,
data: { session: { id: sessionId, active: true, metadata: { name: 'C' } } }
}
}
if (url.endsWith(`/api/sessions/${sessionId}/messages`)) {
expect(config?.params).toEqual({ limit: 100 })
return { status: 200, data: { messages: [], page: {} } }
}
throw new Error(`unexpected GET ${url}`)
}
})
await inspectPeer({
sessionIdPrefix: sessionId,
messageLimit: 999,
accessToken: 'tok',
apiUrl: 'http://hub.test',
http: http as never
})
})
})
describe('formatInspectPeerReport', () => {
it('includes session id and message snippets for agent consumption', () => {
const report = formatInspectPeerReport({
sessionId: '7d55ed21-8a9f-4309-b4f8-30069df36b4b',
name: 'hub runner version governance',
active: true,
thinking: false,
flavor: 'cursor',
path: '/tmp/x',
lifecycleState: null,
updatedAt: 1_700_000_000_000,
messages: [
{ id: '1', role: 'user', text: 'hello', createdAt: 1 },
{ id: '2', role: 'agent', text: 'world', createdAt: 2 }
]
})
expect(report).toContain('7d55ed21-8a9f-4309-b4f8-30069df36b4b')
expect(report).toContain('hub runner version governance')
expect(report).toContain('[user] hello')
expect(report).toContain('[agent] world')
expect(report).toContain('/sessions/7d55ed21-8a9f-4309-b4f8-30069df36b4b')
})
})
+188 -4
View File
@@ -1,12 +1,15 @@
/**
* Resume-if-inactive + wait-active + POST /api/sessions/:id/messages.
* Resume-if-inactive + wait-active + POST /api/sessions/:id/messages,
* plus read-only inspectPeer (GET session + messages, never resume).
*
* Shared by `hapi ping-peer` and MCP `ping_peer`. Uses the same hub JWT flow
* as the web app (`POST /api/auth` with CLI_API_TOKEN), scoped to the token's
* namespace. Callers must not invent parallel auth or arbitrary hosts.
* Shared by `hapi ping-peer` / `hapi inspect-peer` and MCP `ping_peer` /
* `inspect_peer`. Uses the same hub JWT flow as the web app
* (`POST /api/auth` with CLI_API_TOKEN), scoped to the token's namespace.
* Callers must not invent parallel auth or arbitrary hosts.
*/
import axios, { type AxiosInstance } from 'axios'
import { extractAssistantPlainText, isObject } from '@hapi/protocol'
import { configuration } from '@/configuration'
import { getAuthToken } from '@/api/auth'
import { buildHubRequestHeaders } from '@/api/hubExtraHeaders'
@@ -33,10 +36,13 @@ export class PingPeerError extends Error {
export type PingPeerSessionSummary = {
id: string
active: boolean
thinking?: boolean
updatedAt?: number
metadata?: {
name?: string
flavor?: string | null
path?: string | null
lifecycleState?: string | null
piSessionId?: string
} | null
}
@@ -422,3 +428,181 @@ export function exitCodeForPingPeerError(error: PingPeerError): number {
return 1
}
}
// ── inspect_peer (read twin; no resume) ─────────────────────────────────────
export type InspectPeerOptions = {
sessionIdPrefix: string
/** Recent message page size (default 30, clamped 1..100). */
messageLimit?: number
apiUrl?: string
accessToken?: string
http?: AxiosInstance
}
export type InspectPeerMessage = {
id: string
role: string
text: string
createdAt: number | null
}
export type InspectPeerResult = {
sessionId: string
name: string
active: boolean
thinking: boolean
flavor: string | null
path: string | null
lifecycleState: string | null
updatedAt: number | null
messages: InspectPeerMessage[]
}
const DEFAULT_INSPECT_MESSAGE_LIMIT = 30
const MAX_INSPECT_MESSAGE_LIMIT = 100
const MAX_SNIPPET_CHARS = 1_200
function clampInspectMessageLimit(raw: number | undefined): number {
const n = raw ?? DEFAULT_INSPECT_MESSAGE_LIMIT
if (!Number.isFinite(n)) {
throw new PingPeerError('bad_args', 'messageLimit must be a number')
}
return Math.min(MAX_INSPECT_MESSAGE_LIMIT, Math.max(1, Math.floor(n)))
}
function extractUserPlainText(inner: unknown): string | null {
if (typeof inner === 'string' && inner.trim()) return inner
if (!isObject(inner)) return null
if (typeof inner.text === 'string' && inner.text.trim()) return inner.text
if (isObject(inner.content) && typeof inner.content.text === 'string' && inner.content.text.trim()) {
return inner.content.text
}
return null
}
/** Best-effort text from a hub message row; skip tool-call / empty noise. */
export function extractInspectMessageSnippet(content: unknown): InspectPeerMessage | null {
if (!isObject(content)) return null
const role = typeof content.role === 'string' ? content.role : 'unknown'
const inner = content.content
let text: string | null = null
if (role === 'user') {
text = extractUserPlainText(inner)
} else {
text = extractAssistantPlainText(inner)
if (!text) text = extractUserPlainText(inner)
}
if (!text) return null
const trimmed = text.replace(/\s+/g, ' ').trim()
if (!trimmed) return null
const snippet = trimmed.length > MAX_SNIPPET_CHARS
? `${trimmed.slice(0, MAX_SNIPPET_CHARS)}`
: trimmed
return {
id: typeof content.id === 'string' ? content.id : '',
role,
text: snippet,
createdAt: null
}
}
async function fetchSessionMessages(
apiUrl: string,
jwt: string,
sessionId: string,
limit: number,
http: AxiosInstance
): Promise<InspectPeerMessage[]> {
const response = await http.get(
`${apiUrl}/api/sessions/${encodeURIComponent(sessionId)}/messages`,
{
headers: authHeaders(jwt),
params: { limit },
timeout: 20_000,
validateStatus: () => true
}
)
if (response.status < 200 || response.status >= 300) {
const detail = typeof response.data?.error === 'string'
? response.data.error
: `HTTP ${response.status}`
throw new PingPeerError('not_found', `failed to load messages for ${sessionId} (${detail})`)
}
const rows = Array.isArray(response.data?.messages) ? response.data.messages : []
const out: InspectPeerMessage[] = []
for (const row of rows) {
if (!isObject(row)) continue
const snippet = extractInspectMessageSnippet(row.content)
if (!snippet) continue
out.push({
...snippet,
id: typeof row.id === 'string' ? row.id : snippet.id,
createdAt: typeof row.createdAt === 'number' ? row.createdAt : null
})
}
return out
}
/**
* Resolve a peer by id/prefix and return metadata + recent text messages.
* Read-only: never resumes inactive sessions (unlike `pingPeer`).
*/
export async function inspectPeer(options: InspectPeerOptions): Promise<InspectPeerResult> {
const prefix = options.sessionIdPrefix?.trim() ?? ''
if (!prefix) {
throw new PingPeerError('bad_args', 'session id prefix is required')
}
const messageLimit = clampInspectMessageLimit(options.messageLimit)
const apiUrl = resolveApiUrl(options.apiUrl)
const accessToken = resolveAccessToken(options.accessToken)
const http = options.http ?? axios
const jwt = await exchangeJwt(apiUrl, accessToken, http)
const sessions = await listSessions(apiUrl, jwt, http)
const matched = resolveSessionByPrefix(sessions, prefix)
const live = await getSession(apiUrl, jwt, matched.id, http)
const meta = live.metadata ?? matched.metadata ?? null
const messages = await fetchSessionMessages(apiUrl, jwt, matched.id, messageLimit, http)
return {
sessionId: matched.id,
name: meta?.name ?? '(unnamed)',
active: live.active,
thinking: Boolean(live.thinking),
flavor: typeof meta?.flavor === 'string' ? meta.flavor : null,
path: typeof meta?.path === 'string' ? meta.path : null,
lifecycleState: typeof meta?.lifecycleState === 'string' ? meta.lifecycleState : null,
updatedAt: typeof live.updatedAt === 'number'
? live.updatedAt
: typeof matched.updatedAt === 'number'
? matched.updatedAt
: null,
messages
}
}
/** Human/agent-readable report for MCP tool results and CLI stdout. */
export function formatInspectPeerReport(result: InspectPeerResult): string {
const lines: string[] = [
`sessionId: ${result.sessionId}`,
`path: /sessions/${result.sessionId}`,
`name: ${result.name}`,
`flavor: ${result.flavor ?? '(unknown)'}`,
`active: ${result.active}`,
`thinking: ${result.thinking}`,
`lifecycle: ${result.lifecycleState ?? '(none)'}`,
`cwd: ${result.path ?? '(unknown)'}`,
`updatedAt: ${result.updatedAt ?? '(unknown)'}`,
`messages (text snippets, newest page): ${result.messages.length}`
]
if (result.messages.length === 0) {
lines.push('(no extractable user/assistant text in this page)')
} else {
for (const message of result.messages) {
lines.push(`[${message.role}] ${message.text}`)
}
}
return lines.join('\n')
}
+2 -2
View File
@@ -14,7 +14,7 @@ import { SKILL_LOOKUP_INSTRUCTION } from '@/modules/common/skillLookupInstructio
export const TITLE_INSTRUCTION = trimIdent(`
Use the title tool sparingly. For a new chat, call the tool "hapi_change_title" once after the user's initial request is clear, and set a concise task title. Do not rename the chat for routine progress, substeps, implementation details, or a slightly better wording. Rename only when the user's primary objective changes substantially and the existing title would be misleading.
When you create or find a local image file that the user should see, call the tool "hapi_display_image" with the image path so HAPI can show it inline.
To message another HAPI session (peer handoff / nudge), call "hapi_ping_peer" with sessionIdPrefix and message - do not reinvent JWT+curl. Shell fallback: hapi ping-peer <prefix> <message>.
When the user cites another HAPI session as [title](/sessions/<id>) (or a bare /sessions/<id>), extract that <id>. Call "hapi_inspect_peer" with sessionIdPrefix=<id> to read metadata and recent messages; call "hapi_ping_peer" with sessionIdPrefix=<id> and a message to nudge or hand off. Prefer these over JWT+curl. Shell fallbacks: hapi inspect-peer <id> / hapi ping-peer <id> <message>.
${SKILL_LOOKUP_INSTRUCTION}
`);
@@ -24,7 +24,7 @@ export const TITLE_INSTRUCTION = trimIdent(`
*/
export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(`
When you create or find a local image file that the user should see, call the tool "hapi_display_image" with the image path so HAPI can show it inline.
To message another HAPI session (peer handoff / nudge), call "hapi_ping_peer" with sessionIdPrefix and message - do not reinvent JWT+curl. Shell fallback: hapi ping-peer <prefix> <message>.
When the user cites another HAPI session as [title](/sessions/<id>) (or a bare /sessions/<id>), extract that <id>. Call "hapi_inspect_peer" with sessionIdPrefix=<id> to read metadata and recent messages; call "hapi_ping_peer" with sessionIdPrefix=<id> and a message to nudge or hand off. Prefer these over JWT+curl. Shell fallbacks: hapi inspect-peer <id> / hapi ping-peer <id> <message>.
${SKILL_LOOKUP_INSTRUCTION}
`);