mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-15 03:01:39 +00:00
* feat(cli): MCP list_peers + runner hub auth inheritance Runner-spawned agents could not discover same-hub peers without sitting on the hub host or pasting a session id. Add MCP list_peers (in-process credentials), export HAPI_API_URL/CLI_API_TOKEN after auth init for shell fallbacks, and clearer auth failure hints. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): do not export default hub URL into HAPI_API_URL exportHapiHubAuthEnv was writing the implicit localhost default into process.env, which made maybeAutoStartServer skip starting the bundled hub. Only export HAPI_API_URL when the URL came from env or settings; always still export CLI_API_TOKEN. Also fill missing deliveryMode on abort restore so web typecheck matches RawSendError (main tip unblock). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): widen initializeApiUrl mock return type in test Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): never export CLI_API_TOKEN; exclude self from list_peers Keep settings/prompt-backed hub secrets out of wrapped agent env so shell JWT+curl cannot bypass peer-tool approval. Fresh hapi re-reads settings; env-backed tokens already inherit. list_peers omits the calling session from the shortlist. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): resolve peer labels via summary/path like web titles list_peers was showing (unnamed) for ordinary sessions because titles live in metadata.summary.text. Match web getSessionTitle and collapse whitespace so each peer stays one agent-readable line. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli,hub): emit full peer ids and honor GET /sessions?limit Short 8-char prefixes collide across UUID namespaces; print full ids so resolveSessionByPrefix stays unambiguous. Honor optional limit after sort so listPeerSessions stops loading the whole namespace for scheduled counts. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): type sessions limit test mock as Map<string, number> CI tsc rejected Map<string, null> for getNextScheduledAtBySessionIds. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli,hub): unbounded ping resolve; peer list order=updatedAt Keep GET /sessions?limit only for discovery callers. ping/inspect omit limit so full UUIDs outside the first 500 stay resolvable. Peer lists pass order=updatedAt so truncation matches newest-first. Basename fallback splits Windows paths. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): auto-approve ACP title List Peer Sessions Permission derivation prefers request.title; match the MCP tool title form so default-mode ACP sessions do not prompt on discovery. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): pad list_peers fetch; split hub URL vs token hints Fetch limit+2 when excluding the caller so overflow still surfaces at limit=100. Clarify that auth login only saves the token, not HAPI_API_URL. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): use boolean overflow for ping-peer --list Match MCP list_peers: fetch limit+1 and mark hasMore instead of claiming an exact omitted count from a 200-row sample. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): tolerate mocked machineCache without expireInactive CI flake: 5s inactivity tick hit test doubles that only stubbed getOnlineMachinesByNamespace. Optional-call + stub the method. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
212 lines
6.7 KiB
TypeScript
212 lines
6.7 KiB
TypeScript
import { readFile } from 'node:fs/promises'
|
|
import chalk from 'chalk'
|
|
import { initializeToken } from '@/ui/tokenInit'
|
|
import {
|
|
PingPeerError,
|
|
exitCodeForPingPeerError,
|
|
formatPeerSessionsList,
|
|
listPeerSessions,
|
|
peerListFetchLimit,
|
|
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 <session-id-prefix> <message-text>
|
|
hapi ping-peer <session-id-prefix> --message-file <path>
|
|
hapi ping-peer <session-id-prefix> --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 / list_peers.
|
|
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.
|
|
When a user cites [title](/sessions/<id>) or Copy-reference
|
|
See session "…" (/sessions/<id>) for context, pass that <id> here.
|
|
On a remote runner, --list needs HAPI_API_URL set to the runner hub, plus
|
|
CLI_API_TOKEN or \`hapi auth login\` for the token. Inside a session prefer MCP list_peers.
|
|
|
|
${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<string> {
|
|
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<void> {
|
|
const maxRows = 30
|
|
const sessions = await listPeerSessions({
|
|
limit: peerListFetchLimit(maxRows)
|
|
})
|
|
console.log(formatPeerSessionsList(sessions, {
|
|
maxRows,
|
|
hasMore: sessions.length > maxRows
|
|
}))
|
|
}
|
|
|
|
export async function handlePingPeerCommand(args: string[]): Promise<void> {
|
|
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 <session-id> <message>')
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|