feat(cli): ping-peer CLI + MCP ping_peer for peer messaging (#1195)

* feat(cli): add ping-peer CLI and MCP ping_peer for peer messaging

Promote resume-if-inactive + wait-active + POST message into a first-class
CLI command and session MCP tool so agents stop reinventing JWT+curl.

Fixes #1194

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

* fix(cli): do not auto-approve MCP ping_peer

Cross-session messaging can resume a peer and inject a prompt, so keep
permission-mode gating (Codex PR review Major on #1195).

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

* fix(cli): require approval for ping_peer in read-only mode

Read-only auto-approve treated non-write names as safe; ping_peer can still
resume a peer and inject prompts, so gate it like a write tool.

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

* fix(cli): keep ping_peer out of Claude --allowedTools

toolNames still registers the MCP tool, but Claude auto-allow must not
pre-approve cross-session resume+inject without a permission prompt.

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

* fix(cli): re-check session active before ping-peer send

List/get can race; POST /messages still 409s if the target flips inactive
before send. Resume+wait again (and re-gate pi) immediately before POST.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-28 08:36:56 +08:00
committed by GitHub
co-authored by Cursor
parent 84cd9aa3b1
commit e2631a553a
20 changed files with 1241 additions and 21 deletions
@@ -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')
})
})
@@ -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,
+399
View File
@@ -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<MockResponse>
get?: (url: string) => 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) => {
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)
})
})
+424
View File
@@ -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<void>
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<void> {
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<string> {
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<string, string> {
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<PingPeerSessionSummary[]> {
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<PingPeerSessionSummary> {
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<void> {
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<void>,
now: () => number,
onProgress?: (message: string) => void
): Promise<void> {
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<void>,
now: () => number,
onProgress?: (message: string) => void
): Promise<void> {
// 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<void> {
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<PingPeerSessionSummary[]> {
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<PingPeerResult> {
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<PingPeerSessionSummary> => {
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
}
}