diff --git a/cli/README.md b/cli/README.md index a867f1a0..52d5d1e2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -34,6 +34,7 @@ Run Claude Code, Codex, Cursor Agent, Grok Build, or OpenCode sessions from your - `hapi opencode` - Start OpenCode mode via ACP. See `src/opencode/runOpencode.ts`. Note: OpenCode supports local and remote modes; local mode streams via OpenCode plugins. - `hapi resume [sessionId]` - List resumable sessions for this machine or resume one locally. +- `hapi ping-peer ` - Resume (if needed) and message another session. Prefer this or MCP `ping_peer` over reinventing JWT+curl. Also `--message-file` / `--list`. ### Resume a remote session locally @@ -116,7 +117,7 @@ See `src/configuration.ts` for all options. ### Set for the wrapped agent -- `HAPI_SESSION_ID` - The hub session id for the current run, exported into the wrapped agent/CLI child environment at spawn for every flavor (claude / codex / cursor / gemini / opencode / kimi / grok / pi), both runner-spawned and locally started sessions. Agents can read it to self-target "this chat" over the hub REST API or shell helpers without listing `/api/sessions`. Prefer the MCP `display_image` tool for inline media when it is available; use `HAPI_SESSION_ID` for hub REST / shell tooling where MCP is not. +- `HAPI_SESSION_ID` - The hub session id for the current run, exported into the wrapped agent/CLI child environment at spawn for every flavor (claude / codex / cursor / gemini / opencode / kimi / grok / pi), both runner-spawned and locally started sessions. Agents can read it to self-target "this chat" over the hub REST API or shell helpers without listing `/api/sessions`. Prefer the MCP `display_image` tool for inline media when it is available; use `HAPI_SESSION_ID` for hub REST / shell tooling where MCP is not. To message **another** session, prefer MCP `ping_peer` or `hapi ping-peer` — do not reinvent JWT+curl. Lazy Codex (terminal) sessions export the id only after the hub row is materialized, which happens when the MCP bridge starts — before the agent process is spawned — so path-only self-targeting does not race a missing hub row. diff --git a/cli/src/agent/hapiSessionEnv.ts b/cli/src/agent/hapiSessionEnv.ts index 03d89435..8f5ac043 100644 --- a/cli/src/agent/hapiSessionEnv.ts +++ b/cli/src/agent/hapiSessionEnv.ts @@ -15,6 +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. * * 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 diff --git a/cli/src/agent/runners/runAgentSession.test.ts b/cli/src/agent/runners/runAgentSession.test.ts index c3462da8..0e224aa1 100644 --- a/cli/src/agent/runners/runAgentSession.test.ts +++ b/cli/src/agent/runners/runAgentSession.test.ts @@ -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', 'skill_lookup'], + toolNames: ['change_title', 'display_image', 'ping_peer', 'skill_lookup'], stop: harness.stopServer } }) @@ -167,7 +167,7 @@ describe('runAgentSession', () => { '--url', 'http://127.0.0.1:1234', '--tools', - 'change_title,display_image,skill_lookup' + 'change_title,display_image,ping_peer,skill_lookup' ]) expect(harness.newSessionOptions).toMatchObject({ cwd: '/tmp/project', diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 1ebb1601..09f349d9 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -7,7 +7,7 @@ import { hashObject } from '@/utils/deterministicJson'; import { extractSDKMetadataAsync } from '@/claude/sdk/metadataExtractor'; import { parseSpecialCommand } from '@/parsers/specialCommands'; import { getEnvironmentInfo } from '@/ui/doctor'; -import { startHappyServer } from '@/claude/utils/startHappyServer'; +import { startHappyServer, toClaudeAllowedHapiMcpTools } from '@/claude/utils/startHappyServer'; import { startHookServer } from '@/claude/utils/startHookServer'; import { generateHookSettingsFile, cleanupHookSettingsFile } from '@/modules/common/hooks/generateHookSettings'; import { registerKillSessionHandler } from './registerKillSessionHandler'; @@ -420,7 +420,7 @@ export async function runClaude(options: StartOptions = {}): Promise { startingMode, messageQueue, api, - allowedTools: happyServer.toolNames.map(toolName => `mcp__hapi__${toolName}`), + allowedTools: toClaudeAllowedHapiMcpTools(happyServer.toolNames), onModeChange: createModeChangeHandler(session), onSessionReady: (sessionInstance) => { currentSessionRef.current = sessionInstance; diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index 3814f4d1..63f2fd4c 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -5,7 +5,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { ApiSessionClient } from '@/api/apiSession' -import { startHappyServer } from './startHappyServer' +import { startHappyServer, toClaudeAllowedHapiMcpTools } from './startHappyServer' type ToolResult = { content?: Array<{ type: string; text?: string }> @@ -107,7 +107,8 @@ describe('startHappyServer skill_lookup', () => { expect(tools.tools.map((tool) => tool.name)).toEqual([ 'change_title', - 'display_image' + 'display_image', + 'ping_peer' ]) }) @@ -125,8 +126,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']) - expect(tools.tools.map((tool) => tool.name)).toEqual(['display_image']) + expect(server.toolNames).toEqual(['display_image', 'ping_peer']) + expect(tools.tools.map((tool) => tool.name)).toEqual(['display_image', 'ping_peer']) }) }) + +describe('toClaudeAllowedHapiMcpTools', () => { + it('keeps ping_peer registered but out of Claude --allowedTools', () => { + expect(toClaudeAllowedHapiMcpTools([ + 'change_title', + 'display_image', + 'ping_peer', + 'skill_lookup' + ])).toEqual([ + 'mcp__hapi__change_title', + 'mcp__hapi__display_image', + 'mcp__hapi__skill_lookup' + ]) + }) +}) diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index f8d95653..5924d977 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -14,6 +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"; type StartHappyServerOptions = { emitTitleSummary?: boolean; @@ -24,6 +25,19 @@ type StartHappyServerOptions = { }; }; +/** Registered on the MCP server, but never pre-approved via Claude --allowedTools. */ +const CLAUDE_MANUAL_APPROVAL_HAPI_TOOLS = new Set(['ping_peer']); + +/** + * Map HAPI MCP tool names to Claude `--allowedTools` entries. + * Keeps `ping_peer` off the auto-allow list so resume+inject still prompts. + */ +export function toClaudeAllowedHapiMcpTools(toolNames: string[]): string[] { + return toolNames + .filter((toolName) => !CLAUDE_MANUAL_APPROVAL_HAPI_TOOLS.has(toolName)) + .map((toolName) => `mcp__hapi__${toolName}`); +} + function createHapiMcpServer( client: ApiSessionClient, emitTitleSummary: boolean, @@ -61,6 +75,13 @@ function createHapiMcpServer( title: z.string().optional().describe('Optional display title or filename for the image'), }); + 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)' + ), + message: z.string().min(1).describe('Message text to deliver to the target session'), + }); + const skillLookupInputSchema: z.ZodTypeAny = z.object({ name: z.string().trim().min(1).max(128).describe('Exact skill name shown by HAPI skill autocomplete'), }); @@ -162,6 +183,45 @@ function createHapiMcpServer( } }); + mcp.registerTool('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.', + title: 'Ping Peer Session', + inputSchema: pingPeerInputSchema, + }, async (args: { sessionIdPrefix: string; message: string }) => { + logger.debug('[hapiMCP] ping_peer:', args.sessionIdPrefix); + try { + const result = await pingPeer({ + sessionIdPrefix: args.sessionIdPrefix, + message: args.message, + }); + return { + content: [ + { + type: 'text' as const, + text: `Delivered to ${result.sessionId}${result.resumed ? ' (resumed)' : ''} (${result.name})`, + }, + ], + isError: false, + }; + } catch (error) { + const message = error instanceof PingPeerError + ? error.message + : error instanceof Error + ? error.message + : String(error); + logger.debug('[hapiMCP] ping_peer failed:', message); + return { + content: [ + { + type: 'text' as const, + text: `Failed to ping peer: ${message}`, + }, + ], + isError: true, + }; + } + }); + if (skillLookup) { mcp.registerTool('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.', @@ -281,7 +341,9 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH hapiMcpUrl: mcpUrl, })); - const toolNames = enableChangeTitle ? ['change_title', 'display_image'] : ['display_image']; + const toolNames = enableChangeTitle + ? ['change_title', 'display_image', 'ping_peer'] + : ['display_image', 'ping_peer']; if (options.skillLookup) { toolNames.push('skill_lookup'); } diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 9e90179d..51fd9d70 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -7,6 +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 \`. `))(); /** diff --git a/cli/src/codex/happyMcpStdioBridge.test.ts b/cli/src/codex/happyMcpStdioBridge.test.ts index 5eed9974..f748ec93 100644 --- a/cli/src/codex/happyMcpStdioBridge.test.ts +++ b/cli/src/codex/happyMcpStdioBridge.test.ts @@ -53,12 +53,13 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { '--url', 'http://127.0.0.1:43006', '--tools', - 'change_title,display_image,skill_lookup' + 'change_title,display_image,ping_peer,skill_lookup' ]) expect([...harness.tools.keys()]).toEqual([ 'change_title', 'display_image', + 'ping_peer', 'skill_lookup' ]) @@ -79,9 +80,9 @@ describe('runHappyMcpStdioBridge tool forwarding', () => { '--url', 'http://127.0.0.1:43006', '--tools', - 'change_title,display_image' + 'change_title,display_image,ping_peer' ]) - expect([...harness.tools.keys()]).toEqual(['change_title', 'display_image']) + expect([...harness.tools.keys()]).toEqual(['change_title', 'display_image', 'ping_peer']) }) }) diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index a2714f0a..013fdbfa 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -1,7 +1,7 @@ /** * HAPI MCP STDIO Bridge * - * Minimal STDIO MCP server exposing HAPI tools such as `change_title` and `display_image`. + * Minimal STDIO MCP server exposing HAPI tools such as `change_title`, `display_image`, and `ping_peer`. * On invocation it forwards the tool call to an existing HAPI HTTP MCP server * using the StreamableHTTPClientTransport. * @@ -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']; +const DEFAULT_TOOL_NAMES = ['change_title', 'display_image', 'ping_peer']; function parseArgs(argv: string[]): { url: string | null; toolNames: Set } { let url: string | null = null; @@ -133,6 +133,38 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { ); } + 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)' + ), + message: z.string().min(1).describe('Message text to deliver to the target session'), + }); + + if (toolNames.has('ping_peer')) { + server.registerTool( + '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.', + title: 'Ping Peer Session', + inputSchema: pingPeerInputSchema, + }, + async (args: Record) => { + try { + const client = await ensureHttpClient(); + const response = await client.callTool({ name: 'ping_peer', arguments: args }); + return response as any; + } catch (error) { + return { + content: [ + { type: 'text' as const, text: `Failed to ping 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'), }); diff --git a/cli/src/codex/utils/buildHapiMcpBridge.test.ts b/cli/src/codex/utils/buildHapiMcpBridge.test.ts index 9a8086ea..ef183e5e 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.test.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.test.ts @@ -14,8 +14,8 @@ vi.mock('@/claude/utils/startHappyServer', () => ({ return { url: 'http://127.0.0.1:43006/', toolNames: options.skillLookup - ? ['change_title', 'display_image', 'skill_lookup'] - : ['change_title', 'display_image'], + ? ['change_title', 'display_image', 'ping_peer', 'skill_lookup'] + : ['change_title', 'display_image', 'ping_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,skill_lookup' + 'change_title,display_image,ping_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') + expect(harness.cliArgs.at(-1)).toBe('change_title,display_image,ping_peer') expect(bridge.mcpServers.hapi.tools).toEqual({ change_title: { approval_mode: 'approve' } }) diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 1c7ec2ba..8af67d5b 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -95,6 +95,8 @@ 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). if (options.skillLookup) { tools.skill_lookup = { approval_mode: 'approve' diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index a6057be0..6d5c0533 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -19,6 +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 . `); /** diff --git a/cli/src/commands/pingPeer.test.ts b/cli/src/commands/pingPeer.test.ts new file mode 100644 index 00000000..7868e58a --- /dev/null +++ b/cli/src/commands/pingPeer.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { PingPeerError } from '@/modules/pingPeer/pingPeer' +import { parsePingPeerArgs } from './pingPeer' + +describe('parsePingPeerArgs', () => { + it('parses positional prefix + message', () => { + expect(parsePingPeerArgs(['05d9f0f2', 'hello'])).toEqual({ + help: false, + list: false, + sessionIdPrefix: '05d9f0f2', + message: 'hello' + }) + }) + + it('parses --message-file and --wait', () => { + expect(parsePingPeerArgs(['abc', '--message-file', 'brief.md', '--wait', '30'])).toEqual({ + help: false, + list: false, + sessionIdPrefix: 'abc', + messageFile: 'brief.md', + waitActiveSecs: 30 + }) + }) + + it('parses --list and --help', () => { + expect(parsePingPeerArgs(['--list'])).toEqual({ help: false, list: true }) + expect(parsePingPeerArgs(['--help']).help).toBe(true) + }) + + it('rejects unknown flags', () => { + expect(() => parsePingPeerArgs(['--host', 'evil'])).toThrow(PingPeerError) + }) +}) diff --git a/cli/src/commands/pingPeer.ts b/cli/src/commands/pingPeer.ts new file mode 100644 index 00000000..6b28a987 --- /dev/null +++ b/cli/src/commands/pingPeer.ts @@ -0,0 +1,204 @@ +import { readFile } from 'node:fs/promises' +import chalk from 'chalk' +import { initializeToken } from '@/ui/tokenInit' +import { + PingPeerError, + exitCodeForPingPeerError, + listPeerSessions, + pingPeer +} from '@/modules/pingPeer/pingPeer' +import type { CommandDefinition } from './types' + +type ParsedPingPeerArgs = { + help: boolean + list: boolean + sessionIdPrefix?: string + message?: string + messageFile?: string + waitActiveSecs?: number +} + +function showHelp(): void { + console.log(` +${chalk.bold('hapi ping-peer')} - Resume a HAPI session (if needed) and send it a message + +${chalk.bold('Usage:')} + hapi ping-peer + hapi ping-peer --message-file + hapi ping-peer --message-file - # read message from stdin + hapi ping-peer --list + +${chalk.bold('Notes:')} + Do not reinvent JWT + curl for peer handoffs. Prefer this command or MCP ping_peer. + Resolves by id prefix (8 chars OK). Same hub token/namespace as this CLI. + Inactive sessions are resumed via POST /api/sessions/:id/resume, then messaged. + +${chalk.bold('Env:')} + HAPI_API_URL / CLI_API_TOKEN (or ~/.hapi/settings.json via \`hapi auth login\`) + HAPI_WAIT_ACTIVE_SECS (default 60; overridable with --wait) +`) +} + +export function parsePingPeerArgs(args: string[]): ParsedPingPeerArgs { + const result: ParsedPingPeerArgs = { + help: false, + list: false + } + + for (let i = 0; i < args.length; i++) { + const arg = args[i]! + if (arg === '--help' || arg === '-h') { + result.help = true + continue + } + if (arg === '--list') { + result.list = true + continue + } + if (arg === '--message-file') { + const value = args[++i] + if (!value) { + throw new PingPeerError('bad_args', '--message-file requires a path (or - for stdin)') + } + result.messageFile = value + continue + } + if (arg.startsWith('--message-file=')) { + const value = arg.slice('--message-file='.length) + if (!value) { + throw new PingPeerError('bad_args', '--message-file requires a path (or - for stdin)') + } + result.messageFile = value + continue + } + if (arg === '--wait') { + const value = args[++i] + if (!value) { + throw new PingPeerError('bad_args', '--wait requires seconds') + } + result.waitActiveSecs = Number(value) + continue + } + if (arg.startsWith('--wait=')) { + result.waitActiveSecs = Number(arg.slice('--wait='.length)) + continue + } + if (arg.startsWith('-')) { + throw new PingPeerError('bad_args', `unexpected flag: ${arg}`) + } + if (!result.sessionIdPrefix) { + result.sessionIdPrefix = arg + continue + } + if (result.message === undefined) { + result.message = arg + continue + } + throw new PingPeerError('bad_args', `unexpected arg: ${arg}`) + } + + if (result.waitActiveSecs !== undefined && (!Number.isFinite(result.waitActiveSecs) || result.waitActiveSecs <= 0)) { + throw new PingPeerError('bad_args', '--wait must be a positive number of seconds') + } + + return result +} + +async function readMessage(parsed: ParsedPingPeerArgs): Promise { + if (parsed.messageFile !== undefined) { + if (parsed.message !== undefined) { + throw new PingPeerError('bad_args', 'provide message as an argument or --message-file, not both') + } + if (parsed.messageFile === '-') { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + return Buffer.concat(chunks).toString('utf8') + } + return await readFile(parsed.messageFile, 'utf8') + } + return parsed.message ?? '' +} + +function envWaitActiveSecs(): number | undefined { + const raw = process.env.HAPI_WAIT_ACTIVE_SECS + if (!raw) { + return undefined + } + const value = Number(raw) + if (!Number.isFinite(value) || value <= 0) { + throw new PingPeerError('bad_args', 'HAPI_WAIT_ACTIVE_SECS must be a positive number') + } + return value +} + +async function handleList(): Promise { + const sessions = await listPeerSessions() + const sorted = [...sessions].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + for (const session of sorted.slice(0, 30)) { + const flavor = session.metadata?.flavor ?? '?' + const name = session.metadata?.name ?? '(unnamed)' + console.log(` ${session.id.slice(0, 8)} active=${session.active} flavor=${flavor} ${name}`) + } +} + +export async function handlePingPeerCommand(args: string[]): Promise { + const parsed = parsePingPeerArgs(args) + if (parsed.help) { + showHelp() + return + } + + await initializeToken() + + if (parsed.list) { + await handleList() + return + } + + if (!parsed.sessionIdPrefix) { + showHelp() + throw new PingPeerError('bad_args', 'missing session id; usage: hapi ping-peer ') + } + + const message = await readMessage(parsed) + if (!message) { + throw new PingPeerError( + 'bad_args', + 'missing message; provide as arg, --message-file PATH, or --message-file -' + ) + } + + const result = await pingPeer({ + sessionIdPrefix: parsed.sessionIdPrefix, + message, + waitActiveSecs: parsed.waitActiveSecs ?? envWaitActiveSecs(), + onProgress: (line) => console.log(`hapi ping-peer: ${line}`) + }) + + console.log(chalk.green(`hapi ping-peer: OK - delivered to ${result.sessionId}`)) +} + +export const pingPeerCommand: CommandDefinition = { + name: 'ping-peer', + requiresRuntimeAssets: false, + run: async ({ commandArgs }) => { + try { + await handlePingPeerCommand(commandArgs) + } catch (error) { + if (error instanceof PingPeerError) { + console.error(chalk.red('hapi ping-peer:'), error.message) + process.exit(exitCodeForPingPeerError(error)) + } + console.error( + chalk.red('hapi ping-peer:'), + error instanceof Error ? error.message : 'Unknown error' + ) + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 9d3b7d24..e0d72ecb 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -15,6 +15,7 @@ import { hookForwarderCommand } from './hookForwarder' import { mcpCommand } from './mcp' import { notifyCommand } from './notify' import { hubCommand } from './hub' +import { pingPeerCommand } from './pingPeer' import type { CommandContext, CommandDefinition } from './types' // Gemini CLI was sunset (Google stopped serving the consumer Gemini CLI on @@ -50,7 +51,8 @@ const COMMANDS: CommandDefinition[] = [ doctorCommand, resumeCommand, runnerCommand, - notifyCommand + notifyCommand, + pingPeerCommand ] const commandMap = new Map() diff --git a/cli/src/modules/common/permission/BasePermissionHandler.test.ts b/cli/src/modules/common/permission/BasePermissionHandler.test.ts index 6d18a194..5c21d7f2 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.test.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.test.ts @@ -36,3 +36,28 @@ describe('resolveToolAutoApprovalDecision skill_lookup', () => { )).toBeNull() }) }) + +describe('resolveToolAutoApprovalDecision ping_peer', () => { + it.each([ + 'ping_peer', + 'mcp__hapi__ping_peer', + 'hapi_ping_peer', + 'Ping Peer Session' + ])('does not auto-approve %s in default mode', (toolName) => { + expect(resolveToolAutoApprovalDecision('default', toolName, 'call-1')).toBeNull() + }) + + it.each([ + 'ping_peer', + 'mcp__hapi__ping_peer', + 'hapi_ping_peer', + 'Ping Peer Session' + ])('does not auto-approve %s in read-only mode', (toolName) => { + expect(resolveToolAutoApprovalDecision('read-only', toolName, 'call-1')).toBeNull() + }) + + it('still auto-approves unrelated read tools in read-only mode', () => { + expect(resolveToolAutoApprovalDecision('read-only', 'Read', 'call-1')).toBe('approved') + expect(resolveToolAutoApprovalDecision('read-only', 'grep', 'call-2')).toBe('approved') + }) +}) diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index 2c12908d..a4a3ebc1 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -32,8 +32,21 @@ 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. const AUTO_APPROVE_TOOL_ID_HINTS = ['change_title', 'save_memory']; -const AUTO_APPROVE_WRITE_TOOL_HINTS = ['write', 'edit', 'create', 'delete', 'patch', 'fs-edit']; +const SENSITIVE_TOOL_NAME_HINTS = ['ping_peer', 'ping peer']; +const AUTO_APPROVE_WRITE_TOOL_HINTS = [ + 'write', + 'edit', + 'create', + 'delete', + 'patch', + 'fs-edit', + ...SENSITIVE_TOOL_NAME_HINTS +]; export function resolveToolAutoApprovalDecision( mode: PermissionMode | undefined, diff --git a/cli/src/modules/pingPeer/pingPeer.test.ts b/cli/src/modules/pingPeer/pingPeer.test.ts new file mode 100644 index 00000000..fa495cf8 --- /dev/null +++ b/cli/src/modules/pingPeer/pingPeer.test.ts @@ -0,0 +1,399 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + PingPeerError, + exitCodeForPingPeerError, + pingPeer, + resolveSessionByPrefix, + type PingPeerSessionSummary +} from './pingPeer' + +type MockResponse = { + status: number + data: unknown +} + +function createHttpMock(handlers: { + post?: (url: string, body?: unknown) => MockResponse | Promise + get?: (url: string) => MockResponse | Promise +}) { + 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) => { + if (!handlers.get) { + throw new Error(`unexpected GET ${url}`) + } + return handlers.get(url) + }) + } +} + +describe('resolveSessionByPrefix', () => { + const sessions: PingPeerSessionSummary[] = [ + { id: 'aaaaaaaa-1111-1111-1111-111111111111', active: true, metadata: { name: 'A' } }, + { id: 'aaaaaaab-2222-2222-2222-222222222222', active: false, metadata: { name: 'B' } }, + { id: 'bbbbbbbb-3333-3333-3333-333333333333', active: true, metadata: { name: 'C' } } + ] + + it('resolves a unique id prefix', () => { + expect(resolveSessionByPrefix(sessions, 'bbbb').id).toBe(sessions[2]!.id) + }) + + it('prefers an exact id match', () => { + expect(resolveSessionByPrefix(sessions, sessions[0]!.id).id).toBe(sessions[0]!.id) + }) + + it('refuses ambiguous prefixes', () => { + expect(() => resolveSessionByPrefix(sessions, 'aaaa')).toThrow(PingPeerError) + try { + resolveSessionByPrefix(sessions, 'aaaa') + } catch (error) { + expect(error).toBeInstanceOf(PingPeerError) + expect((error as PingPeerError).code).toBe('ambiguous') + } + }) + + it('refuses unknown prefixes', () => { + expect(() => resolveSessionByPrefix(sessions, 'zzzz')).toThrowError(/no session matching/) + }) +}) + +describe('pingPeer', () => { + let nowMs: number + let sleepCalls: number[] + + beforeEach(() => { + nowMs = 1_000_000 + sleepCalls = [] + }) + + it('sends to an already-active session without resume', async () => { + const sessionId = '05d9f0f2-9273-4137-933c-07459a1146a2' + const http = createHttpMock({ + post: (url, body) => { + if (url.endsWith('/api/auth')) { + expect(body).toEqual({ accessToken: 'tok' }) + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/sessions/${sessionId}/messages`)) { + expect(body).toEqual({ text: 'hello peer' }) + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/sessions')) { + return { + status: 200, + data: { + sessions: [{ + id: sessionId, + active: true, + metadata: { name: 'Orchestrator', flavor: 'cursor' } + }] + } + } + } + if (url.endsWith(`/api/sessions/${sessionId}`)) { + return { + status: 200, + data: { + session: { + id: sessionId, + active: true, + metadata: { name: 'Orchestrator', flavor: 'cursor' } + } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + const result = await pingPeer({ + sessionIdPrefix: '05d9f0f2', + message: 'hello peer', + accessToken: 'tok', + apiUrl: 'http://127.0.0.1:3006', + http: http as never + }) + + expect(result).toEqual({ + sessionId, + name: 'Orchestrator', + resumed: false + }) + expect(http.post).toHaveBeenCalledTimes(2) + }) + + it('resumes an inactive session, waits for active, then sends', async () => { + const sessionId = 'aaaaaaaa-1111-1111-1111-111111111111' + let active = false + let polls = 0 + + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/sessions/${sessionId}/resume`)) { + return { status: 200, data: { type: 'success', sessionId, resumed: true } } + } + if (url.endsWith(`/api/sessions/${sessionId}/messages`)) { + expect(active).toBe(true) + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/sessions') && !url.includes(sessionId)) { + return { + status: 200, + data: { + sessions: [{ + id: sessionId, + active: false, + metadata: { name: 'Peer', flavor: 'claude' } + }] + } + } + } + if (url.endsWith(`/api/sessions/${sessionId}`)) { + polls += 1 + if (polls >= 3) { + active = true + } + return { + status: 200, + data: { + session: { + id: sessionId, + active, + metadata: { name: 'Peer', flavor: 'claude' } + } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + const result = await pingPeer({ + sessionIdPrefix: 'aaaaaaaa', + message: 'wake up', + accessToken: 'tok', + apiUrl: 'http://hub.test', + waitActiveSecs: 10, + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + sleepCalls.push(ms) + nowMs += ms + } + }) + + expect(result.resumed).toBe(true) + expect(result.sessionId).toBe(sessionId) + expect(sleepCalls.length).toBeGreaterThan(0) + }) + + it('re-checks active before send when the list snapshot was stale', async () => { + const sessionId = 'bbbbbbbb-1111-1111-1111-111111111111' + let active = false + let resumeCalls = 0 + + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/sessions/${sessionId}/resume`)) { + resumeCalls += 1 + return { status: 200, data: { type: 'success', sessionId, resumed: true } } + } + if (url.endsWith(`/api/sessions/${sessionId}/messages`)) { + expect(active).toBe(true) + expect(resumeCalls).toBe(1) + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/sessions') && !url.includes(sessionId)) { + return { + status: 200, + data: { + sessions: [{ + id: sessionId, + // Stale snapshot: list claims active, live GET disagrees. + active: true, + metadata: { name: 'Stale', flavor: 'claude' } + }] + } + } + } + if (url.endsWith(`/api/sessions/${sessionId}`)) { + return { + status: 200, + data: { + session: { + id: sessionId, + active, + metadata: { name: 'Stale', flavor: 'claude' } + } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + const result = await pingPeer({ + sessionIdPrefix: 'bbbbbbbb', + message: 'still here?', + accessToken: 'tok', + apiUrl: 'http://hub.test', + waitActiveSecs: 10, + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + sleepCalls.push(ms) + nowMs += ms + // Become active only after resume has been requested. + if (resumeCalls > 0) { + active = true + } + } + }) + + expect(result.resumed).toBe(true) + expect(resumeCalls).toBe(1) + }) + + it('waits for piSessionId before sending to a pi session', async () => { + const sessionId = 'piiiiiii-1111-1111-1111-111111111111' + let piSessionId: string | undefined + let getCount = 0 + + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/sessions/${sessionId}/messages`)) { + expect(piSessionId).toBe('pi-ready-1') + return { status: 200, data: { ok: true } } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/sessions') && !url.includes(sessionId)) { + return { + status: 200, + data: { + sessions: [{ + id: sessionId, + active: true, + metadata: { name: 'Pi', flavor: 'pi' } + }] + } + } + } + if (url.endsWith(`/api/sessions/${sessionId}`)) { + getCount += 1 + if (getCount >= 2) { + piSessionId = 'pi-ready-1' + } + return { + status: 200, + data: { + session: { + id: sessionId, + active: true, + metadata: { name: 'Pi', flavor: 'pi', piSessionId } + } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await pingPeer({ + sessionIdPrefix: 'piiiiiii', + message: 'hi pi', + accessToken: 'tok', + apiUrl: 'http://hub.test', + waitActiveSecs: 5, + http: http as never, + now: () => nowMs, + sleep: async (ms) => { + nowMs += ms + } + }) + }) + + it('maps resume failures to resume_failed', async () => { + const sessionId = 'deadbeef-1111-1111-1111-111111111111' + const http = createHttpMock({ + post: (url) => { + if (url.endsWith('/api/auth')) { + return { status: 200, data: { token: 'jwt' } } + } + if (url.endsWith(`/api/sessions/${sessionId}/resume`)) { + return { + status: 503, + data: { type: 'error', code: 'no_machine_online', message: 'no runner' } + } + } + throw new Error(`unexpected POST ${url}`) + }, + get: (url) => { + if (url.endsWith('/api/sessions') && !url.includes(sessionId)) { + return { + status: 200, + data: { + sessions: [{ + id: sessionId, + active: false, + metadata: { name: 'Dead' } + }] + } + } + } + if (url.endsWith(`/api/sessions/${sessionId}`)) { + return { + status: 200, + data: { + session: { + id: sessionId, + active: false, + metadata: { name: 'Dead' } + } + } + } + } + throw new Error(`unexpected GET ${url}`) + } + }) + + await expect(pingPeer({ + sessionIdPrefix: 'deadbeef', + message: 'nudge', + accessToken: 'tok', + apiUrl: 'http://hub.test', + http: http as never + })).rejects.toMatchObject({ code: 'resume_failed' }) + }) + + it('maps exit codes', () => { + expect(exitCodeForPingPeerError(new PingPeerError('bad_args', 'x'))).toBe(2) + expect(exitCodeForPingPeerError(new PingPeerError('resume_failed', 'x'))).toBe(3) + expect(exitCodeForPingPeerError(new PingPeerError('timeout', 'x'))).toBe(4) + expect(exitCodeForPingPeerError(new PingPeerError('send_failed', 'x'))).toBe(4) + }) +}) diff --git a/cli/src/modules/pingPeer/pingPeer.ts b/cli/src/modules/pingPeer/pingPeer.ts new file mode 100644 index 00000000..06288ee6 --- /dev/null +++ b/cli/src/modules/pingPeer/pingPeer.ts @@ -0,0 +1,424 @@ +/** + * Resume-if-inactive + wait-active + POST /api/sessions/:id/messages. + * + * 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. + */ + +import axios, { type AxiosInstance } from 'axios' +import { configuration } from '@/configuration' +import { getAuthToken } from '@/api/auth' +import { buildHubRequestHeaders } from '@/api/hubExtraHeaders' + +export type PingPeerErrorCode = + | 'bad_args' + | 'auth_failed' + | 'not_found' + | 'ambiguous' + | 'resume_failed' + | 'timeout' + | 'send_failed' + +export class PingPeerError extends Error { + readonly code: PingPeerErrorCode + + constructor(code: PingPeerErrorCode, message: string) { + super(message) + this.name = 'PingPeerError' + this.code = code + } +} + +export type PingPeerSessionSummary = { + id: string + active: boolean + updatedAt?: number + metadata?: { + name?: string + flavor?: string | null + piSessionId?: string + } | null +} + +export type PingPeerOptions = { + sessionIdPrefix: string + message: string + waitActiveSecs?: number + apiUrl?: string + accessToken?: string + http?: AxiosInstance + sleep?: (ms: number) => Promise + now?: () => number + onProgress?: (message: string) => void +} + +export type PingPeerResult = { + sessionId: string + name: string + resumed: boolean +} + +export type ListPeerSessionsOptions = { + apiUrl?: string + accessToken?: string + http?: AxiosInstance + limit?: number +} + +const DEFAULT_WAIT_ACTIVE_SECS = 60 +const POLL_ACTIVE_MS = 2_000 +const POLL_PI_READY_MS = 1_000 + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function resolveApiUrl(apiUrl?: string): string { + const raw = (apiUrl ?? configuration.apiUrl).trim().replace(/\/+$/, '') + if (!raw) { + throw new PingPeerError('bad_args', 'HAPI API URL is empty') + } + // Peer messaging only targets the configured hub - never accept host overrides + // from MCP tool args (security: same hub/token/namespace only). + return raw +} + +function resolveAccessToken(accessToken?: string): string { + const token = (accessToken ?? getAuthToken()).trim() + if (!token) { + throw new PingPeerError('bad_args', 'CLI_API_TOKEN is required (run `hapi auth login`)') + } + return token +} + +async function exchangeJwt( + apiUrl: string, + accessToken: string, + http: AxiosInstance +): Promise { + try { + const response = await http.post( + `${apiUrl}/api/auth`, + { accessToken }, + { + headers: buildHubRequestHeaders({ 'Content-Type': 'application/json' }), + timeout: 10_000, + validateStatus: () => true + } + ) + const token = typeof response.data?.token === 'string' ? response.data.token : '' + if (response.status < 200 || response.status >= 300 || !token) { + const detail = typeof response.data?.error === 'string' + ? response.data.error + : `HTTP ${response.status}` + throw new PingPeerError('auth_failed', `failed to exchange access token for JWT (${detail})`) + } + return token + } catch (error) { + if (error instanceof PingPeerError) { + throw error + } + throw new PingPeerError( + 'auth_failed', + `failed to exchange access token for JWT (${error instanceof Error ? error.message : String(error)})` + ) + } +} + +function authHeaders(jwt: string): Record { + return buildHubRequestHeaders({ + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json' + }) +} + +export function resolveSessionByPrefix( + sessions: PingPeerSessionSummary[], + prefix: string +): PingPeerSessionSummary { + const trimmed = prefix.trim() + if (!trimmed) { + throw new PingPeerError('bad_args', 'session id prefix is required') + } + + const exact = sessions.filter((session) => session.id === trimmed) + if (exact.length === 1) { + return exact[0]! + } + + const matches = sessions.filter((session) => session.id.startsWith(trimmed)) + if (matches.length === 0) { + throw new PingPeerError('not_found', `no session matching prefix '${trimmed}'`) + } + if (matches.length > 1) { + const sample = matches.slice(0, 5).map((session) => session.id.slice(0, 8)).join(', ') + throw new PingPeerError( + 'ambiguous', + `prefix '${trimmed}' matches ${matches.length} sessions (${sample}${matches.length > 5 ? ', ...' : ''}); use a longer prefix` + ) + } + return matches[0]! +} + +async function listSessions( + apiUrl: string, + jwt: string, + http: AxiosInstance, + limit = 500 +): Promise { + const response = await http.get( + `${apiUrl}/api/sessions`, + { + headers: authHeaders(jwt), + params: { limit }, + timeout: 15_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('auth_failed', `failed to list sessions (${detail})`) + } + const body = response.data + const sessions = Array.isArray(body?.sessions) + ? body.sessions + : Array.isArray(body) + ? body + : null + if (!sessions) { + throw new PingPeerError('auth_failed', 'failed to list sessions (unexpected response)') + } + return sessions as PingPeerSessionSummary[] +} + +async function getSession( + apiUrl: string, + jwt: string, + sessionId: string, + http: AxiosInstance +): Promise { + const response = await http.get( + `${apiUrl}/api/sessions/${encodeURIComponent(sessionId)}`, + { + headers: authHeaders(jwt), + timeout: 10_000, + validateStatus: () => true + } + ) + if (response.status < 200 || response.status >= 300 || !response.data?.session) { + const detail = typeof response.data?.error === 'string' + ? response.data.error + : `HTTP ${response.status}` + throw new PingPeerError('not_found', `failed to load session ${sessionId} (${detail})`) + } + return response.data.session as PingPeerSessionSummary +} + +async function resumeSession( + apiUrl: string, + jwt: string, + sessionId: string, + http: AxiosInstance +): Promise { + const response = await http.post( + `${apiUrl}/api/sessions/${encodeURIComponent(sessionId)}/resume`, + {}, + { + headers: authHeaders(jwt), + timeout: 30_000, + validateStatus: () => true + } + ) + if (response.data?.type === 'success') { + return + } + const detail = typeof response.data?.message === 'string' + ? response.data.message + : typeof response.data?.error === 'string' + ? response.data.error + : typeof response.data?.code === 'string' + ? response.data.code + : `HTTP ${response.status}` + throw new PingPeerError('resume_failed', `resume failed: ${detail}`) +} + +async function waitUntilActive( + apiUrl: string, + jwt: string, + sessionId: string, + waitActiveSecs: number, + http: AxiosInstance, + sleep: (ms: number) => Promise, + now: () => number, + onProgress?: (message: string) => void +): Promise { + const deadline = now() + waitActiveSecs * 1000 + onProgress?.(`waiting up to ${waitActiveSecs}s for active state...`) + while (now() < deadline) { + const session = await getSession(apiUrl, jwt, sessionId, http) + if (session.active) { + return + } + await sleep(POLL_ACTIVE_MS) + } + throw new PingPeerError( + 'timeout', + `session did not become active within ${waitActiveSecs}s; runner may have failed to spawn` + ) +} + +async function waitForPiReady( + apiUrl: string, + jwt: string, + sessionId: string, + waitActiveSecs: number, + http: AxiosInstance, + sleep: (ms: number) => Promise, + now: () => number, + onProgress?: (message: string) => void +): Promise { + // active can precede piSessionId (tiann/hapi#1143). Instant /messages before + // get_state settles wedges (Prompt accepted / agent_start / silence). + onProgress?.(`flavor=pi - waiting up to ${waitActiveSecs}s for metadata.piSessionId...`) + const deadline = now() + waitActiveSecs * 1000 + while (now() < deadline) { + const session = await getSession(apiUrl, jwt, sessionId, http) + const piSessionId = session.metadata?.piSessionId + if (typeof piSessionId === 'string' && piSessionId.length > 0) { + onProgress?.(`piSessionId=${piSessionId}`) + return + } + await sleep(POLL_PI_READY_MS) + } + throw new PingPeerError( + 'timeout', + `piSessionId never appeared within ${waitActiveSecs}s; refusing to send (would likely wedge - see #1143)` + ) +} + +async function sendMessage( + apiUrl: string, + jwt: string, + sessionId: string, + message: string, + http: AxiosInstance +): Promise { + const response = await http.post( + `${apiUrl}/api/sessions/${encodeURIComponent(sessionId)}/messages`, + { text: message }, + { + headers: authHeaders(jwt), + timeout: 30_000, + validateStatus: () => true + } + ) + if (response.status >= 200 && response.status < 300 && response.data?.ok === true) { + return + } + const detail = typeof response.data?.error === 'string' + ? response.data.error + : typeof response.data?.code === 'string' + ? response.data.code + : `HTTP ${response.status}` + throw new PingPeerError('send_failed', `send failed: ${detail}`) +} + +export async function listPeerSessions( + options: ListPeerSessionsOptions = {} +): Promise { + const apiUrl = resolveApiUrl(options.apiUrl) + const accessToken = resolveAccessToken(options.accessToken) + const http = options.http ?? axios + const jwt = await exchangeJwt(apiUrl, accessToken, http) + return listSessions(apiUrl, jwt, http, options.limit ?? 200) +} + +export async function pingPeer(options: PingPeerOptions): Promise { + const prefix = options.sessionIdPrefix?.trim() ?? '' + const message = options.message ?? '' + if (!prefix) { + throw new PingPeerError('bad_args', 'session id prefix is required') + } + if (!message) { + throw new PingPeerError('bad_args', 'message is required') + } + + const waitActiveSecs = options.waitActiveSecs ?? DEFAULT_WAIT_ACTIVE_SECS + if (!Number.isFinite(waitActiveSecs) || waitActiveSecs <= 0) { + throw new PingPeerError('bad_args', 'waitActiveSecs must be a positive number') + } + + const apiUrl = resolveApiUrl(options.apiUrl) + const accessToken = resolveAccessToken(options.accessToken) + const http = options.http ?? axios + const sleep = options.sleep ?? defaultSleep + const now = options.now ?? Date.now + const onProgress = options.onProgress + + const jwt = await exchangeJwt(apiUrl, accessToken, http) + const sessions = await listSessions(apiUrl, jwt, http) + const matched = resolveSessionByPrefix(sessions, prefix) + const name = matched.metadata?.name ?? '(unnamed)' + onProgress?.(`resolved ${matched.id} active=${matched.active} name="${name}"`) + + let resumed = false + const ensureActive = async (progressMessage: string): Promise => { + const session = await getSession(apiUrl, jwt, matched.id, http) + if (session.active) { + return session + } + onProgress?.(progressMessage) + await resumeSession(apiUrl, jwt, matched.id, http) + resumed = true + await waitUntilActive(apiUrl, jwt, matched.id, waitActiveSecs, http, sleep, now, onProgress) + onProgress?.('session active') + return getSession(apiUrl, jwt, matched.id, http) + } + + // Prefer the list snapshot for the first resume decision, then re-check before + // send so a flip to inactive between list and POST cannot 409 (#1195). + if (!matched.active) { + await ensureActive('requesting resume...') + } + + let live = await ensureActive('session went inactive before send; requesting resume...') + if (live.metadata?.flavor === 'pi') { + await waitForPiReady(apiUrl, jwt, matched.id, waitActiveSecs, http, sleep, now, onProgress) + const beforePiResume = resumed + live = await ensureActive('session went inactive before send; requesting resume...') + if (resumed && !beforePiResume && live.metadata?.flavor === 'pi') { + // Fresh agent after mid-wait resume: wait for piSessionId again (#1143). + await waitForPiReady(apiUrl, jwt, matched.id, waitActiveSecs, http, sleep, now, onProgress) + live = await ensureActive('session went inactive before send; requesting resume...') + } + } + + onProgress?.(`sending message (${message.length} chars)...`) + await sendMessage(apiUrl, jwt, matched.id, message, http) + + return { + sessionId: matched.id, + name, + resumed + } +} + +export function exitCodeForPingPeerError(error: PingPeerError): number { + switch (error.code) { + case 'bad_args': + case 'auth_failed': + case 'not_found': + case 'ambiguous': + return 2 + case 'resume_failed': + return 3 + case 'timeout': + case 'send_failed': + return 4 + default: + return 1 + } +} diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index 1c22fad1..9235e362 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -14,6 +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 . ${SKILL_LOOKUP_INSTRUCTION} `); @@ -23,6 +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 . ${SKILL_LOOKUP_INSTRUCTION} `);