diff --git a/hub/src/overseer/brainClient.ts b/hub/src/overseer/brainClient.ts index 9b9af7c206..eeadc90636 100644 --- a/hub/src/overseer/brainClient.ts +++ b/hub/src/overseer/brainClient.ts @@ -8,6 +8,8 @@ * `BrainUnavailableError` so callers can degrade gracefully instead of erroring. */ +import type { OverseerConverseFocus } from '@hapi/protocol' + export type BrainConfig = { /** Base URL including the `/v1` suffix. */ baseUrl: string @@ -44,6 +46,9 @@ export type OverseerOpenAiToolLike = { export type BrainErrorKind = 'unreachable' | 'timeout' | 'http' | 'protocol' export class BrainUnavailableError extends Error { + /** Latest hub focus when the converse loop fails after successful tool resolves. */ + converseFocus: OverseerConverseFocus | null | undefined + constructor( message: string, readonly kind: BrainErrorKind = 'unreachable', diff --git a/hub/src/overseer/converse.test.ts b/hub/src/overseer/converse.test.ts index e00b6d5a3d..d2ac1dd3d5 100644 --- a/hub/src/overseer/converse.test.ts +++ b/hub/src/overseer/converse.test.ts @@ -205,7 +205,13 @@ describe('runOverseerConverse', () => { const { toolTrace } = await runOverseerConverse({ overseer, config, - messages: [{ role: 'operator', content: 'ping session sess-1: "hi"' }] + messages: [{ role: 'operator', content: 'ping session sess-1: "hi"' }], + focus: { + sessionId: 'sess-1', + itemId: null, + source: 'tool_resolve', + updatedAt: 1 + } }) expect(toolTrace[0]).toMatchObject({ @@ -243,7 +249,13 @@ describe('runOverseerConverse', () => { const { reply, toolTrace } = await runOverseerConverse({ overseer, config, - messages: [{ role: 'operator', content: 'ping session old-id: "please continue"' }] + messages: [{ role: 'operator', content: 'ping session old-id: "please continue"' }], + focus: { + sessionId: 'old-id', + itemId: null, + source: 'tool_resolve', + updatedAt: 1 + } }) expect(toolTrace).toHaveLength(1) @@ -253,7 +265,7 @@ describe('runOverseerConverse', () => { expect(reply).toContain('Do not retry') }) - it('refuses ping_session when the operator message has no write intent', async () => { + it('refuses ping_session when there is no conversational focus and no allowWrites', async () => { const pingSession = vi.fn() const overseer = { ...fakeOverseer, @@ -271,7 +283,7 @@ describe('runOverseerConverse', () => { })) .mockResolvedValueOnce(chatResponse({ role: 'assistant', - content: 'I cannot relay without an explicit operator request.' + content: 'I cannot relay without conversational focus.' })) setFetch(fetchMock) @@ -283,6 +295,492 @@ describe('runOverseerConverse', () => { expect(pingSession).not.toHaveBeenCalled() expect(toolTrace[0]).toMatchObject({ tool: 'ping_session', ok: false }) - expect(toolTrace[0]?.error).toMatch(/not authorized/i) + expect(toolTrace[0]?.error).toMatch(/not authorized|no conversational focus/i) + }) + + it('authorizes anaphoric ping_session from hub focus without ids in the follow-up line', async () => { + const sessionId = '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff' + const pingSession = vi.fn(async () => ({ + ok: true, + sessionId, + sessionName: 'W1.8 worker', + project: 'hapi', + resumed: true, + tombstone: `Relayed to W1.8 worker (${sessionId.slice(0, 8)}) [resumed]: "go ahead"` + })) + const overseer = { + ...fakeOverseer, + pingSession + } as unknown as OverseerEntity + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [{ + id: 'c1', + type: 'function', + function: { + name: 'ping_session', + arguments: JSON.stringify({ + sessionId, + itemId: 118, + message: 'go ahead — tear down and rebuild is fine' + }) + } + }] + })) + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: 'Relayed to the W1.8 worker.' + })) + setFetch(fetchMock) + + const { toolTrace, focus } = await runOverseerConverse({ + overseer, + config, + messages: [{ + role: 'operator', + content: 'tell it to go ahead - explain tear down and rebuild is the point' + }], + focus: { + sessionId, + itemId: 118, + source: 'tool_resolve', + updatedAt: 1 + } + }) + + expect(pingSession).toHaveBeenCalledOnce() + expect(toolTrace[0]).toMatchObject({ tool: 'ping_session', ok: true }) + expect(focus?.sessionId).toBe(sessionId) + expect(focus?.itemId).toBe(118) + }) + + it('sets focus from explain_priority and keeps multi-item inbox dumps from retargeting', async () => { + const sessionId = '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff' + const overseer = { + ...fakeOverseer, + explainPriority: () => ({ + inboxItemId: 118, + relatedSessionId: sessionId, + title: 'W1.8 acceptance' + }), + queryInbox: () => ({ + items: [ + { id: 1, title: 'noise', relatedSessionId: '96f67085-1111-2222-3333-444455556666' }, + { id: 118, title: 'W1.8', relatedSessionId: sessionId } + ], + candidates: [], + surfaced: [], + held: [] + }) + } as unknown as OverseerEntity + + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [{ + id: 'c1', + type: 'function', + function: { name: 'explain_priority', arguments: '{"itemId":118}' } + }] + })) + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [{ + id: 'c2', + type: 'function', + function: { name: 'query_inbox', arguments: '{"limit":25}' } + }] + })) + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: 'Item 118 is the W1.8 worker.' + })) + setFetch(fetchMock) + + const { focus } = await runOverseerConverse({ + overseer, + config, + messages: [{ role: 'operator', content: 'query it then' }], + focus: null + }) + + expect(focus?.itemId).toBe(118) + expect(focus?.sessionId).toBe(sessionId) + expect(focus?.source).toBe('tool_resolve') + }) + + it('persists mid-turn tool focus for the next turn but does not unlock same-turn writes', async () => { + const sessionId = '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff' + const pingSession = vi.fn(async () => ({ + ok: true, + sessionId, + sessionName: 'W1.8', + project: 'hapi', + resumed: true, + tombstone: 'Relayed' + })) + const overseer = { + ...fakeOverseer, + explainPriority: () => ({ + inboxItemId: 118, + relatedSessionId: sessionId, + title: 'W1.8' + }), + pingSession + } as unknown as OverseerEntity + + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [{ + id: 'c1', + type: 'function', + function: { name: 'explain_priority', arguments: '{"itemId":118}' } + }] + })) + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [{ + id: 'c2', + type: 'function', + function: { + name: 'ping_session', + arguments: JSON.stringify({ + sessionId, + itemId: 118, + message: 'go ahead' + }) + } + }] + })) + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: 'Need focus from a prior turn to relay.' + })) + setFetch(fetchMock) + + const { toolTrace, focus } = await runOverseerConverse({ + overseer, + config, + messages: [{ role: 'operator', content: 'tell it to go ahead' }], + focus: null + }) + + expect(toolTrace.find((t) => t.tool === 'explain_priority')?.ok).toBe(true) + expect(toolTrace.find((t) => t.tool === 'ping_session')?.ok).toBe(false) + expect(pingSession).not.toHaveBeenCalled() + // Focus is ready for the *next* operator turn. + expect(focus?.itemId).toBe(118) + expect(focus?.sessionId).toBe(sessionId) + }) + + it('does not last-win when multiple distinct subjects resolve in one turn', async () => { + const sessionA = '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff' + const sessionB = '96f67085-1111-2222-3333-444455556666' + const overseer = { + ...fakeOverseer, + getSessionState: ({ sessionId }: { sessionId: string }) => ({ + state: { sessionId, name: sessionId.slice(0, 8), active: true } + }) + } as unknown as OverseerEntity + + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { + name: 'get_session_state', + arguments: JSON.stringify({ sessionId: sessionA }) + } + }, + { + id: 'c2', + type: 'function', + function: { + name: 'get_session_state', + arguments: JSON.stringify({ sessionId: sessionB }) + } + } + ] + })) + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: 'Compared both workers.' + })) + setFetch(fetchMock) + + const { focus } = await runOverseerConverse({ + overseer, + config, + messages: [{ role: 'operator', content: 'compare these two workers' }], + focus: null + }) + + expect(focus).toBeNull() + }) + + it('treats re-resolving turn-start focus plus another subject as multi-subject', async () => { + const sessionA = '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff' + const sessionB = '96f67085-1111-2222-3333-444455556666' + const entity = { + ...fakeOverseer, + getSessionState: (sessionId: string) => ({ + sessionId, + name: sessionId.slice(0, 8), + active: true + }) + } as unknown as OverseerEntity + + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { + name: 'get_session_state', + arguments: JSON.stringify({ sessionId: sessionA }) + } + }, + { + id: 'c2', + type: 'function', + function: { + name: 'get_session_state', + arguments: JSON.stringify({ sessionId: sessionB }) + } + } + ] + })) + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: 'Compared A and B.' + })) + setFetch(fetchMock) + + const { focus } = await runOverseerConverse({ + overseer: entity, + config, + messages: [{ role: 'operator', content: 'compare A and B' }], + focus: { + sessionId: sessionA, + itemId: null, + source: 'client', + updatedAt: 1 + } + }) + + // Multi-subject → retain turn-start A, do not last-win to B + expect(focus?.sessionId).toBe(sessionA) + }) + + it('keeps focus when inbox item and same-session probe are compatible', async () => { + const sessionA = '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff' + const entity = { + ...fakeOverseer, + queryInbox: () => ({ + items: [{ id: 1, title: 'one', relatedSessionId: sessionA }], + candidates: [], + surfaced: [], + held: [] + }), + getSessionState: (sessionId: string) => ({ + sessionId, + name: 'W1.8', + active: true + }) + } as unknown as OverseerEntity + + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { name: 'query_inbox', arguments: '{"limit":1}' } + }, + { + id: 'c2', + type: 'function', + function: { + name: 'get_session_state', + arguments: JSON.stringify({ sessionId: sessionA }) + } + } + ] + })) + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: 'Same worker.' + })) + setFetch(fetchMock) + + const { focus } = await runOverseerConverse({ + overseer: entity, + config, + messages: [{ role: 'operator', content: 'look at the one inbox item then its health' }], + focus: null + }) + + expect(focus?.sessionId).toBe(sessionA) + expect(focus?.itemId).toBe(1) + }) + + it('treats two different inbox items on the same session as multi-subject', async () => { + const sessionA = '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff' + const entity = { + ...fakeOverseer, + explainPriority: ({ itemId }: { itemId: number }) => ({ + inboxItemId: itemId, + relatedSessionId: sessionA, + title: `item-${itemId}` + }) + } as unknown as OverseerEntity + + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { name: 'explain_priority', arguments: '{"itemId":1}' } + }, + { + id: 'c2', + type: 'function', + function: { name: 'explain_priority', arguments: '{"itemId":2}' } + } + ] + })) + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: 'Compared both items.' + })) + setFetch(fetchMock) + + const { focus } = await runOverseerConverse({ + overseer: entity, + config, + messages: [{ role: 'operator', content: 'compare item 1 and 2' }], + focus: null + }) + + expect(focus).toBeNull() + }) + + it('still retargets when a non-retargeting read precedes a real subject change', async () => { + const sessionA = '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff' + const sessionB = '96f67085-1111-2222-3333-444455556666' + const overseer = { + ...fakeOverseer, + queryInbox: () => ({ + items: [ + { id: 1, title: 'noise', relatedSessionId: sessionA }, + { id: 2, title: 'other', relatedSessionId: sessionB } + ], + candidates: [], + surfaced: [], + held: [] + }), + explainPriority: () => ({ + inboxItemId: 99, + relatedSessionId: sessionB, + title: 'B' + }) + } as unknown as OverseerEntity + + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'c1', + type: 'function', + function: { name: 'query_inbox', arguments: '{"limit":10}' } + }, + { + id: 'c2', + type: 'function', + function: { name: 'explain_priority', arguments: '{"itemId":99}' } + } + ] + })) + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: 'Focused on B.' + })) + setFetch(fetchMock) + + const { focus } = await runOverseerConverse({ + overseer, + config, + messages: [{ role: 'operator', content: 'look at inbox then explain 99' }], + focus: { + sessionId: sessionA, + itemId: 1, + source: 'tool_resolve', + updatedAt: 1 + } + }) + + expect(focus?.sessionId).toBe(sessionB) + expect(focus?.itemId).toBe(99) + }) + + it('attaches mid-turn focus onto BrainUnavailableError after a resolving tool', async () => { + const sessionId = '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff' + const overseer = { + ...fakeOverseer, + explainPriority: () => ({ + inboxItemId: 118, + relatedSessionId: sessionId, + title: 'W1.8' + }) + } as unknown as OverseerEntity + + const fetchMock = vi.fn() + .mockResolvedValueOnce(chatResponse({ + role: 'assistant', + content: '', + tool_calls: [{ + id: 'c1', + type: 'function', + function: { name: 'explain_priority', arguments: '{"itemId":118}' } + }] + })) + .mockRejectedValueOnce(new TypeError('fetch failed')) + setFetch(fetchMock) + + try { + await runOverseerConverse({ + overseer, + config, + messages: [{ role: 'operator', content: 'what is item 118?' }], + focus: null + }) + expect.unreachable('should have thrown') + } catch (error) { + expect(error).toBeInstanceOf(BrainUnavailableError) + const brainErr = error as BrainUnavailableError + expect(brainErr.converseFocus?.itemId).toBe(118) + expect(brainErr.converseFocus?.sessionId).toBe(sessionId) + } }) }) diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index 668044860c..a6c916ac5a 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -8,13 +8,16 @@ */ import { + applyFocusFromToolResolve, buildOverseerOpenAiTools, buildOverseerSystemPrompt, fingerprintWriteToolCall, + formatConverseFocusDirective, + hasConverseFocusSubject, isOverseerWriteTool, - isWriteToolAuthorized, isWriteToolCallAuthorized, resolveOverseerWriteAuthorization, + type OverseerConverseFocus, type OverseerConverseMessage, type OverseerToolName, type OverseerToolTraceEntry, @@ -25,6 +28,7 @@ import { isOverseerToolName, runOverseerTool } from './runOverseerTool' import { projectToolResultForBrain } from './toolProjection' import { callBrain, + BrainUnavailableError, type BrainConfig, type OpenAiChatMessage, type OverseerOpenAiToolLike @@ -106,6 +110,23 @@ function hasSuccessfulWrite(toolTrace: OverseerToolTraceEntry[]): boolean { return toolTrace.some((entry) => entry.ok && isOverseerWriteTool(entry.tool as OverseerToolName)) } +/** True when two resolved subjects refer to the same conversational referent. */ +function subjectsCompatible( + a: OverseerConverseFocus, + b: OverseerConverseFocus +): boolean { + const aItem = a.itemId != null && a.itemId > 0 ? a.itemId : null + const bItem = b.itemId != null && b.itemId > 0 ? b.itemId : null + // Distinct inbox items are never the same referent, even on one session. + if (aItem != null && bItem != null && aItem !== bItem) return false + + const aSession = a.sessionId?.trim().toLowerCase() || null + const bSession = b.sessionId?.trim().toLowerCase() || null + if (aSession && bSession && aSession === bSession) return true + if (aItem != null && bItem != null && aItem === bItem) return true + return false +} + export async function runOverseerConverse(params: { overseer: OverseerEntity config: BrainConfig @@ -114,22 +135,49 @@ export async function runOverseerConverse(params: { signal?: AbortSignal /** Explicit client opt-in for write tools (admin/voice confirm). */ allowWrites?: boolean -}): Promise<{ reply: string; toolTrace: OverseerToolTraceEntry[] }> { + /** Hub-owned subject from prior turns (session and/or inbox item). */ + focus?: OverseerConverseFocus | null +}): Promise<{ + reply: string + toolTrace: OverseerToolTraceEntry[] + focus: OverseerConverseFocus | null +}> { const { overseer, config, messages, maxIterations = 6, signal, allowWrites } = params const latestOperatorText = [...messages].reverse().find((m) => m.role === 'operator')?.content ?? '' - const writeAuth: OverseerWriteAuthorization = resolveOverseerWriteAuthorization({ - latestOperatorText, - allowWrites - }) + /** Focus that may advance from tool resolves — persisted for the next operator turn. */ + let focus = params.focus ?? null + /** + * Write grants are frozen at turn start. Mid-turn tool resolves must not unlock + * ping/disposition against a model-chosen subject in the same turn (Codex P1). + * Cross-turn "tell it…" uses the focus persisted from the prior turn. + */ + const writeFocus = params.focus ?? null + /** Strictly above prior focus version — equal wall-clock ms cannot clobber. */ + const turnStartedAt = Math.max(Date.now(), (params.focus?.updatedAt ?? 0) + 1) + + const writeAuthFor = (): OverseerWriteAuthorization => + resolveOverseerWriteAuthorization({ + latestOperatorText, + allowWrites, + focus: writeFocus + }) - const tools = (buildOverseerOpenAiTools() as OverseerOpenAiToolLike[]).filter((tool) => { - const name = tool.function?.name ?? '' - return isWriteToolAuthorized(name, writeAuth) - }) + // Full catalog always exposed; write authorization is enforced at call time + // against turn-start focus (or allowWrites). + const tools = buildOverseerOpenAiTools() as OverseerOpenAiToolLike[] const clockLine = `Server time now: ${new Date().toISOString()} (epoch ms ${Date.now()}, timezone ${Intl.DateTimeFormat().resolvedOptions().timeZone}). Relative snoozes must use absolute snoozedUntil epoch ms from this clock.` + const focusDirective = formatConverseFocusDirective(focus) + const systemContent = [ + buildOverseerSystemPrompt(), + GROUNDING_DIRECTIVE, + focusDirective, + `# Clock\n\n${clockLine}` + ] + .filter((block): block is string => Boolean(block && block.trim())) + .join('\n\n') const convo: OpenAiChatMessage[] = [ - { role: 'system', content: `${buildOverseerSystemPrompt()}\n\n${GROUNDING_DIRECTIVE}\n\n# Clock\n\n${clockLine}` }, + { role: 'system', content: systemContent }, ...messages.map((m): OpenAiChatMessage => ({ role: m.role === 'operator' ? 'user' : 'assistant', content: m.content @@ -141,6 +189,12 @@ export async function runOverseerConverse(params: { const writeConfirmations: string[] = [] /** Successful irreversible call fingerprints — reject duplicates in this turn. */ const consumedWriteFingerprints = new Set() + /** + * Subjects this turn's tools identified on their own (apply-from-null). + * Incompatible subjects → do not last-win; keep turn-start focus. + * Compatible pairs (same session or same item) count as one referent. + */ + const subjectsResolvedThisTurn: OverseerConverseFocus[] = [] // The brain (llama-server) does not honor tool_choice:'required', so it will // sometimes answer a fleet question from nothing (e.g. "the inbox is empty" // when it never called query_inbox). Guardrail: if the very first answer @@ -148,6 +202,30 @@ export async function runOverseerConverse(params: { // it to verify. If it still declines, the question genuinely needed no tool. let nudged = false + const finish = (reply: string) => ({ reply, toolTrace, focus }) + + const applyToolFocus = ( + previous: OverseerConverseFocus | null, + event: Parameters[1] + ): OverseerConverseFocus | null => { + // What subject did THIS tool identify on its own (ignore prior focus passthrough)? + const identifiedAlone = applyFocusFromToolResolve(null, event, turnStartedAt) + if (hasConverseFocusSubject(identifiedAlone) && identifiedAlone) { + subjectsResolvedThisTurn.push(identifiedAlone) + } + + const next = applyFocusFromToolResolve(previous, event, turnStartedAt) + if (subjectsResolvedThisTurn.length > 1) { + const first = subjectsResolvedThisTurn[0]! + const multi = subjectsResolvedThisTurn.some((s) => !subjectsCompatible(first, s)) + if (multi) { + // Multi-subject comparison turn — refuse to invent a last-wins referent. + return params.focus ?? null + } + } + return next + } + for (let iter = 0; iter < maxIterations; iter++) { let message: OpenAiChatMessage try { @@ -156,7 +234,12 @@ export async function runOverseerConverse(params: { // Irreversible writes already landed — return their audit trail so the // route can record the turn and the operator does not duplicate-retry. if (hasSuccessfulWrite(toolTrace)) { - return { reply: fallbackReplyAfterWriteSuccess(writeConfirmations), toolTrace } + return finish(fallbackReplyAfterWriteSuccess(writeConfirmations)) + } + if (error instanceof BrainUnavailableError) { + // Carry mid-turn tool-resolved focus so the route can persist it + // even when the follow-up brain call fails (Codex P2). + error.converseFocus = focus } throw error } @@ -172,7 +255,7 @@ export async function runOverseerConverse(params: { }) continue } - return { reply: (message.content ?? '').trim(), toolTrace } + return finish((message.content ?? '').trim()) } // Execute the requested tools and feed the results back as a plain USER @@ -201,7 +284,7 @@ export async function runOverseerConverse(params: { resultLines.push(`${name}(${argsRaw}) => ${JSON.stringify({ error: deferred })}`) continue } - const authz = isWriteToolCallAuthorized(name, args, writeAuth) + const authz = isWriteToolCallAuthorized(name, args, writeAuthFor()) if (!authz.ok) { toolTrace.push({ tool: name, args, ok: false, error: authz.error }) resultLines.push(`${name}(${argsRaw}) => ${JSON.stringify({ error: authz.error })}`) @@ -227,6 +310,14 @@ export async function runOverseerConverse(params: { ok, ...(ok ? {} : { error: toolResultError(result) }) }) + if (ok) { + focus = applyToolFocus(focus, { + tool: name, + ok: true, + args, + result + }) + } if (ok && isOverseerWriteTool(name)) { consumedWriteFingerprints.add(fingerprintWriteToolCall(name, args)) const tombstone = writeResultTombstone(result) @@ -255,10 +346,15 @@ export async function runOverseerConverse(params: { messages: [...convo, { role: 'user', content: 'Answer now in plain text, no more tools.' }], signal }) - return { reply: (finalMsg.content ?? '').trim() || 'I gathered the data but could not compose an answer.', toolTrace } + return finish( + (finalMsg.content ?? '').trim() || 'I gathered the data but could not compose an answer.' + ) } catch (error) { if (hasSuccessfulWrite(toolTrace)) { - return { reply: fallbackReplyAfterWriteSuccess(writeConfirmations), toolTrace } + return finish(fallbackReplyAfterWriteSuccess(writeConfirmations)) + } + if (error instanceof BrainUnavailableError) { + error.converseFocus = focus } throw error } diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index 79dda16f18..119ea12106 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -118,6 +118,18 @@ export class Store { this.settings = new SettingsStore(this.db) } + /** + * Delete a session row and clear conversational focus when it pointed at + * that id. Prefer this over `sessions.deleteSession` so cache-bypass paths + * (e.g. Codex duplicate merge without a SyncEngine) cannot leave a stale + * anaphoric target. + */ + deleteSession(id: string, namespace: string): boolean { + const deleted = this.sessions.deleteSession(id, namespace) + if (deleted) this.settings.clearConverseFocusIfSession(id, namespace) + return deleted + } + close(): void { if (this.closed) return this.db.close() diff --git a/hub/src/store/settingsStore.test.ts b/hub/src/store/settingsStore.test.ts index 42b45f1c70..88c31dbbdb 100644 --- a/hub/src/store/settingsStore.test.ts +++ b/hub/src/store/settingsStore.test.ts @@ -43,6 +43,113 @@ describe('SettingsStore', () => { expect(s.getActiveBrain()).toBeNull() }) + it('round-trips conversational focus per namespace', () => { + const s = freshStore() + expect(s.getConverseFocus()).toBeNull() + s.setConverseFocus({ + sessionId: '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff', + itemId: 118, + source: 'tool_resolve', + updatedAt: 42 + }) + expect(s.getConverseFocus()).toEqual({ + sessionId: '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff', + itemId: 118, + source: 'tool_resolve', + updatedAt: 42 + }) + s.setConverseFocus( + { + sessionId: 'other', + itemId: null, + source: 'client', + updatedAt: 99 + }, + 'ns-a' + ) + expect(s.getConverseFocus('ns-a')?.sessionId).toBe('other') + expect(s.getConverseFocus()?.itemId).toBe(118) + s.clearConverseFocus() + const tombstone = s.getConverseFocus() + expect(tombstone?.sessionId).toBeNull() + expect(tombstone?.itemId).toBeNull() + expect(typeof tombstone?.updatedAt).toBe('number') + }) + + it('tombstone blocks older in-flight focus resurrection', () => { + const s = freshStore() + s.setConverseFocus({ + sessionId: 'sess', + itemId: null, + source: 'tool_resolve', + updatedAt: 100 + }) + s.clearConverseFocus() + const clearedAt = s.getConverseFocus()?.updatedAt ?? 0 + expect(clearedAt).toBeGreaterThanOrEqual(100) + expect( + s.setConverseFocusIfNewer({ + sessionId: 'sess', + itemId: null, + source: 'tool_resolve', + updatedAt: clearedAt - 1 + }) + ).toBe(false) + expect(s.getConverseFocus()?.sessionId).toBeNull() + }) + + it('setConverseFocusIfNewer refuses older overlapping writes', () => { + const s = freshStore() + s.setConverseFocus({ + sessionId: 'new', + itemId: 1, + source: 'tool_resolve', + updatedAt: 200 + }) + expect( + s.setConverseFocusIfNewer({ + sessionId: 'old', + itemId: 2, + source: 'client', + updatedAt: 100 + }) + ).toBe(false) + expect(s.getConverseFocus()?.sessionId).toBe('new') + expect( + s.setConverseFocusIfNewer({ + sessionId: 'newer', + itemId: 3, + source: 'tool_resolve', + updatedAt: 300 + }) + ).toBe(true) + expect(s.getConverseFocus()?.sessionId).toBe('newer') + expect( + s.setConverseFocusIfNewer({ + sessionId: 'same-ms', + itemId: 4, + source: 'client', + updatedAt: 300 + }) + ).toBe(false) + expect(s.getConverseFocus()?.sessionId).toBe('newer') + }) + + it('repoints and clears focus when sessions merge or delete', () => { + const s = freshStore() + s.setConverseFocus({ + sessionId: 'old-id', + itemId: 118, + source: 'tool_resolve', + updatedAt: 1 + }) + s.repointConverseFocusSession('old-id', 'new-id') + expect(s.getConverseFocus()?.sessionId).toBe('new-id') + s.clearConverseFocusIfSession('new-id') + expect(s.getConverseFocus()?.sessionId).toBeNull() + expect(s.getConverseFocus()?.itemId).toBe(118) + }) + it('DDL is idempotent', () => { const db = new Database(':memory:', { strict: true }) ensureOverseerSettingsSchema(db) diff --git a/hub/src/store/settingsStore.ts b/hub/src/store/settingsStore.ts index 00cba84914..028de9058d 100644 --- a/hub/src/store/settingsStore.ts +++ b/hub/src/store/settingsStore.ts @@ -1,4 +1,5 @@ import type { Database } from 'bun:sqlite' +import { parseConverseFocus, type OverseerConverseFocus } from '@hapi/protocol' /** * Tiny key/value settings table for hub-side runtime config that must survive a restart and be @@ -23,12 +24,18 @@ export type ActiveBrainSetting = { } const ACTIVE_BRAIN_KEY = 'active_brain' +const CONVERSE_FOCUS_KEY = 'converse_focus' function activeBrainKey(namespace: string): string { const ns = namespace.trim() || 'default' return ns === 'default' ? ACTIVE_BRAIN_KEY : `${ACTIVE_BRAIN_KEY}:${ns}` } +function converseFocusKey(namespace: string): string { + const ns = namespace.trim() || 'default' + return ns === 'default' ? CONVERSE_FOCUS_KEY : `${CONVERSE_FOCUS_KEY}:${ns}` +} + export class SettingsStore { constructor(private readonly db: Database) {} @@ -72,4 +79,96 @@ export class SettingsStore { clearActiveBrain(namespace = 'default'): void { this.delete(activeBrainKey(namespace)) } + + /** Hub-owned conversational focus for talk-to (session and/or inbox item). */ + getConverseFocus(namespace = 'default'): OverseerConverseFocus | null { + const raw = this.get(converseFocusKey(namespace)) + if (!raw) return null + try { + return parseConverseFocus(JSON.parse(raw) as unknown) + } catch { + return null + } + } + + setConverseFocus(value: OverseerConverseFocus, namespace = 'default'): void { + this.set( + converseFocusKey(namespace), + JSON.stringify({ + sessionId: value.sessionId, + itemId: value.itemId, + source: value.source, + updatedAt: value.updatedAt + }) + ) + } + + /** + * Persist focus only when it is strictly newer than the durable row. + * Equal timestamps refuse (first writer wins) so same-ms overlapping turns + * cannot resurrect a deleted or superseded subject. + */ + setConverseFocusIfNewer(value: OverseerConverseFocus, namespace = 'default'): boolean { + const current = this.getConverseFocus(namespace) + if (current && current.updatedAt >= value.updatedAt) return false + this.setConverseFocus(value, namespace) + return true + } + + /** Wall clock, but always strictly above the durable focus version. */ + private nextFocusUpdatedAt(namespace: string): number { + const current = this.getConverseFocus(namespace) + const floor = current?.updatedAt ?? 0 + return Math.max(Date.now(), floor + 1) + } + + /** + * Clear live subject by writing a timestamped tombstone (not a row delete). + * Keeps setConverseFocusIfNewer able to reject older in-flight turns that + * still hold the deleted session (Codex P2). + */ + clearConverseFocus(namespace = 'default'): void { + this.setConverseFocus( + { + sessionId: null, + itemId: null, + source: 'client', + updatedAt: this.nextFocusUpdatedAt(namespace) + }, + namespace + ) + } + + /** After session merge/resume remaps ids — keep anaphoric writes on the live session. */ + repointConverseFocusSession( + oldSessionId: string, + newSessionId: string, + namespace = 'default' + ): void { + const focus = this.getConverseFocus(namespace) + if (!focus?.sessionId) return + if (focus.sessionId.toLowerCase() !== oldSessionId.trim().toLowerCase()) return + this.setConverseFocus({ + ...focus, + sessionId: newSessionId, + updatedAt: this.nextFocusUpdatedAt(namespace) + }, namespace) + } + + /** Drop session slot (or clear entirely) when the focused session is deleted. */ + clearConverseFocusIfSession(sessionId: string, namespace = 'default'): void { + const focus = this.getConverseFocus(namespace) + if (!focus?.sessionId) return + if (focus.sessionId.toLowerCase() !== sessionId.trim().toLowerCase()) return + if (focus.itemId != null) { + this.setConverseFocus({ + sessionId: null, + itemId: focus.itemId, + source: focus.source, + updatedAt: this.nextFocusUpdatedAt(namespace) + }, namespace) + return + } + this.clearConverseFocus(namespace) + } } diff --git a/hub/src/sync/overseerEntity.ts b/hub/src/sync/overseerEntity.ts index d595f7d3ee..2999d565e2 100644 --- a/hub/src/sync/overseerEntity.ts +++ b/hub/src/sync/overseerEntity.ts @@ -858,6 +858,14 @@ export class OverseerEntity { return this.matchSessions(related).length === 1 } + /** + * Exact session id, else unique prefix (hapi-ping-peer / loomux / pi pattern). + * Ambiguous or unknown → null. Never silently picks among collisions. + */ + resolveCanonicalSessionId(sessionId: string): string | null { + return this.resolveSession(sessionId)?.id ?? null + } + /** * Exact session id, else unique prefix (hapi-ping-peer / loomux / pi pattern). * Ambiguous or unknown → undefined. Never silently picks among collisions. @@ -871,9 +879,10 @@ export class OverseerEntity { private matchSessions(sessionId: string): Session[] { const trimmed = sessionId.trim() if (!trimmed) return [] - const exact = this.getSession(trimmed) + const lower = trimmed.toLowerCase() + const exact = this.getSession(trimmed) ?? this.getSessions().find((s) => s.id.toLowerCase() === lower) if (exact) return [exact] - return this.getSessions().filter((s) => s.id.startsWith(trimmed)) + return this.getSessions().filter((s) => s.id.toLowerCase().startsWith(lower)) } private parseEventPayload(payloadJson: string | null): { diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 0d2da5bd86..8c2ee0b5e4 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -798,7 +798,7 @@ export class SessionCache { throw new Error('Cannot delete active session') } - const deleted = this.store.sessions.deleteSession(sessionId, session.namespace) + const deleted = this.store.deleteSession(sessionId, session.namespace) if (!deleted) { throw new Error('Failed to delete session') } @@ -950,7 +950,8 @@ export class SessionCache { if (options.deleteOldSession) { this.store.events.repointSession(oldSessionId, newSessionId) this.store.inbox.repointSession(oldSessionId, newSessionId) - const deleted = this.store.sessions.deleteSession(oldSessionId, namespace) + this.store.settings.repointConverseFocusSession(oldSessionId, newSessionId, namespace) + const deleted = this.store.deleteSession(oldSessionId, namespace) if (!deleted) { throw new Error('Failed to delete old session during merge') } diff --git a/hub/src/web/routes/codexDesktop.ts b/hub/src/web/routes/codexDesktop.ts index 82e5d71fe3..410c976fb8 100644 --- a/hub/src/web/routes/codexDesktop.ts +++ b/hub/src/web/routes/codexDesktop.ts @@ -1212,7 +1212,7 @@ async function mergeSingleDuplicateCodexSessionGroup(options: { if (engine) { await engine.deleteSession(source.sessionId) } else { - const deleted = options.store.sessions.deleteSession(source.sessionId, options.namespace) + const deleted = options.store.deleteSession(source.sessionId, options.namespace) if (!deleted) { throw new Error(`Failed to delete duplicate Hapi session: ${source.sessionId}`) } diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index e75a8311e0..c929e9bc61 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -15,6 +15,23 @@ import { runOverseerConverse } from '../../overseer/converse' import { assembleOverseerConverseMessages, listRecentConvoTurns, persistOverseerConvoExchange } from '../../overseer/converseContext' import { BrainUnavailableError, filterChatModels, isKnownBrainProfile, listBrainModels, listBrainProfiles, resolveBrainConfig, resolveBrainSelection } from '../../overseer/brainClient' import type { ActiveBrainSetting } from '../../store/settingsStore' +import { applyFocusFromClientSession } from '@hapi/protocol' +import type { OverseerEntity } from '../../sync/overseerEntity' + +/** + * Client relatedSessionId must resolve to one live canonical session before it + * can authorize write prefixes. Ambiguous/unknown values (including nonexistent + * full UUIDs) return null — never seed an unresolved id that could grant a + * colliding short-prefix ping. + */ +function resolveClientRelatedSessionId( + overseer: OverseerEntity, + relatedSessionId: string | null | undefined +): string | null { + const raw = typeof relatedSessionId === 'string' ? relatedSessionId.trim() : '' + if (!raw) return null + return overseer.resolveCanonicalSessionId(raw) +} const convoTurnBodySchema = z.object({ operatorText: z.string().max(8000).default(''), @@ -231,12 +248,27 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho } const overseer = engine.getOverseer(c.get('namespace')) + const namespace = c.get('namespace') + const settings = engine.getSettings() const assembled = assembleOverseerConverseMessages({ overseer, clientMessages }) const messages = assembled.messages const lastOperator = [...messages].reverse().find((m) => m.role === 'operator')?.content ?? '' + const durableFocus = settings.getConverseFocus(namespace) + const rawRelated = parsed.data.relatedSessionId + const clientSessionSeed = resolveClientRelatedSessionId(overseer, rawRelated) + // Nonempty relatedSessionId that failed canonical resolve must not + // silently inherit durable focus (would authorize the wrong worker). + const priorFocus = + typeof rawRelated === 'string' && rawRelated.trim() && !clientSessionSeed + ? null + : applyFocusFromClientSession( + durableFocus, + clientSessionSeed, + Math.max(Date.now(), (durableFocus?.updatedAt ?? 0) + 1) + ) const active = getSanitizedActiveBrain(engine, c.get('namespace')) const config = resolveBrainConfig(process.env, resolveBrainSelection(active, { @@ -253,10 +285,11 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho profile: parsed.data.profile ?? null }) const reply = 'The Overseer brain is not configured on this hub (set OVERSEER_BRAIN_URL). I can still show raw events and inbox items, but I cannot answer in conversation yet.' + if (priorFocus) settings.setConverseFocusIfNewer(priorFocus, namespace) persistOverseerConvoExchange(overseer, assembled, { operatorText: lastOperator, overseerText: reply, - relatedSessionId: parsed.data.relatedSessionId ?? null + relatedSessionId: parsed.data.relatedSessionId ?? priorFocus?.sessionId ?? null }) return c.json({ reply, @@ -264,22 +297,35 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho model: null, brainOnline: false, hydratedTurns: assembled.hydratedTurns, - truncated: assembled.truncated + truncated: assembled.truncated, + focus: priorFocus }) } + // Publish client-selected focus before awaiting the brain so overlapping + // converse requests (voice/text/devices) observe the new referent. + if (priorFocus) settings.setConverseFocusIfNewer(priorFocus, namespace) + try { - const { reply, toolTrace } = await runOverseerConverse({ + const { reply, toolTrace, focus } = await runOverseerConverse({ overseer, config, messages, - allowWrites: parsed.data.allowWrites + allowWrites: parsed.data.allowWrites, + focus: priorFocus }) + if (focus) { + settings.setConverseFocusIfNewer(focus, namespace) + } + // Do not clear durable focus on an empty result — a concurrent newer + // turn may have already advanced it (lost-update race). + persistOverseerConvoExchange(overseer, assembled, { operatorText: lastOperator, overseerText: reply, - relatedSessionId: parsed.data.relatedSessionId ?? null, + relatedSessionId: + parsed.data.relatedSessionId ?? focus?.sessionId ?? null, toolCalls: toolTrace .filter((t) => t.ok) .map((t) => ({ tool: t.tool, argsSummary: JSON.stringify(t.args).slice(0, 500) })) @@ -291,7 +337,8 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho model: config.model, brainOnline: true, hydratedTurns: assembled.hydratedTurns, - truncated: assembled.truncated + truncated: assembled.truncated, + focus }) } catch (error) { if (error instanceof BrainUnavailableError) { @@ -310,10 +357,13 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho const reply = error.reachable ? 'I reached the Overseer brain but could not complete the tool conversation (request error). This is a converse-loop issue, not the brain being offline — please retry, and flag it if it persists.' : 'The Overseer brain is offline right now. Try again shortly — your events and inbox are still being captured.' + const focusToPersist = error.converseFocus ?? priorFocus + if (focusToPersist) settings.setConverseFocusIfNewer(focusToPersist, namespace) persistOverseerConvoExchange(overseer, assembled, { operatorText: lastOperator, overseerText: reply, - relatedSessionId: parsed.data.relatedSessionId ?? null + relatedSessionId: + parsed.data.relatedSessionId ?? focusToPersist?.sessionId ?? null }) return c.json({ reply, @@ -321,7 +371,8 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho model: config.model, brainOnline: error.reachable, hydratedTurns: assembled.hydratedTurns, - truncated: assembled.truncated + truncated: assembled.truncated, + focus: focusToPersist }) } throw error diff --git a/shared/src/index.ts b/shared/src/index.ts index c5f60ab413..e84664d8c5 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -5,6 +5,7 @@ export * from './overseerEvents' export * from './overseerInbox' export * from './overseerEntity' export * from './overseerWriteIntent' +export * from './overseerConverseFocus' export * from './overseerConverse' export * from './buildInfo' export * from './effort' diff --git a/shared/src/overseerConverse.ts b/shared/src/overseerConverse.ts index e98702f522..098bcd15d8 100644 --- a/shared/src/overseerConverse.ts +++ b/shared/src/overseerConverse.ts @@ -79,6 +79,13 @@ export type OverseerConverseResponse = { hydratedTurns?: number /** True when older turns were dropped to stay under the history budget. */ truncated?: boolean + /** Hub-owned conversational focus after this turn (session and/or inbox item). */ + focus?: { + sessionId: string | null + itemId: number | null + source: 'tool_resolve' | 'client' + updatedAt: number + } | null } /** One durable operator↔Overseer exchange for transport hydrate (UI / voice attach). */ diff --git a/shared/src/overseerConverseFocus.test.ts b/shared/src/overseerConverseFocus.test.ts new file mode 100644 index 0000000000..71367325c8 --- /dev/null +++ b/shared/src/overseerConverseFocus.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from 'vitest' +import { + applyFocusFromClientSession, + applyFocusFromToolResolve, + formatConverseFocusDirective, + hasConverseFocusSubject, + parseConverseFocus, + type OverseerConverseFocus +} from './overseerConverseFocus' +import { + isWriteToolCallAuthorized, + resolveOverseerWriteAuthorization +} from './overseerWriteIntent' + +const SESSION_A = '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff' +const SESSION_B = '96f67085-1111-2222-3333-444455556666' + +function focus(partial: Partial = {}): OverseerConverseFocus { + return { + sessionId: SESSION_A, + itemId: 118, + source: 'tool_resolve', + updatedAt: 1_700_000_000_000, + ...partial + } +} + +describe('hub-owned conversational focus (capability, not pattern matching)', () => { + it('authorizes natural-language action against established focus without ids in the utterance', () => { + const auth = resolveOverseerWriteAuthorization({ + latestOperatorText: 'tell it to go ahead - explain we can tear down and rebuild', + focus: focus() + }) + expect([...auth.allowed]).toContain('ping_session') + expect( + isWriteToolCallAuthorized( + 'ping_session', + { sessionId: SESSION_A, itemId: 118, message: 'go ahead - tear down ok' }, + auth + ).ok + ).toBe(true) + expect( + isWriteToolCallAuthorized( + 'ping_session', + { sessionId: SESSION_B, message: 'go ahead' }, + auth + ).ok + ).toBe(false) + }) + + it('updates focus from a successful subject-resolving tool; subject change replaces prior focus', () => { + const afterExplain = applyFocusFromToolResolve(null, { + tool: 'explain_priority', + ok: true, + args: { itemId: 118 }, + result: { + explanation: { + inboxItemId: 118, + relatedSessionId: SESSION_A, + title: 'W1.8 acceptance' + } + } + }) + expect(afterExplain).toEqual( + expect.objectContaining({ + itemId: 118, + sessionId: SESSION_A, + source: 'tool_resolve' + }) + ) + + const changed = applyFocusFromToolResolve(afterExplain, { + tool: 'explain_priority', + ok: true, + args: { itemId: 99 }, + result: { + explanation: { + inboxItemId: 99, + relatedSessionId: SESSION_B, + title: 'other' + } + } + }) + expect(changed?.itemId).toBe(99) + expect(changed?.sessionId).toBe(SESSION_B) + }) + + it('does not retarget focus from multi-item tool dumps (injection surface)', () => { + const still = applyFocusFromToolResolve(focus(), { + tool: 'query_inbox', + ok: true, + args: { limit: 25 }, + result: { + items: [ + { + id: 999, + relatedSessionId: SESSION_B, + title: 'cursor inline model-error detect' + }, + { id: 118, relatedSessionId: SESSION_A, title: 'W1.8' } + ] + } + }) + expect(still?.sessionId).toBe(SESSION_A) + expect(still?.itemId).toBe(118) + }) + + it('seeds focus from explicit client session id, not from operator prose grepping', () => { + const seeded = applyFocusFromClientSession(null, SESSION_A) + expect(seeded).toEqual( + expect.objectContaining({ sessionId: SESSION_A, itemId: null, source: 'client' }) + ) + const replaced = applyFocusFromClientSession(focus(), SESSION_B) + expect(replaced?.sessionId).toBe(SESSION_B) + expect(replaced?.itemId).toBeNull() + }) + + it('does not adopt null session probes or model-arg-only recent_output', () => { + expect( + applyFocusFromToolResolve(focus(), { + tool: 'get_session_state', + ok: true, + args: { sessionId: SESSION_B }, + result: { state: null } + }) + ).toEqual(focus()) + + expect( + applyFocusFromToolResolve(null, { + tool: 'get_session_recent_output', + ok: true, + args: { sessionId: SESSION_B }, + result: { chunks: [] } + }) + ).toBeNull() + + const resolved = applyFocusFromToolResolve(null, { + tool: 'get_session_state', + ok: true, + args: { sessionId: 'short' }, + result: { state: { sessionId: SESSION_A, name: 'W1.8' } } + }) + expect(resolved?.sessionId).toBe(SESSION_A) + }) + + it('formats a focus directive for the brain assemble path', () => { + const line = formatConverseFocusDirective(focus()) + expect(line).toContain('118') + expect(line).toContain(SESSION_A) + expect(line.toLowerCase()).toMatch(/focus|subject/) + }) + + it('promotes singleton list_active_workers roster to focus', () => { + const next = applyFocusFromToolResolve(null, { + tool: 'list_active_workers', + ok: true, + args: {}, + result: { + workers: [{ sessionId: SESSION_A, name: 'W1.8', observedState: 'working' }] + } + }) + expect(next).toEqual( + expect.objectContaining({ + sessionId: SESSION_A, + itemId: null, + source: 'tool_resolve' + }) + ) + expect( + applyFocusFromToolResolve(focus(), { + tool: 'list_active_workers', + ok: true, + args: {}, + result: { + workers: [ + { sessionId: SESSION_A, name: 'a' }, + { sessionId: SESSION_B, name: 'b' } + ] + } + }) + ).toEqual(focus()) + }) + + it('replaces the whole focus pair on subject-changing writes', () => { + const afterPing = applyFocusFromToolResolve(focus(), { + tool: 'ping_session', + ok: true, + args: { sessionId: SESSION_B, message: 'retry' }, + result: { ok: true, sessionId: SESSION_B } + }) + expect(afterPing).toEqual( + expect.objectContaining({ + sessionId: SESSION_B, + itemId: null, + source: 'tool_resolve' + }) + ) + const afterDisp = applyFocusFromToolResolve(focus(), { + tool: 'record_disposition', + ok: true, + args: { itemId: 99, action: 'done' }, + result: { ok: true, itemId: 99 } + }) + expect(afterDisp).toEqual( + expect.objectContaining({ + sessionId: null, + itemId: 99, + source: 'tool_resolve' + }) + ) + // Injected unsupported sessionId on disposition args must not retarget focus. + const poisoned = applyFocusFromToolResolve(focus(), { + tool: 'record_disposition', + ok: true, + args: { itemId: 118, action: 'done', sessionId: SESSION_B }, + result: { ok: true, itemId: 118 } + }) + expect(poisoned?.sessionId).toBe(SESSION_A) + expect(poisoned?.itemId).toBe(118) + }) + + it('does not promote an ungranted itemId from a successful ping', () => { + const after = applyFocusFromToolResolve(focus(), { + tool: 'ping_session', + ok: true, + args: { sessionId: SESSION_A, itemId: 999, message: 'hi' }, + result: { ok: true, sessionId: SESSION_A } + }) + expect(after?.sessionId).toBe(SESSION_A) + expect(after?.itemId).toBe(118) + }) + + it('retains itemId from an item-only successful ping', () => { + const after = applyFocusFromToolResolve(null, { + tool: 'ping_session', + ok: true, + args: { itemId: 118, message: 'hi' }, + result: { ok: true, sessionId: SESSION_A } + }) + expect(after).toEqual( + expect.objectContaining({ + sessionId: SESSION_A, + itemId: 118, + source: 'tool_resolve' + }) + ) + }) + + it('promotes singleton query_events to focus', () => { + expect( + applyFocusFromToolResolve(null, { + tool: 'query_events', + ok: true, + args: { limit: 1 }, + result: { + events: [{ id: 1, relatedSessionId: SESSION_A, eventType: 'failed' }] + } + })?.sessionId + ).toBe(SESSION_A) + }) + + it('promotes singleton query_open_loops to focus', () => { + expect( + applyFocusFromToolResolve(null, { + tool: 'query_open_loops', + ok: true, + args: {}, + result: { + openLoops: [{ sessionId: SESSION_A, name: 'abandoned', bucket: 'waiting_on_you' }] + } + })?.sessionId + ).toBe(SESSION_A) + expect( + applyFocusFromToolResolve(focus(), { + tool: 'query_open_loops', + ok: true, + args: {}, + result: { + openLoops: [ + { sessionId: SESSION_A, name: 'a' }, + { sessionId: SESSION_B, name: 'b' } + ] + } + }) + ).toEqual(focus()) + }) + + it('promotes singleton list-mode query_dispositions to focus', () => { + expect( + applyFocusFromToolResolve(null, { + tool: 'query_dispositions', + ok: true, + args: {}, + result: { + mode: 'list', + rows: [{ itemId: 42, action: 'dismiss' }], + total: 1 + } + })?.itemId + ).toBe(42) + expect( + applyFocusFromToolResolve(focus(), { + tool: 'query_dispositions', + ok: true, + args: { groupBy: ['action'] }, + result: { + mode: 'cluster', + clusters: [{ keys: { action: 'dismiss' }, count: 3 }], + total: 1 + } + }) + ).toEqual(focus()) + }) + + it('parses clear-tombstones and ignores them as write subjects', () => { + const tomb = parseConverseFocus({ + sessionId: null, + itemId: null, + source: 'client', + updatedAt: 50 + }) + expect(tomb).toEqual({ + sessionId: null, + itemId: null, + source: 'client', + updatedAt: 50 + }) + expect(hasConverseFocusSubject(tomb)).toBe(false) + expect(formatConverseFocusDirective(tomb)).toBeNull() + expect(parseConverseFocus({ sessionId: null, itemId: null, source: 'client' })).toBeNull() + }) +}) diff --git a/shared/src/overseerConverseFocus.ts b/shared/src/overseerConverseFocus.ts new file mode 100644 index 0000000000..0ac71dc19a --- /dev/null +++ b/shared/src/overseerConverseFocus.ts @@ -0,0 +1,421 @@ +/** + * Hub-owned conversational focus for Overseer converse. + * + * Structured dialogue-state (session and/or inbox item) that brain + write gate + * share. Focus updates from successful hub-executed tool resolutions that + * identify a subject — not from grepping pronouns or ids out of operator prose. + * Tool-result *prose* fed back to the brain is untrusted and must not retarget + * focus by itself. + */ + +export type OverseerConverseFocusSource = 'tool_resolve' | 'client' + +export type OverseerConverseFocus = { + sessionId: string | null + itemId: number | null + source: OverseerConverseFocusSource + updatedAt: number +} + +/** True when focus names a session and/or inbox item (not a clear-tombstone). */ +export function hasConverseFocusSubject( + focus: OverseerConverseFocus | null | undefined +): boolean { + if (!focus) return false + return Boolean(focus.sessionId?.trim()) || (focus.itemId != null && focus.itemId > 0) +} + +export type OverseerToolResolveEvent = { + tool: string + ok: boolean + args: Record + result: unknown +} + +function isObj(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function buildFocus(input: { + sessionId?: string | null + itemId?: number | null + source: OverseerConverseFocusSource + previous?: OverseerConverseFocus | null + now?: number +}): OverseerConverseFocus | null { + const prev = input.previous ?? null + + let nextSession: string | null + if (input.sessionId === null) { + nextSession = null + } else if (typeof input.sessionId === 'string' && input.sessionId.trim()) { + nextSession = input.sessionId.trim() + } else { + nextSession = prev?.sessionId ?? null + } + + let nextItem: number | null + if (input.itemId === null) { + nextItem = null + } else if (typeof input.itemId === 'number' && Number.isFinite(input.itemId) && input.itemId > 0) { + nextItem = input.itemId + } else { + nextItem = prev?.itemId ?? null + } + + if (!nextSession && nextItem == null) return null + + const unchanged = + prev != null && + prev.sessionId === nextSession && + prev.itemId === nextItem + + return { + sessionId: nextSession, + itemId: nextItem, + source: unchanged ? prev.source : input.source, + updatedAt: unchanged ? prev.updatedAt : (input.now ?? Date.now()) + } +} + +/** Seed focus from an explicit client-related session (transport thread), not NL grep. */ +export function applyFocusFromClientSession( + previous: OverseerConverseFocus | null, + sessionId: string | null | undefined, + now = Date.now() +): OverseerConverseFocus | null { + const id = typeof sessionId === 'string' ? sessionId.trim() : '' + if (!id) return previous + if (previous?.sessionId && previous.sessionId.toLowerCase() === id.toLowerCase()) { + return previous + } + // Client thread wins over durable focus — clear item (may belong to the old session). + return { + sessionId: id, + itemId: null, + source: 'client', + updatedAt: now + } +} + +function sessionFromResult(result: unknown): string | null { + if (!isObj(result)) return null + if (typeof result.sessionId === 'string' && result.sessionId.trim()) { + return result.sessionId.trim() + } + if (isObj(result.state)) { + if (typeof result.state.sessionId === 'string' && result.state.sessionId.trim()) { + return result.state.sessionId.trim() + } + if (typeof result.state.id === 'string' && result.state.id.trim()) { + return result.state.id.trim() + } + } + if (isObj(result.health)) { + if (typeof result.health.sessionId === 'string' && result.health.sessionId.trim()) { + return result.health.sessionId.trim() + } + if (typeof result.health.id === 'string' && result.health.id.trim()) { + return result.health.id.trim() + } + } + if ( + isObj(result.explanation) && + typeof result.explanation.relatedSessionId === 'string' && + result.explanation.relatedSessionId.trim() + ) { + return result.explanation.relatedSessionId.trim() + } + return null +} + +function itemFromResult(result: unknown): number | null { + if (!isObj(result)) return null + if (typeof result.itemId === 'number' && result.itemId > 0) return result.itemId + if ( + isObj(result.explanation) && + typeof result.explanation.inboxItemId === 'number' && + result.explanation.inboxItemId > 0 + ) { + return result.explanation.inboxItemId + } + return null +} + +/** + * Update focus after a hub-executed tool call with structured args/result. + * Multi-item list tools never retarget (avoids wander from inbox dumps). + * Does not parse tool-result prose — only structured fields from the hub call. + */ +export function applyFocusFromToolResolve( + previous: OverseerConverseFocus | null, + event: OverseerToolResolveEvent, + now = Date.now() +): OverseerConverseFocus | null { + if (!event.ok) return previous + + const tool = event.tool + const args = event.args + const result = event.result + + if (tool === 'explain_priority') { + if (!isObj(result) || result.explanation == null) return previous + const itemId = + itemFromResult(result) ?? + (typeof args.itemId === 'number' ? args.itemId : null) + const sessionId = sessionFromResult(result) + if (itemId == null && !sessionId) return previous + // New item resolve replaces the prior pair (subject change). + return { + sessionId: sessionId, + itemId: itemId, + source: 'tool_resolve', + updatedAt: now + } + } + + if (tool === 'get_session_state') { + if (!isObj(result) || result.state == null) return previous + const sessionId = sessionFromResult(result) + if (!sessionId) return previous + const keepItem = + previous?.sessionId && + previous.sessionId.toLowerCase() === sessionId.toLowerCase() + ? previous.itemId + : null + return { + sessionId, + itemId: keepItem, + source: 'tool_resolve', + updatedAt: now + } + } + + if (tool === 'get_worker_health') { + if (!isObj(result) || result.health == null) return previous + const sessionId = sessionFromResult(result) + if (!sessionId) return previous + const keepItem = + previous?.sessionId && + previous.sessionId.toLowerCase() === sessionId.toLowerCase() + ? previous.itemId + : null + return { + sessionId, + itemId: keepItem, + source: 'tool_resolve', + updatedAt: now + } + } + + // recent_output has no resolved session identity in the result — do not + // promote a model-supplied arg into durable focus. + if (tool === 'get_session_recent_output') { + return previous + } + + if (tool === 'ping_session') { + const sessionId = + sessionFromResult(result) ?? + (typeof args.sessionId === 'string' ? args.sessionId.trim() : null) + if (!sessionId && itemFromResult(result) == null) { + // No session identity — do not promote an ungranted model itemId alone. + return previous + } + const resultItem = itemFromResult(result) + const argsItem = typeof args.itemId === 'number' && args.itemId > 0 ? args.itemId : null + const argsSession = + typeof args.sessionId === 'string' && args.sessionId.trim() ? args.sessionId.trim() : null + // Never adopt a model-supplied itemId that was not already focused, unless + // this was an item-only ping (the item was the authorized target). + let itemId: number | null = resultItem + if (itemId == null) { + if (argsItem != null && previous?.itemId === argsItem) { + itemId = argsItem + } else if (argsItem != null && !argsSession) { + itemId = argsItem + } else if ( + previous?.itemId != null && + sessionId && + previous.sessionId && + previous.sessionId.toLowerCase() === sessionId.toLowerCase() + ) { + itemId = previous.itemId + } else { + itemId = null + } + } + if (!sessionId && itemId == null) return previous + return { + sessionId: sessionId || null, + itemId, + source: 'tool_resolve', + updatedAt: now + } + } + + if (tool === 'record_disposition') { + const itemId = typeof args.itemId === 'number' ? args.itemId : itemFromResult(result) + if (itemId == null) return previous + // Never read sessionId from raw model args — Zod strips it before the + // write runs; accepting it here would let an injected arg retarget focus. + const sessionId = + sessionFromResult(result) ?? + (previous?.itemId === itemId ? previous.sessionId : null) + return { + sessionId: sessionId || null, + itemId, + source: 'tool_resolve', + updatedAt: now + } + } + + // query_inbox: only retarget when exactly one subject. + if (tool === 'query_inbox' && isObj(result) && Array.isArray(result.items)) { + if (result.items.length !== 1) return previous + const only = result.items[0] + if (!isObj(only)) return previous + const itemId = typeof only.id === 'number' ? only.id : null + const sessionId = + typeof only.relatedSessionId === 'string' + ? only.relatedSessionId + : typeof only.session === 'string' + ? only.session + : null + if (itemId == null && !sessionId) return previous + return { + sessionId, + itemId, + source: 'tool_resolve', + updatedAt: now + } + } + + // Singleton worker roster — same singular-subject rule as query_inbox. + if (tool === 'list_active_workers' && isObj(result) && Array.isArray(result.workers)) { + if (result.workers.length !== 1) return previous + const only = result.workers[0] + if (!isObj(only)) return previous + const sessionId = + typeof only.sessionId === 'string' + ? only.sessionId.trim() + : typeof only.id === 'string' + ? only.id.trim() + : '' + if (!sessionId) return previous + return { + sessionId, + itemId: null, + source: 'tool_resolve', + updatedAt: now + } + } + + // Singleton open-loop — same rule (abandoned-thread questions). + if (tool === 'query_open_loops' && isObj(result) && Array.isArray(result.openLoops)) { + if (result.openLoops.length !== 1) return previous + const only = result.openLoops[0] + if (!isObj(only)) return previous + const sessionId = + typeof only.sessionId === 'string' + ? only.sessionId.trim() + : typeof only.id === 'string' + ? only.id.trim() + : '' + if (!sessionId) return previous + return { + sessionId, + itemId: null, + source: 'tool_resolve', + updatedAt: now + } + } + + // Singleton list-mode disposition — reopen/follow-up on the dismissed item. + if ( + tool === 'query_dispositions' && + isObj(result) && + result.mode === 'list' && + Array.isArray(result.rows) + ) { + if (result.rows.length !== 1) return previous + const only = result.rows[0] + if (!isObj(only)) return previous + const itemId = typeof only.itemId === 'number' && only.itemId > 0 ? only.itemId : null + if (itemId == null) return previous + return { + sessionId: null, + itemId, + source: 'tool_resolve', + updatedAt: now + } + } + + // Singleton event — "what just failed?" then "tell it to retry". + if (tool === 'query_events' && isObj(result) && Array.isArray(result.events)) { + if (result.events.length !== 1) return previous + const only = result.events[0] + if (!isObj(only)) return previous + const sessionId = + typeof only.relatedSessionId === 'string' + ? only.relatedSessionId.trim() + : typeof only.sessionId === 'string' + ? only.sessionId.trim() + : typeof only.session === 'string' + ? only.session.trim() + : '' + if (!sessionId) return previous + return { + sessionId, + itemId: null, + source: 'tool_resolve', + updatedAt: now + } + } + + return previous +} + +/** System-prompt / assemble hint so the brain shares the hub referent. */ +export function formatConverseFocusDirective(focus: OverseerConverseFocus | null): string | null { + if (!hasConverseFocusSubject(focus)) return null + const parts: string[] = ['# Conversational focus (hub-owned)', ''] + parts.push( + 'The operator is currently focused on the subject below. Prefer this referent for', + 'queries and writes unless they clearly direct you to a different session or inbox item', + '(via a tool resolve). Do not invent a different session id.', + 'When they direct action on this subject in natural language, use write tools against it.' + ) + parts.push('') + if (focus!.itemId != null) parts.push(`- inbox itemId: ${focus!.itemId}`) + if (focus!.sessionId) parts.push(`- sessionId: ${focus!.sessionId}`) + parts.push(`- established via: ${focus!.source}`) + return parts.join('\n') +} + +/** + * Parse persisted focus. Empty session+item with an updatedAt is a clear-tombstone + * (generation barrier for concurrent older turns) — not a live subject. + */ +export function parseConverseFocus(raw: unknown): OverseerConverseFocus | null { + if (!isObj(raw)) return null + const sessionId = + typeof raw.sessionId === 'string' && raw.sessionId.trim() ? raw.sessionId.trim() : null + const itemId = + typeof raw.itemId === 'number' && Number.isFinite(raw.itemId) && raw.itemId > 0 + ? raw.itemId + : null + const source: OverseerConverseFocusSource = + raw.source === 'tool_resolve' || raw.source === 'client' ? raw.source : 'tool_resolve' + const updatedAt = + typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) + ? raw.updatedAt + : Date.now() + if (!sessionId && itemId == null) { + // Tombstone must carry an updatedAt so setConverseFocusIfNewer can reject + // older in-flight turns that still hold the deleted session. + if (typeof raw.updatedAt !== 'number' || !Number.isFinite(raw.updatedAt)) return null + return { sessionId: null, itemId: null, source, updatedAt } + } + return { sessionId, itemId, source, updatedAt } +} diff --git a/shared/src/overseerWriteIntent.test.ts b/shared/src/overseerWriteIntent.test.ts index 240e849d1a..c5d4201934 100644 --- a/shared/src/overseerWriteIntent.test.ts +++ b/shared/src/overseerWriteIntent.test.ts @@ -1,34 +1,22 @@ import { describe, expect, it } from 'vitest' import { - detectOperatorWriteTools, isWriteToolCallAuthorized, resolveOverseerWriteAuthorization } from './overseerWriteIntent' +import type { OverseerConverseFocus } from './overseerConverseFocus' -describe('detectOperatorWriteTools', () => { - it('authorizes relay for ping/tell session phrasing', () => { - expect([...detectOperatorWriteTools('ping the expenses session: please continue')]).toEqual([ - 'ping_session' - ]) - expect([...detectOperatorWriteTools('tell that worker to retry the flaky test')]).toContain( - 'ping_session' - ) - }) - - it('authorizes disposition for snooze/done phrasing', () => { - expect([...detectOperatorWriteTools('snooze item 12 until tomorrow')]).toEqual([ - 'record_disposition' - ]) - expect([...detectOperatorWriteTools('mark #7 done')]).toContain('record_disposition') - }) +const SESSION_A = '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff' +const SESSION_B = '96f67085-1111-2222-3333-444455556666' - it('does not authorize writes for read-only questions', () => { - expect([...detectOperatorWriteTools('what needs my attention?')]).toEqual([]) - }) -}) +const focused: OverseerConverseFocus = { + sessionId: SESSION_A, + itemId: 118, + source: 'tool_resolve', + updatedAt: 1 +} -describe('resolveOverseerWriteAuthorization', () => { - it('explicit allowWrites unlocks both write tools', () => { +describe('resolveOverseerWriteAuthorization (focus-owned, not regex)', () => { + it('explicit allowWrites unlocks both write tools without focus', () => { const auth = resolveOverseerWriteAuthorization({ latestOperatorText: 'what is in the inbox?', allowWrites: true @@ -40,46 +28,80 @@ describe('resolveOverseerWriteAuthorization', () => { }, auth).ok).toBe(true) }) - it('binds ping_session to the session id named by the operator', () => { + it('focus alone unlocks writes bound to that subject — no RELAY_INTENT / ids in the line', () => { + const auth = resolveOverseerWriteAuthorization({ + latestOperatorText: 'tell it to go ahead - tear down and rebuild is fine', + focus: focused + }) + expect([...auth.allowed].sort()).toEqual(['ping_session', 'record_disposition']) + expect( + isWriteToolCallAuthorized( + 'ping_session', + { sessionId: SESSION_A, itemId: 118, message: 'go ahead' }, + auth + ).ok + ).toBe(true) + expect( + isWriteToolCallAuthorized( + 'ping_session', + { sessionId: SESSION_B, message: 'go ahead' }, + auth + ).ok + ).toBe(false) + }) + + it('denies writes when there is no focus and no allowWrites — even if the line looks like a ping', () => { const auth = resolveOverseerWriteAuthorization({ latestOperatorText: 'ping session abcdef12: "please continue"' }) - expect(isWriteToolCallAuthorized('ping_session', { - sessionId: 'abcdef12-ffff-ffff-ffff-ffffffffffff', - message: 'please continue' - }, auth).ok).toBe(true) - expect(isWriteToolCallAuthorized('ping_session', { - sessionId: 'deadbeef-ffff-ffff-ffff-ffffffffffff', - message: 'please continue' - }, auth).ok).toBe(false) + expect([...auth.allowed]).toEqual([]) + expect( + isWriteToolCallAuthorized( + 'ping_session', + { sessionId: 'abcdef12-ffff-ffff-ffff-ffffffffffff', message: 'please continue' }, + auth + ).ok + ).toBe(false) }) - it('binds short named session tokens after the word session', () => { + it('denies write tools on a read-only ask with no focus', () => { const auth = resolveOverseerWriteAuthorization({ - latestOperatorText: 'ping session sess-1: "hi"' + latestOperatorText: 'summarize the inbox' }) - expect(isWriteToolCallAuthorized('ping_session', { - sessionId: 'sess-1', - message: 'hi' - }, auth).ok).toBe(true) + expect(isWriteToolCallAuthorized('ping_session', { sessionId: 'x', message: 'y' }, auth).ok).toBe(false) + expect(isWriteToolCallAuthorized('query_inbox', {}, auth).ok).toBe(true) }) - it('denies ping without a concrete target in the operator message', () => { + it('disposition binds to focused itemId', () => { const auth = resolveOverseerWriteAuthorization({ - latestOperatorText: 'ping that worker to continue' + latestOperatorText: 'mark it done', + focus: focused }) - const result = isWriteToolCallAuthorized('ping_session', { - sessionId: 'abcdef12', - message: 'continue' - }, auth) - expect(result.ok).toBe(false) + expect(isWriteToolCallAuthorized('record_disposition', { itemId: 118, action: 'done' }, auth).ok).toBe(true) + expect(isWriteToolCallAuthorized('record_disposition', { itemId: 999, action: 'done' }, auth).ok).toBe(false) }) - it('denies write tools when neither flag nor intent matches', () => { + it('accepts a unique short prefix of the focused session id', () => { const auth = resolveOverseerWriteAuthorization({ - latestOperatorText: 'summarize the inbox' + focus: { ...focused, itemId: null } }) - expect(isWriteToolCallAuthorized('ping_session', { sessionId: 'x', message: 'y' }, auth).ok).toBe(false) - expect(isWriteToolCallAuthorized('query_inbox', {}, auth).ok).toBe(true) + expect( + isWriteToolCallAuthorized( + 'ping_session', + { sessionId: '6cd8d0c3', message: 'retry' }, + auth + ).ok + ).toBe(true) + }) + + it('rejects an off-focus itemId even when the session selector matches', () => { + const auth = resolveOverseerWriteAuthorization({ focus: focused }) + expect( + isWriteToolCallAuthorized( + 'ping_session', + { sessionId: SESSION_A, itemId: 999, message: 'hi' }, + auth + ).ok + ).toBe(false) }) }) diff --git a/shared/src/overseerWriteIntent.ts b/shared/src/overseerWriteIntent.ts index 5bba4ba99e..9f67ee06b0 100644 --- a/shared/src/overseerWriteIntent.ts +++ b/shared/src/overseerWriteIntent.ts @@ -1,17 +1,24 @@ /** * Server-side write authorization for Overseer converse. * - * Write tools must not run merely because the model asked — untrusted tool - * results (inbox titles, worker output) are fed back as `user` messages and can - * prompt-inject a relay/disposition. Authorization comes from the operator's - * latest utterance and/or an explicit client `allowWrites` flag — never from - * model-selected tools alone. + * Capability is hub-owned conversational focus (structured session and/or inbox + * item), not regex matching of the latest utterance. The old RELAY_INTENT / + * pronoun / line-local id extractors were debt — they made "tell it to go ahead" + * fail and faked understanding with pattern matching. * - * Grants are bound to extracted targets/payloads when present so a later - * injected tool call cannot retarget a legitimate "ping session X" grant. + * Authorization: + * - `allowWrites: true` (admin / voice confirm) → write tools unlocked + * - else a non-empty hub focus → write tools unlocked, bound to that focus + * - else writes denied + * + * Injection defense: tool-originated prose cannot set or retarget focus (see + * overseerConverseFocus). Write calls must bind to the hub focus when the + * explicit client flag is off. */ import { isOverseerWriteTool, type OverseerWriteToolName } from './overseerEntity' +import type { OverseerConverseFocus } from './overseerConverseFocus' +import { hasConverseFocusSubject } from './overseerConverseFocus' export type OverseerWriteAuthorization = { allowed: ReadonlySet @@ -19,45 +26,10 @@ export type OverseerWriteAuthorization = { explicitClientFlag: boolean sessionIdPrefixes: readonly string[] itemIds: readonly number[] - /** Quoted snippets from the operator line that a relay message should match. */ + /** Quoted snippets from the operator line that a relay message should match (allowWrites only). */ messageSnippets: readonly string[] } -const RELAY_INTENT = - /\b(ping|relay|nudge|wake)\b|\btell\b[\s\S]{0,80}\b(session|worker|peer|agent|him|her|them|it)\b|\b(message|ask|send)\b[\s\S]{0,80}\b(session|worker|peer|agent)\b/i - -const DISPOSITION_INTENT = - /\b(snooze|dismiss|reopen|dispose)\b|\bmark\b[\s\S]{0,40}\bdone\b|\b(resolve|done with)\b/i - -/** UUID or hex-prefix session ids (production hub shape). */ -const UUID_OR_HEX_SESSION_RE = - /\b([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}|[0-9a-f]{8,})\b/gi -/** Explicit `session ` form — covers short test ids like `sess-1` / `old-id`. */ -const NAMED_SESSION_RE = /\bsession\s+([a-z0-9][a-z0-9_-]{1,63})\b/gi -const ITEM_ID_RE = /\b(?:item\s*#?|#)(\d+)\b/gi - -function extractSessionIdPrefixes(text: string): string[] { - const out: string[] = [] - for (const match of text.matchAll(UUID_OR_HEX_SESSION_RE)) { - const value = match[1]?.toLowerCase() - if (value && !out.includes(value)) out.push(value) - } - for (const match of text.matchAll(NAMED_SESSION_RE)) { - const value = match[1]?.toLowerCase() - if (value && !out.includes(value)) out.push(value) - } - return out -} - -function extractItemIds(text: string): number[] { - const out: number[] = [] - for (const match of text.matchAll(ITEM_ID_RE)) { - const id = Number(match[1]) - if (Number.isFinite(id) && id > 0 && !out.includes(id)) out.push(id) - } - return out -} - function extractQuotedSnippets(text: string): string[] { const out: string[] = [] for (const match of text.matchAll(/"([^"]{1,500})"|'([^']{1,500})'/g)) { @@ -67,42 +39,74 @@ function extractQuotedSnippets(text: string): string[] { return out } -/** Detect which write classes the latest operator message authorizes. */ -export function detectOperatorWriteTools(operatorText: string): Set { - const allowed = new Set() - const text = operatorText.trim() - if (!text) return allowed - if (RELAY_INTENT.test(text)) allowed.add('ping_session') - if (DISPOSITION_INTENT.test(text)) allowed.add('record_disposition') - return allowed +function focusTargets(focus: OverseerConverseFocus | null | undefined): { + sessionIdPrefixes: string[] + itemIds: number[] +} { + const sessionIdPrefixes: string[] = [] + const itemIds: number[] = [] + if (!hasConverseFocusSubject(focus) || !focus) return { sessionIdPrefixes, itemIds } + if (focus.sessionId?.trim()) sessionIdPrefixes.push(focus.sessionId.trim().toLowerCase()) + if (focus.itemId != null && focus.itemId > 0) itemIds.push(focus.itemId) + return { sessionIdPrefixes, itemIds } } +function hasFocusSubject(focus: OverseerConverseFocus | null | undefined): boolean { + return hasConverseFocusSubject(focus) +} + +/** + * Resolve write authorization from hub focus and/or explicit client flag. + * Does not pattern-match operator NL for intent or targets. + */ export function resolveOverseerWriteAuthorization(opts: { - latestOperatorText: string + latestOperatorText?: string allowWrites?: boolean + /** Hub-owned subject from successful tool resolves (required for converse writes). */ + focus?: OverseerConverseFocus | null }): OverseerWriteAuthorization { - const text = opts.latestOperatorText + const text = opts.latestOperatorText ?? '' + const targets = focusTargets(opts.focus) + if (opts.allowWrites === true) { return { allowed: new Set(['ping_session', 'record_disposition']), explicitClientFlag: true, - sessionIdPrefixes: extractSessionIdPrefixes(text), - itemIds: extractItemIds(text), + sessionIdPrefixes: targets.sessionIdPrefixes, + itemIds: targets.itemIds, messageSnippets: extractQuotedSnippets(text) } } + + if (hasFocusSubject(opts.focus)) { + return { + allowed: new Set(['ping_session', 'record_disposition']), + explicitClientFlag: false, + sessionIdPrefixes: targets.sessionIdPrefixes, + itemIds: targets.itemIds, + messageSnippets: [] + } + } + return { - allowed: detectOperatorWriteTools(text), + allowed: new Set(), explicitClientFlag: false, - sessionIdPrefixes: extractSessionIdPrefixes(text), - itemIds: extractItemIds(text), - messageSnippets: extractQuotedSnippets(text) + sessionIdPrefixes: [], + itemIds: [], + messageSnippets: [] } } function sessionIdMatchesGrant(sessionId: string, prefixes: readonly string[]): boolean { const lower = sessionId.trim().toLowerCase() - return prefixes.some((prefix) => lower === prefix || lower.startsWith(prefix)) + if (!lower) return false + return prefixes.some((prefix) => { + if (!prefix) return false + // Exact, call extends grant prefix, or call is a unique short prefix of the + // focused canonical id (tool contract accepts abbreviated session ids). + if (lower === prefix || lower.startsWith(prefix)) return true + return lower.length >= 8 && prefix.startsWith(lower) + }) } function messageMatchesGrant(message: string, snippets: readonly string[]): boolean { @@ -111,8 +115,8 @@ function messageMatchesGrant(message: string, snippets: readonly string[]): bool } /** - * Per-call authorization: tool class must be allowed, and when the operator - * named a target, the call args must bind to it (unless explicitClientFlag). + * Per-call authorization: tool class must be allowed, and (unless explicitClientFlag) + * the call args must bind to hub focus. */ export function isWriteToolCallAuthorized( tool: string, @@ -121,7 +125,10 @@ export function isWriteToolCallAuthorized( ): { ok: true } | { ok: false; error: string } { if (!isOverseerWriteTool(tool)) return { ok: true } if (!auth.allowed.has(tool)) { - return { ok: false, error: 'write not authorized by operator message (no explicit write intent)' } + return { + ok: false, + error: 'write not authorized (no conversational focus and no allowWrites)' + } } if (tool === 'ping_session') { @@ -133,6 +140,8 @@ export function isWriteToolCallAuthorized( if (!messageMatchesGrant(message, auth.messageSnippets)) { return { ok: false, error: 'relay message does not match operator-quoted payload' } } + // Optional soft bind: when focus exists under allowWrites, still prefer it, + // but allowWrites alone may target any session (admin confirm path). return { ok: true } } @@ -140,16 +149,21 @@ export function isWriteToolCallAuthorized( if (!hasTargetGrant) { return { ok: false, - error: 'relay requires an explicit session id / item id in the operator message (or allowWrites)' + error: 'relay requires conversational focus (session and/or inbox item) or allowWrites' } } const sessionOk = sessionId.length > 0 && sessionIdMatchesGrant(sessionId, auth.sessionIdPrefixes) const itemOk = itemId != null && auth.itemIds.includes(itemId) - if (!sessionOk && !itemOk) { - return { ok: false, error: 'relay target does not match operator-authorized session/item' } + // When focus binds both slots, every supplied selector must match — + // session-only match must not launder an off-focus itemId into the ping. + if (sessionId.length > 0 && auth.sessionIdPrefixes.length > 0 && !sessionOk) { + return { ok: false, error: 'relay target does not match conversational focus' } } - if (!messageMatchesGrant(message, auth.messageSnippets)) { - return { ok: false, error: 'relay message does not match operator-quoted payload' } + if (itemId != null && auth.itemIds.length > 0 && !itemOk) { + return { ok: false, error: 'relay target does not match conversational focus' } + } + if (!sessionOk && !itemOk) { + return { ok: false, error: 'relay target does not match conversational focus' } } return { ok: true } } @@ -160,11 +174,11 @@ export function isWriteToolCallAuthorized( if (auth.itemIds.length === 0) { return { ok: false, - error: 'disposition requires an explicit item id in the operator message (or allowWrites)' + error: 'disposition requires focused inbox item (or allowWrites)' } } if (itemId == null || !auth.itemIds.includes(itemId)) { - return { ok: false, error: 'disposition itemId does not match operator-authorized item' } + return { ok: false, error: 'disposition itemId does not match conversational focus' } } return { ok: true } }