mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>\`.
|
||||
`))();
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>.
|
||||
`);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user