Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cli/src/commands/pingPeer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ ${chalk.bold('Usage:')}

${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.
Resolves by hub session id prefix (8 chars OK) or durable metadata.agentSessionId.
POST always targets the current hub row after resolve (hub ids rotate).
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.
Expand Down
134 changes: 133 additions & 1 deletion cli/src/modules/pingPeer/pingPeer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,80 @@ describe('resolveSessionByPrefix', () => {
} catch (error) {
expect(error).toBeInstanceOf(PingPeerError)
expect((error as PingPeerError).code).toBe('ambiguous')
expect((error as PingPeerError).message).toMatch(/hub id prefix/)
expect((error as PingPeerError).message).toMatch(/name=/)
}
})

it('refuses unknown prefixes', () => {
it('refuses unknown prefixes with actionable hints', () => {
expect(() => resolveSessionByPrefix(sessions, 'zzzz')).toThrowError(/no session matching/)
try {
resolveSessionByPrefix(sessions, 'zzzz')
} catch (error) {
expect((error as PingPeerError).message).toMatch(/agentSessionId prefix/)
expect((error as PingPeerError).message).toMatch(/Sample:/)
}
})

it('resolves by exact agentSessionId (case insensitive)', () => {
const withAgent: PingPeerSessionSummary[] = [
{
id: '11111111-1111-1111-1111-111111111111',
active: false,
updatedAt: 100,
metadata: { name: 'Stale row', agentSessionId: '8e1f4fd4-durable-agent' }
},
{
id: '22222222-2222-2222-2222-222222222222',
active: true,
updatedAt: 200,
metadata: { name: 'Current row', agentSessionId: '8e1f4fd4-durable-agent' }
}
]
expect(resolveSessionByPrefix(withAgent, '8e1f4fd4-durable-agent').id).toBe(withAgent[1]!.id)
expect(resolveSessionByPrefix(withAgent, '8E1F4FD4-DURABLE-AGENT').id).toBe(withAgent[1]!.id)
})

it('resolves by agentSessionId prefix and prefers active + newest', () => {
const withAgent: PingPeerSessionSummary[] = [
{
id: 'aaaaaaaa-1111-1111-1111-111111111111',
active: false,
updatedAt: 50,
metadata: { name: 'Old', agentSessionId: 'peer-agent-aaaa-old' }
},
{
id: 'bbbbbbbb-2222-2222-2222-222222222222',
active: true,
updatedAt: 100,
metadata: { name: 'Live', agentSessionId: 'peer-agent-bbbb-live' }
}
]
expect(resolveSessionByPrefix(withAgent, 'peer-agent-bbbb').id).toBe(withAgent[1]!.id)
})

it('refuses ambiguous agentSessionId prefix ties at the same active/updatedAt tier', () => {
const withAgent: PingPeerSessionSummary[] = [
{
id: 'aaaaaaaa-1111-1111-1111-111111111111',
active: true,
updatedAt: 100,
metadata: { name: 'A', agentSessionId: 'shared-prefix-1111' }
},
{
id: 'bbbbbbbb-2222-2222-2222-222222222222',
active: true,
updatedAt: 100,
metadata: { name: 'B', agentSessionId: 'shared-prefix-2222' }
}
]
expect(() => resolveSessionByPrefix(withAgent, 'shared-prefix')).toThrow(PingPeerError)
try {
resolveSessionByPrefix(withAgent, 'shared-prefix')
} catch (error) {
expect((error as PingPeerError).code).toBe('ambiguous')
expect((error as PingPeerError).message).toMatch(/agentSessionId prefix/)
}
})
})

Expand Down Expand Up @@ -671,4 +740,67 @@ describe('listSessions query params', () => {
expect(result.sessionId).toBe(sessionId)
expect(pingParams[0]).toBeUndefined()
})

it('resolves by agentSessionId prefix and posts to the current hub id', async () => {
const hubId = 'cccccccc-3333-3333-3333-333333333333'
const agentSessionId = '05d9f0f2-durable-cursor-thread'
const http = createHttpMock({
post: (url, body) => {
if (url.endsWith('/api/auth')) {
return { status: 200, data: { token: 'jwt' } }
}
if (url.endsWith(`/api/sessions/${hubId}/messages`)) {
expect(body).toEqual({ text: 'via agentSessionId' })
return { status: 200, data: { ok: true } }
}
throw new Error(`unexpected POST ${url}`)
},
get: (url) => {
if (url.endsWith('/api/sessions') && !url.includes(hubId)) {
return {
status: 200,
data: {
sessions: [{
id: hubId,
active: true,
metadata: {
name: 'Orchestrator',
flavor: 'cursor',
agentSessionId
}
}]
}
}
}
if (url.endsWith(`/api/sessions/${hubId}`)) {
return {
status: 200,
data: {
session: {
id: hubId,
active: true,
metadata: {
name: 'Orchestrator',
flavor: 'cursor',
agentSessionId
}
}
}
}
}
throw new Error(`unexpected GET ${url}`)
}
})

const result = await pingPeer({
sessionIdPrefix: '05d9f0f2',
message: 'via agentSessionId',
accessToken: 'tok',
apiUrl: 'http://hub.test',
http: http as never
})

expect(result.sessionId).toBe(hubId)
expect(result.name).toBe('Orchestrator')
})
})
123 changes: 110 additions & 13 deletions cli/src/modules/pingPeer/pingPeer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type PingPeerSessionSummary = {
path?: string | null
lifecycleState?: string | null
piSessionId?: string
agentSessionId?: string
summary?: { text?: string } | null
} | null
}
Expand Down Expand Up @@ -163,6 +164,82 @@ function authHeaders(jwt: string): Record<string, string> {
})
}

function readAgentSessionId(session: PingPeerSessionSummary): string {
const raw = session.metadata?.agentSessionId
return typeof raw === 'string' ? raw.trim() : ''
}

function sortPeersActiveThenUpdated(sessions: PingPeerSessionSummary[]): PingPeerSessionSummary[] {
return [...sessions].sort((a, b) => {
const aActive = a.active ? 0 : 1
const bActive = b.active ? 0 : 1
if (aActive !== bActive) {
return aActive - bActive
}
return (b.updatedAt ?? 0) - (a.updatedAt ?? 0)
})
}

function formatPeerResolveHint(session: PingPeerSessionSummary): string {
const name = session.metadata?.name?.trim() || resolvePeerSessionLabel(session)
const agentSessionId = readAgentSessionId(session)
const hubShort = session.id.slice(0, 8)
const agentPart = agentSessionId
? ` agentSessionId=${agentSessionId.length > 12 ? `${agentSessionId.slice(0, 12)}…` : agentSessionId}`
: ''
return `name="${name}" hubId=${hubShort}…${agentPart}`
}

function throwPeerResolveNotFound(trimmed: string, sessions: PingPeerSessionSummary[]): never {
const sample = sessions.slice(0, 3).map(formatPeerResolveHint).join('; ')
const hint = sample ? ` Sample: ${sample}.` : ''
throw new PingPeerError(
'not_found',
`no session matching '${trimmed}'. Try a longer hub id prefix or agentSessionId prefix.${hint}`
)
}

function throwPeerResolveAmbiguous(
trimmed: string,
matches: PingPeerSessionSummary[],
kind: string
): never {
const sample = matches.slice(0, 5).map(formatPeerResolveHint).join('; ')
throw new PingPeerError(
'ambiguous',
`'${trimmed}' matches ${matches.length} sessions by ${kind} (${sample}${matches.length > 5 ? '; …' : ''}); use a longer prefix`
)
}

function pickBestPeerMatch(
matches: PingPeerSessionSummary[],
trimmed: string,
kind: string
): PingPeerSessionSummary {
if (matches.length === 0) {
throw new PingPeerError('not_found', `no session matching '${trimmed}'`)
}
if (matches.length === 1) {
return matches[0]!
}
const sorted = sortPeersActiveThenUpdated(matches)
const top = sorted[0]!
const tied = sorted.filter(
(session) =>
session.active === top.active
&& (session.updatedAt ?? 0) === (top.updatedAt ?? 0)
)
if (tied.length > 1) {
throwPeerResolveAmbiguous(trimmed, tied, kind)
}
return top

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject prefixes spanning multiple durable peers

When an agentSessionId prefix or substring matches different durable peers with unequal activity or timestamps, pickBestPeerMatch silently selects the active/newest row instead of reporting ambiguity, so ping-peer can send a message to an unintended session. Activity/recency should select among churned hub rows only after grouping by the same normalized agentSessionId and flavor; distinct groups must remain ambiguous (the flavor distinction is already required by web/src/components/SessionList.tsx:189-195).

Useful? React with 👍 / 👎.

}

/**
* Resolve a peer session by hub id prefix or durable `metadata.agentSessionId`.
* Hub ids are ephemeral; agentSessionId survives hub-row churn (#1203).
* POST/resume always use the resolved hub `id` (current row).
*/
export function resolveSessionByPrefix(
sessions: PingPeerSessionSummary[],
prefix: string
Expand All @@ -172,23 +249,43 @@ export function resolveSessionByPrefix(
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 exactHub = sessions.filter((session) => session.id === trimmed)
if (exactHub.length === 1) {
return exactHub[0]!
Comment on lines +252 to +254

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redirect stale hub IDs to the current durable peer

When a stale and a current hub row share the same metadata.agentSessionId, supplying the stale row's exact /sessions/<id> value returns it immediately and bypasses the active/newest durable-ID selection below. pingPeer consequently resumes and posts to the stale hub ID rather than the current row, defeating the row-churn behavior this change advertises and potentially reviving a duplicate session. Resolve an exact hub match through its durable-ID group before returning it.

Useful? React with 👍 / 👎.

}

const matches = sessions.filter((session) => session.id.startsWith(trimmed))
if (matches.length === 0) {
throw new PingPeerError('not_found', `no session matching prefix '${trimmed}'`)
const hubPrefixMatches = sessions.filter((session) => session.id.startsWith(trimmed))
if (hubPrefixMatches.length === 1) {
return hubPrefixMatches[0]!
}
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`
)
if (hubPrefixMatches.length > 1) {
throwPeerResolveAmbiguous(trimmed, hubPrefixMatches, 'hub id prefix')
}
return matches[0]!

const needleLower = trimmed.toLowerCase()
const agentExact = sessions.filter(
(session) => readAgentSessionId(session).toLowerCase() === needleLower
)
if (agentExact.length > 0) {
return pickBestPeerMatch(agentExact, trimmed, 'agentSessionId')
}

const agentPrefixMatches = sessions.filter((session) =>
readAgentSessionId(session).toLowerCase().startsWith(needleLower)
)
if (agentPrefixMatches.length > 0) {
return pickBestPeerMatch(agentPrefixMatches, trimmed, 'agentSessionId prefix')
}

// Overseer `resolve` substring parity for agentSessionId when prefix is too short.
const agentContainsMatches = sessions.filter((session) =>
readAgentSessionId(session).toLowerCase().includes(needleLower)
)
if (agentContainsMatches.length > 0) {
return pickBestPeerMatch(agentContainsMatches, trimmed, 'agentSessionId')
}

throwPeerResolveNotFound(trimmed, sessions)
}

async function listSessions(
Expand Down
7 changes: 4 additions & 3 deletions shared/src/sessionCitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,16 @@ export const INSPECT_PEER_TOOL_DESCRIPTION =

/** MCP `ping_peer` tool description (same citation forms as inspect_peer). */
export const PING_PEER_TOOL_DESCRIPTION =
'Send a message to another HAPI session (peer handoff / nudge). Resolves by session id prefix, resumes if inactive, then POSTs on the same hub/namespace. ' +
'Send a message to another HAPI session (peer handoff / nudge). Resolves by hub session id prefix or durable metadata.agentSessionId, resumes if inactive, then POSTs on the same hub/namespace. ' +
'When the user cites a peer via [title](/sessions/<id>), Copy-reference prose See session "…" (/sessions/<id>) for context, or a bare /sessions/<id>, ' +
'extract <id> and pass it as sessionIdPrefix. /sessions/<id> is a hub path - do NOT search the local filesystem for it. ' +
'Prefer this (or `hapi ping-peer`) over reinventing JWT+curl. Targets another session - not the current chat.'

/** Zod `.describe` for sessionIdPrefix on inspect_peer / ping_peer. */
export const SESSION_ID_PREFIX_PARAM_DESCRIPTION =
'Target HAPI session id or unique id prefix (another session - not this chat). ' +
'Prefer the full UUID from [title](/sessions/<id>) or Copy-reference See session "…" (/sessions/<id>) for context.'
'Target HAPI session id, hub id prefix, or durable agentSessionId prefix (another session - not this chat). ' +
'Prefer the full UUID from [title](/sessions/<id>) or Copy-reference See session "…" (/sessions/<id>) for context; ' +
'agentSessionId survives hub-row churn when the cited hub id is stale.'

/**
* Hub session ids have no dots. Reject dotted tails so source paths like
Expand Down
Loading