From a1ed76056136ef8902b8411d24c45ad0786ec036 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:49:04 +0000 Subject: [PATCH 01/14] feat(overseer): hub-owned conversational focus for anaphoric relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist a structured session/item subject across converse turns so write authorization and the brain share one referent. "tell it to…" binds via focus + write intent, not line-local ids. Tool-result dumps cannot retarget. Fixes #112 Co-authored-by: Cursor --- hub/src/overseer/converse.test.ts | 115 +++++++++ hub/src/overseer/converse.ts | 59 ++++- hub/src/store/settingsStore.test.ts | 30 +++ hub/src/store/settingsStore.ts | 34 +++ hub/src/web/routes/overseer.ts | 27 +- shared/src/index.ts | 1 + shared/src/overseerConverse.ts | 7 + shared/src/overseerConverseFocus.test.ts | 131 ++++++++++ shared/src/overseerConverseFocus.ts | 299 +++++++++++++++++++++++ shared/src/overseerWriteIntent.ts | 43 +++- 10 files changed, 720 insertions(+), 26 deletions(-) create mode 100644 shared/src/overseerConverseFocus.test.ts create mode 100644 shared/src/overseerConverseFocus.ts diff --git a/hub/src/overseer/converse.test.ts b/hub/src/overseer/converse.test.ts index e00b6d5a3d..6314d79184 100644 --- a/hub/src/overseer/converse.test.ts +++ b/hub/src/overseer/converse.test.ts @@ -285,4 +285,119 @@ describe('runOverseerConverse', () => { expect(toolTrace[0]).toMatchObject({ tool: 'ping_session', ok: false }) expect(toolTrace[0]?.error).toMatch(/not authorized/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') + }) }) diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index 668044860c..ae4f36682a 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -8,13 +8,17 @@ */ import { + applyFocusFromOperatorText, + applyFocusFromToolResolve, buildOverseerOpenAiTools, buildOverseerSystemPrompt, fingerprintWriteToolCall, + formatConverseFocusDirective, isOverseerWriteTool, isWriteToolAuthorized, isWriteToolCallAuthorized, resolveOverseerWriteAuthorization, + type OverseerConverseFocus, type OverseerConverseMessage, type OverseerToolName, type OverseerToolTraceEntry, @@ -114,22 +118,41 @@ 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 - }) + let focus = applyFocusFromOperatorText(params.focus ?? null, latestOperatorText) + + const writeAuthFor = (): OverseerWriteAuthorization => + resolveOverseerWriteAuthorization({ + latestOperatorText, + allowWrites, + focus + }) const tools = (buildOverseerOpenAiTools() as OverseerOpenAiToolLike[]).filter((tool) => { const name = tool.function?.name ?? '' - return isWriteToolAuthorized(name, writeAuth) + return isWriteToolAuthorized(name, writeAuthFor()) }) 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 @@ -148,6 +171,8 @@ 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 }) + for (let iter = 0; iter < maxIterations; iter++) { let message: OpenAiChatMessage try { @@ -156,7 +181,7 @@ 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)) } throw error } @@ -172,7 +197,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 +226,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 +252,14 @@ export async function runOverseerConverse(params: { ok, ...(ok ? {} : { error: toolResultError(result) }) }) + if (ok) { + focus = applyFocusFromToolResolve(focus, { + tool: name, + ok: true, + args, + result + }) + } if (ok && isOverseerWriteTool(name)) { consumedWriteFingerprints.add(fingerprintWriteToolCall(name, args)) const tombstone = writeResultTombstone(result) @@ -255,10 +288,12 @@ 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)) } throw error } diff --git a/hub/src/store/settingsStore.test.ts b/hub/src/store/settingsStore.test.ts index 42b45f1c70..7ff933485d 100644 --- a/hub/src/store/settingsStore.test.ts +++ b/hub/src/store/settingsStore.test.ts @@ -43,6 +43,36 @@ 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: 'operator', + updatedAt: 99 + }, + 'ns-a' + ) + expect(s.getConverseFocus('ns-a')?.sessionId).toBe('other') + expect(s.getConverseFocus()?.itemId).toBe(118) + s.clearConverseFocus() + expect(s.getConverseFocus()).toBeNull() + }) + 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..5e8716209d 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,31 @@ 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 + }) + ) + } + + clearConverseFocus(namespace = 'default'): void { + this.delete(converseFocusKey(namespace)) + } } diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index e75a8311e0..52a2435e2a 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -231,12 +231,15 @@ 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 priorFocus = settings.getConverseFocus(namespace) const active = getSanitizedActiveBrain(engine, c.get('namespace')) const config = resolveBrainConfig(process.env, resolveBrainSelection(active, { @@ -256,7 +259,7 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho 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 +267,28 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho model: null, brainOnline: false, hydratedTurns: assembled.hydratedTurns, - truncated: assembled.truncated + truncated: assembled.truncated, + focus: priorFocus }) } 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.setConverseFocus(focus, namespace) + else settings.clearConverseFocus(namespace) + 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 +300,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) { @@ -313,7 +323,7 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho persistOverseerConvoExchange(overseer, assembled, { operatorText: lastOperator, overseerText: reply, - relatedSessionId: parsed.data.relatedSessionId ?? null + relatedSessionId: parsed.data.relatedSessionId ?? priorFocus?.sessionId ?? null }) return c.json({ reply, @@ -321,7 +331,8 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho model: config.model, brainOnline: error.reachable, hydratedTurns: assembled.hydratedTurns, - truncated: assembled.truncated + truncated: assembled.truncated, + focus: priorFocus }) } 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..ed7d691c77 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: 'operator' | '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..805a372818 --- /dev/null +++ b/shared/src/overseerConverseFocus.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { + applyFocusFromOperatorText, + applyFocusFromToolResolve, + formatConverseFocusDirective, + 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 pronoun grep)', () => { + it('authorizes anaphoric relay against established focus without ids in the follow-up line', () => { + 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 when the operator clearly names a new subject', () => { + const next = applyFocusFromOperatorText(focus({ itemId: 10, sessionId: SESSION_B }), 'look at item #118') + expect(next?.itemId).toBe(118) + expect(next?.sessionId).toBeNull() + expect(next?.source).toBe('operator') + }) + + it('replaces focus when the operator names a different session', () => { + const next = applyFocusFromOperatorText( + focus(), + `switch to session ${SESSION_B}` + ) + expect(next?.sessionId).toBe(SESSION_B) + expect(next?.itemId).toBeNull() + expect(next?.source).toBe('operator') + }) + + it('updates focus from a successful subject-resolving tool, not from multi-item dumps', () => { + 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 afterDump = applyFocusFromToolResolve(focus(), { + tool: 'query_inbox', + ok: true, + args: { limit: 25 }, + result: { + items: [ + { id: 1, relatedSessionId: SESSION_B, title: 'noise' }, + { id: 118, relatedSessionId: SESSION_A, title: 'W1.8' } + ] + } + }) + // Multi-item list must not silently retarget away from established focus. + expect(afterDump).toEqual(focus()) + }) + + it('does not retarget focus from tool-result content alone (injection surface)', () => { + // Focus updates only from structured hub tool resolves / operator naming — + // never from re-parsing the projected tool-result prose fed to the brain. + // A multi-row dump that *mentions* another session must leave focus alone. + 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('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/) + }) +}) diff --git a/shared/src/overseerConverseFocus.ts b/shared/src/overseerConverseFocus.ts new file mode 100644 index 0000000000..a5df367872 --- /dev/null +++ b/shared/src/overseerConverseFocus.ts @@ -0,0 +1,299 @@ +/** + * Hub-owned conversational focus for Overseer converse. + * + * Structured dialogue-state (session and/or inbox item) that brain + write gate + * share. Not pronoun/regex grepping — focus updates from clear operator naming + * and successful hub-executed tool resolutions. Tool-result *prose* fed back to + * the brain is untrusted and must not retarget focus by itself. + */ + +export type OverseerConverseFocusSource = 'operator' | 'tool_resolve' | 'client' + +export type OverseerConverseFocus = { + sessionId: string | null + itemId: number | null + source: OverseerConverseFocusSource + updatedAt: number +} + +export type OverseerToolResolveEvent = { + tool: string + ok: boolean + args: Record + result: unknown +} + +const ITEM_ID_RE = /\b(?:item\s*#?|#)(\d+)\b/gi +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 +const NAMED_SESSION_RE = /\bsession\s+([a-z0-9][a-z0-9_-]{1,63})\b/gi + +function isObj(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +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 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 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()) + } +} + +/** + * Establish / replace focus from clear operator naming in the latest utterance. + * Does not invent subjects from pronouns — ids / `item #N` / `session ` only. + * Returns previous focus unchanged when the line names nothing. + * + * Naming only an item clears a prior session (and vice versa) so we do not keep + * a stale pair across subjects; successful tool resolves fill the other slot. + */ +export function applyFocusFromOperatorText( + previous: OverseerConverseFocus | null, + operatorText: string, + now = Date.now() +): OverseerConverseFocus | null { + const text = operatorText.trim() + if (!text) return previous + + const itemIds = extractItemIds(text) + const sessions = extractSessionIdPrefixes(text) + if (itemIds.length === 0 && sessions.length === 0) return previous + + const itemId = itemIds.length > 0 ? itemIds[itemIds.length - 1]! : null + const sessionId = sessions.length > 0 ? sessions[sessions.length - 1]! : null + + return { + sessionId, + itemId, + source: 'operator', + 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) && typeof result.state.id === 'string' && result.state.id.trim()) { + return result.state.id.trim() + } + if (isObj(result.health) && 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') { + const itemId = + itemFromResult(result) ?? + (typeof args.itemId === 'number' ? args.itemId : null) + const sessionId = sessionFromResult(result) + if (itemId == null && !sessionId) return previous + return buildFocus({ + previous, + itemId: itemId ?? undefined, + sessionId: sessionId ?? undefined, + source: 'tool_resolve', + now + }) + } + + if ( + tool === 'get_session_state' || + tool === 'get_session_recent_output' || + tool === 'get_worker_health' + ) { + const fromArgs = typeof args.sessionId === 'string' ? args.sessionId.trim() : '' + const sessionId = sessionFromResult(result) ?? (fromArgs || null) + if (!sessionId) return previous + return buildFocus({ + previous, + sessionId, + // Session-only probe: keep prior item if any (same worker thread). + source: 'tool_resolve', + now + }) + } + + if (tool === 'ping_session') { + const sessionId = + sessionFromResult(result) ?? + (typeof args.sessionId === 'string' ? args.sessionId.trim() : null) + const itemId = + itemFromResult(result) ?? + (typeof args.itemId === 'number' ? args.itemId : null) + if (!sessionId && itemId == null) return previous + return buildFocus({ + previous, + sessionId: sessionId || undefined, + itemId: itemId ?? undefined, + source: 'tool_resolve', + now + }) + } + + if (tool === 'record_disposition') { + const itemId = typeof args.itemId === 'number' ? args.itemId : itemFromResult(result) + if (itemId == null) return previous + return buildFocus({ + previous, + itemId, + source: 'tool_resolve', + now + }) + } + + // query_inbox / query_events / list dumps: 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 buildFocus({ + previous, + itemId: itemId ?? undefined, + sessionId: sessionId ?? undefined, + source: 'tool_resolve', + now + }) + } + + return previous +} + +/** System-prompt / assemble hint so the brain shares the hub referent. */ +export function formatConverseFocusDirective(focus: OverseerConverseFocus | null): string | null { + if (!focus || (!focus.sessionId && focus.itemId == null)) 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 name a different session or inbox item.', + 'Do not invent a different session id.' + ) + 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') +} + +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 + if (!sessionId && itemId == null) return null + const source: OverseerConverseFocusSource = + raw.source === 'operator' || 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() + return { sessionId, itemId, source, updatedAt } +} diff --git a/shared/src/overseerWriteIntent.ts b/shared/src/overseerWriteIntent.ts index 5bba4ba99e..8d1f3c04ba 100644 --- a/shared/src/overseerWriteIntent.ts +++ b/shared/src/overseerWriteIntent.ts @@ -9,9 +9,13 @@ * * Grants are bound to extracted targets/payloads when present so a later * injected tool call cannot retarget a legitimate "ping session X" grant. + * + * Hub-owned conversational focus supplies targets for anaphoric follow-ups + * ("tell it to…") when the latest line has write intent but no fresh ids. */ import { isOverseerWriteTool, type OverseerWriteToolName } from './overseerEntity' +import type { OverseerConverseFocus } from './overseerConverseFocus' export type OverseerWriteAuthorization = { allowed: ReadonlySet @@ -77,25 +81,50 @@ export function detectOperatorWriteTools(operatorText: string): Set(['ping_session', 'record_disposition']), explicitClientFlag: true, - sessionIdPrefixes: extractSessionIdPrefixes(text), - itemIds: extractItemIds(text), + sessionIdPrefixes: merged.sessionIdPrefixes, + itemIds: merged.itemIds, messageSnippets: extractQuotedSnippets(text) } } return { allowed: detectOperatorWriteTools(text), explicitClientFlag: false, - sessionIdPrefixes: extractSessionIdPrefixes(text), - itemIds: extractItemIds(text), + sessionIdPrefixes: merged.sessionIdPrefixes, + itemIds: merged.itemIds, messageSnippets: extractQuotedSnippets(text) } } @@ -140,7 +169,8 @@ 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 a focused session/item (or an explicit id in the operator message / allowWrites)' } } const sessionOk = sessionId.length > 0 && sessionIdMatchesGrant(sessionId, auth.sessionIdPrefixes) @@ -160,7 +190,8 @@ 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 a focused item (or an explicit item id in the operator message / allowWrites)' } } if (itemId == null || !auth.itemIds.includes(itemId)) { From 4e2acc015fe0ee66fa8ec6e0935b42b43f33a60d Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:53:24 +0000 Subject: [PATCH 02/14] fix(overseer): replace RELAY_INTENT write gate with hub focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Write auth is focus and/or allowWrites only — no utterance regex for intent or targets. Focus updates from tool resolves (and client session), not operator-text id grepping. Mid-turn resolve unlocks ping. Co-authored-by: Cursor --- hub/src/overseer/converse.test.ts | 87 ++++++++++++- hub/src/overseer/converse.ts | 13 +- hub/src/store/settingsStore.test.ts | 2 +- hub/src/web/routes/overseer.ts | 6 +- shared/src/overseerConverse.ts | 2 +- shared/src/overseerConverseFocus.test.ts | 56 ++++---- shared/src/overseerConverseFocus.ts | 122 ++++++----------- shared/src/overseerWriteIntent.test.ts | 98 +++++++------- shared/src/overseerWriteIntent.ts | 158 +++++++++-------------- 9 files changed, 270 insertions(+), 274 deletions(-) diff --git a/hub/src/overseer/converse.test.ts b/hub/src/overseer/converse.test.ts index 6314d79184..7e97562d4c 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,7 +295,7 @@ 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 () => { @@ -400,4 +412,69 @@ describe('runOverseerConverse', () => { expect(focus?.sessionId).toBe(sessionId) expect(focus?.source).toBe('tool_resolve') }) + + it('unlocks ping mid-turn after a subject-resolving read establishes focus', 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: 'Relayed.' + })) + 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(true) + expect(pingSession).toHaveBeenCalledOnce() + expect(focus?.itemId).toBe(118) + }) }) diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index ae4f36682a..1219bad2d8 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -8,14 +8,12 @@ */ import { - applyFocusFromOperatorText, applyFocusFromToolResolve, buildOverseerOpenAiTools, buildOverseerSystemPrompt, fingerprintWriteToolCall, formatConverseFocusDirective, isOverseerWriteTool, - isWriteToolAuthorized, isWriteToolCallAuthorized, resolveOverseerWriteAuthorization, type OverseerConverseFocus, @@ -128,7 +126,7 @@ export async function runOverseerConverse(params: { const { overseer, config, messages, maxIterations = 6, signal, allowWrites } = params const latestOperatorText = [...messages].reverse().find((m) => m.role === 'operator')?.content ?? '' - let focus = applyFocusFromOperatorText(params.focus ?? null, latestOperatorText) + let focus = params.focus ?? null const writeAuthFor = (): OverseerWriteAuthorization => resolveOverseerWriteAuthorization({ @@ -137,10 +135,11 @@ export async function runOverseerConverse(params: { focus }) - const tools = (buildOverseerOpenAiTools() as OverseerOpenAiToolLike[]).filter((tool) => { - const name = tool.function?.name ?? '' - return isWriteToolAuthorized(name, writeAuthFor()) - }) + // Always expose the full tool catalog. Write authorization is enforced at + // call time against hub focus (which may be established mid-turn by a + // successful subject-resolving read). Filtering writes out of `tools` when + // focus starts empty would prevent the brain from acting after resolve. + 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 = [ diff --git a/hub/src/store/settingsStore.test.ts b/hub/src/store/settingsStore.test.ts index 7ff933485d..2aca70213a 100644 --- a/hub/src/store/settingsStore.test.ts +++ b/hub/src/store/settingsStore.test.ts @@ -62,7 +62,7 @@ describe('SettingsStore', () => { { sessionId: 'other', itemId: null, - source: 'operator', + source: 'client', updatedAt: 99 }, 'ns-a' diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index 52a2435e2a..fd5417709b 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -15,6 +15,7 @@ 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' const convoTurnBodySchema = z.object({ operatorText: z.string().max(8000).default(''), @@ -239,7 +240,10 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho }) const messages = assembled.messages const lastOperator = [...messages].reverse().find((m) => m.role === 'operator')?.content ?? '' - const priorFocus = settings.getConverseFocus(namespace) + const priorFocus = applyFocusFromClientSession( + settings.getConverseFocus(namespace), + parsed.data.relatedSessionId + ) const active = getSanitizedActiveBrain(engine, c.get('namespace')) const config = resolveBrainConfig(process.env, resolveBrainSelection(active, { diff --git a/shared/src/overseerConverse.ts b/shared/src/overseerConverse.ts index ed7d691c77..098bcd15d8 100644 --- a/shared/src/overseerConverse.ts +++ b/shared/src/overseerConverse.ts @@ -83,7 +83,7 @@ export type OverseerConverseResponse = { focus?: { sessionId: string | null itemId: number | null - source: 'operator' | 'tool_resolve' | 'client' + source: 'tool_resolve' | 'client' updatedAt: number } | null } diff --git a/shared/src/overseerConverseFocus.test.ts b/shared/src/overseerConverseFocus.test.ts index 805a372818..cb0ee3eda9 100644 --- a/shared/src/overseerConverseFocus.test.ts +++ b/shared/src/overseerConverseFocus.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { - applyFocusFromOperatorText, + applyFocusFromClientSession, applyFocusFromToolResolve, formatConverseFocusDirective, type OverseerConverseFocus @@ -23,8 +23,8 @@ function focus(partial: Partial = {}): OverseerConverseFo } } -describe('hub-owned conversational focus (capability, not pronoun grep)', () => { - it('authorizes anaphoric relay against established focus without ids in the follow-up line', () => { +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() @@ -46,24 +46,7 @@ describe('hub-owned conversational focus (capability, not pronoun grep)', () => ).toBe(false) }) - it('updates focus when the operator clearly names a new subject', () => { - const next = applyFocusFromOperatorText(focus({ itemId: 10, sessionId: SESSION_B }), 'look at item #118') - expect(next?.itemId).toBe(118) - expect(next?.sessionId).toBeNull() - expect(next?.source).toBe('operator') - }) - - it('replaces focus when the operator names a different session', () => { - const next = applyFocusFromOperatorText( - focus(), - `switch to session ${SESSION_B}` - ) - expect(next?.sessionId).toBe(SESSION_B) - expect(next?.itemId).toBeNull() - expect(next?.source).toBe('operator') - }) - - it('updates focus from a successful subject-resolving tool, not from multi-item dumps', () => { + it('updates focus from a successful subject-resolving tool; subject change replaces prior focus', () => { const afterExplain = applyFocusFromToolResolve(null, { tool: 'explain_priority', ok: true, @@ -84,25 +67,23 @@ describe('hub-owned conversational focus (capability, not pronoun grep)', () => }) ) - const afterDump = applyFocusFromToolResolve(focus(), { - tool: 'query_inbox', + const changed = applyFocusFromToolResolve(afterExplain, { + tool: 'explain_priority', ok: true, - args: { limit: 25 }, + args: { itemId: 99 }, result: { - items: [ - { id: 1, relatedSessionId: SESSION_B, title: 'noise' }, - { id: 118, relatedSessionId: SESSION_A, title: 'W1.8' } - ] + explanation: { + inboxItemId: 99, + relatedSessionId: SESSION_B, + title: 'other' + } } }) - // Multi-item list must not silently retarget away from established focus. - expect(afterDump).toEqual(focus()) + expect(changed?.itemId).toBe(99) + expect(changed?.sessionId).toBe(SESSION_B) }) - it('does not retarget focus from tool-result content alone (injection surface)', () => { - // Focus updates only from structured hub tool resolves / operator naming — - // never from re-parsing the projected tool-result prose fed to the brain. - // A multi-row dump that *mentions* another session must leave focus alone. + it('does not retarget focus from multi-item tool dumps (injection surface)', () => { const still = applyFocusFromToolResolve(focus(), { tool: 'query_inbox', ok: true, @@ -122,6 +103,13 @@ describe('hub-owned conversational focus (capability, not pronoun grep)', () => 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' }) + ) + }) + it('formats a focus directive for the brain assemble path', () => { const line = formatConverseFocusDirective(focus()) expect(line).toContain('118') diff --git a/shared/src/overseerConverseFocus.ts b/shared/src/overseerConverseFocus.ts index a5df367872..b036c60403 100644 --- a/shared/src/overseerConverseFocus.ts +++ b/shared/src/overseerConverseFocus.ts @@ -2,12 +2,13 @@ * Hub-owned conversational focus for Overseer converse. * * Structured dialogue-state (session and/or inbox item) that brain + write gate - * share. Not pronoun/regex grepping — focus updates from clear operator naming - * and successful hub-executed tool resolutions. Tool-result *prose* fed back to - * the brain is untrusted and must not retarget focus by itself. + * 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 = 'operator' | 'tool_resolve' | 'client' +export type OverseerConverseFocusSource = 'tool_resolve' | 'client' export type OverseerConverseFocus = { sessionId: string | null @@ -23,37 +24,10 @@ export type OverseerToolResolveEvent = { result: unknown } -const ITEM_ID_RE = /\b(?:item\s*#?|#)(\d+)\b/gi -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 -const NAMED_SESSION_RE = /\bsession\s+([a-z0-9][a-z0-9_-]{1,63})\b/gi - function isObj(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -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 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 buildFocus(input: { sessionId?: string | null itemId?: number | null @@ -96,35 +70,20 @@ function buildFocus(input: { } } -/** - * Establish / replace focus from clear operator naming in the latest utterance. - * Does not invent subjects from pronouns — ids / `item #N` / `session ` only. - * Returns previous focus unchanged when the line names nothing. - * - * Naming only an item clears a prior session (and vice versa) so we do not keep - * a stale pair across subjects; successful tool resolves fill the other slot. - */ -export function applyFocusFromOperatorText( +/** Seed focus from an explicit client-related session (transport thread), not NL grep. */ +export function applyFocusFromClientSession( previous: OverseerConverseFocus | null, - operatorText: string, + sessionId: string | null | undefined, now = Date.now() ): OverseerConverseFocus | null { - const text = operatorText.trim() - if (!text) return previous - - const itemIds = extractItemIds(text) - const sessions = extractSessionIdPrefixes(text) - if (itemIds.length === 0 && sessions.length === 0) return previous - - const itemId = itemIds.length > 0 ? itemIds[itemIds.length - 1]! : null - const sessionId = sessions.length > 0 ? sessions[sessions.length - 1]! : null - - return { - sessionId, - itemId, - source: 'operator', - updatedAt: now - } + const id = typeof sessionId === 'string' ? sessionId.trim() : '' + if (!id) return previous + return buildFocus({ + previous, + sessionId: id, + source: 'client', + now + }) } function sessionFromResult(result: unknown): string | null { @@ -183,13 +142,13 @@ export function applyFocusFromToolResolve( (typeof args.itemId === 'number' ? args.itemId : null) const sessionId = sessionFromResult(result) if (itemId == null && !sessionId) return previous - return buildFocus({ - previous, - itemId: itemId ?? undefined, - sessionId: sessionId ?? undefined, + // New item resolve replaces the prior pair (subject change). + return { + sessionId: sessionId, + itemId: itemId, source: 'tool_resolve', - now - }) + updatedAt: now + } } if ( @@ -200,13 +159,18 @@ export function applyFocusFromToolResolve( const fromArgs = typeof args.sessionId === 'string' ? args.sessionId.trim() : '' const sessionId = sessionFromResult(result) ?? (fromArgs || null) if (!sessionId) return previous - return buildFocus({ - previous, + // Session probe replaces session; clear item unless same session keeps prior item. + const keepItem = + previous?.sessionId && + previous.sessionId.toLowerCase() === sessionId.toLowerCase() + ? previous.itemId + : null + return { sessionId, - // Session-only probe: keep prior item if any (same worker thread). + itemId: keepItem, source: 'tool_resolve', - now - }) + updatedAt: now + } } if (tool === 'ping_session') { @@ -237,7 +201,7 @@ export function applyFocusFromToolResolve( }) } - // query_inbox / query_events / list dumps: only retarget when exactly one subject. + // 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] @@ -250,13 +214,12 @@ export function applyFocusFromToolResolve( ? only.session : null if (itemId == null && !sessionId) return previous - return buildFocus({ - previous, - itemId: itemId ?? undefined, - sessionId: sessionId ?? undefined, + return { + sessionId, + itemId, source: 'tool_resolve', - now - }) + updatedAt: now + } } return previous @@ -268,8 +231,9 @@ export function formatConverseFocusDirective(focus: OverseerConverseFocus | 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 name a different session or inbox item.', - 'Do not invent a different session id.' + '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}`) @@ -288,9 +252,7 @@ export function parseConverseFocus(raw: unknown): OverseerConverseFocus | null { : null if (!sessionId && itemId == null) return null const source: OverseerConverseFocusSource = - raw.source === 'operator' || raw.source === 'tool_resolve' || raw.source === 'client' - ? raw.source - : 'tool_resolve' + raw.source === 'tool_resolve' || raw.source === 'client' ? raw.source : 'tool_resolve' const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt diff --git a/shared/src/overseerWriteIntent.test.ts b/shared/src/overseerWriteIntent.test.ts index 240e849d1a..e7d62d2624 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,56 @@ 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: 'ping session abcdef12: "please continue"' + latestOperatorText: 'tell it to go ahead - tear down and rebuild is fine', + focus: focused }) - 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].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('binds short named session tokens after the word session', () => { + 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 sess-1: "hi"' + latestOperatorText: 'ping session abcdef12: "please continue"' }) - expect(isWriteToolCallAuthorized('ping_session', { - sessionId: 'sess-1', - message: 'hi' - }, auth).ok).toBe(true) + expect([...auth.allowed]).toEqual([]) + expect( + isWriteToolCallAuthorized( + 'ping_session', + { sessionId: 'abcdef12-ffff-ffff-ffff-ffffffffffff', message: 'please continue' }, + auth + ).ok + ).toBe(false) }) - it('denies ping without a concrete target in the operator message', () => { + it('denies write tools on a read-only ask with no focus', () => { const auth = resolveOverseerWriteAuthorization({ - latestOperatorText: 'ping that worker to continue' + latestOperatorText: 'summarize the inbox' }) - const result = isWriteToolCallAuthorized('ping_session', { - sessionId: 'abcdef12', - message: 'continue' - }, auth) - expect(result.ok).toBe(false) + expect(isWriteToolCallAuthorized('ping_session', { sessionId: 'x', message: 'y' }, auth).ok).toBe(false) + expect(isWriteToolCallAuthorized('query_inbox', {}, auth).ok).toBe(true) }) - it('denies write tools when neither flag nor intent matches', () => { + it('disposition binds to focused itemId', () => { const auth = resolveOverseerWriteAuthorization({ - latestOperatorText: 'summarize the inbox' + latestOperatorText: 'mark it done', + focus: focused }) - expect(isWriteToolCallAuthorized('ping_session', { sessionId: 'x', message: 'y' }, auth).ok).toBe(false) - expect(isWriteToolCallAuthorized('query_inbox', {}, auth).ok).toBe(true) + expect(isWriteToolCallAuthorized('record_disposition', { itemId: 118, action: 'done' }, auth).ok).toBe(true) + expect(isWriteToolCallAuthorized('record_disposition', { itemId: 999, action: 'done' }, auth).ok).toBe(false) }) }) diff --git a/shared/src/overseerWriteIntent.ts b/shared/src/overseerWriteIntent.ts index 8d1f3c04ba..d0718c3db4 100644 --- a/shared/src/overseerWriteIntent.ts +++ b/shared/src/overseerWriteIntent.ts @@ -1,17 +1,19 @@ /** * 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 * - * Hub-owned conversational focus supplies targets for anaphoric follow-ups - * ("tell it to…") when the latest line has write intent but no fresh ids. + * 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' @@ -23,45 +25,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)) { @@ -71,61 +38,62 @@ 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 mergeFocusTargets( - fromText: { sessionIdPrefixes: string[]; itemIds: number[] }, - focus: OverseerConverseFocus | null | undefined -): { sessionIdPrefixes: string[]; itemIds: number[] } { - const sessionIdPrefixes = [...fromText.sessionIdPrefixes] - const itemIds = [...fromText.itemIds] +function focusTargets(focus: OverseerConverseFocus | null | undefined): { + sessionIdPrefixes: string[] + itemIds: number[] +} { + const sessionIdPrefixes: string[] = [] + const itemIds: number[] = [] if (!focus) return { sessionIdPrefixes, itemIds } - - // Line-local ids win for binding; focus fills gaps for anaphora. - if (sessionIdPrefixes.length === 0 && focus.sessionId) { - sessionIdPrefixes.push(focus.sessionId.toLowerCase()) - } - if (itemIds.length === 0 && focus.itemId != null) { - itemIds.push(focus.itemId) - } + 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 { + const t = focusTargets(focus) + return t.sessionIdPrefixes.length > 0 || t.itemIds.length > 0 +} + +/** + * 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 prior turns / successful tool resolves. */ + /** Hub-owned subject from successful tool resolves (required for converse writes). */ focus?: OverseerConverseFocus | null }): OverseerWriteAuthorization { - const text = opts.latestOperatorText - const fromText = { - sessionIdPrefixes: extractSessionIdPrefixes(text), - itemIds: extractItemIds(text) - } - const merged = mergeFocusTargets(fromText, opts.focus) + const text = opts.latestOperatorText ?? '' + const targets = focusTargets(opts.focus) + if (opts.allowWrites === true) { return { allowed: new Set(['ping_session', 'record_disposition']), explicitClientFlag: true, - sessionIdPrefixes: merged.sessionIdPrefixes, - itemIds: merged.itemIds, + 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: merged.sessionIdPrefixes, - itemIds: merged.itemIds, - messageSnippets: extractQuotedSnippets(text) + sessionIdPrefixes: [], + itemIds: [], + messageSnippets: [] } } @@ -140,8 +108,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, @@ -150,7 +118,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') { @@ -162,6 +133,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 } } @@ -169,17 +142,13 @@ export function isWriteToolCallAuthorized( if (!hasTargetGrant) { return { ok: false, - error: - 'relay requires a focused session/item (or an explicit id in the operator message / 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' } - } - if (!messageMatchesGrant(message, auth.messageSnippets)) { - return { ok: false, error: 'relay message does not match operator-quoted payload' } + return { ok: false, error: 'relay target does not match conversational focus' } } return { ok: true } } @@ -190,12 +159,11 @@ export function isWriteToolCallAuthorized( if (auth.itemIds.length === 0) { return { ok: false, - error: - 'disposition requires a focused item (or an explicit item id in the operator message / 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 } } From d43367af10a1c6443596c2b029c67d869007b533 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:05:57 +0000 Subject: [PATCH 03/14] fix(overseer): address Codex P1/P2 on converse focus Freeze write grants at turn start so mid-turn tool resolves cannot unlock same-turn writes. Require non-null session probes, conditional focus persist, client relatedSessionId replace, and merge/delete repoint. Co-authored-by: Cursor --- .cursor/mcp.json | 17 +++++++ .cursor/rules/hapi-session.mdc | 19 +++++++ hub/src/overseer/converse.test.ts | 10 ++-- hub/src/overseer/converse.ts | 15 ++++-- hub/src/store/settingsStore.test.ts | 43 ++++++++++++++++ hub/src/store/settingsStore.ts | 44 ++++++++++++++++ hub/src/sync/sessionCache.ts | 3 ++ hub/src/web/routes/overseer.ts | 9 +++- shared/src/overseerConverseFocus.test.ts | 31 +++++++++++ shared/src/overseerConverseFocus.ts | 65 ++++++++++++++++++------ 10 files changed, 229 insertions(+), 27 deletions(-) create mode 100644 .cursor/mcp.json create mode 100644 .cursor/rules/hapi-session.mdc diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000000..9f7ad3e557 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "hapi-15e236e1-5f2b-4738-966f-ed09f047920b": { + "command": "/home/heavygee/.bun/bin/bun", + "args": [ + "--cwd", + "/home/heavygee/coding/hapi/driver/cli", + "/home/heavygee/coding/hapi/driver/cli/src/index.ts", + "mcp", + "--url", + "http://127.0.0.1:35013/", + "--tools", + "link_pr,display_image,display_video,ping_peer,inspect_peer,skill_lookup" + ] + } + } +} diff --git a/.cursor/rules/hapi-session.mdc b/.cursor/rules/hapi-session.mdc new file mode 100644 index 0000000000..0d4e7d1691 --- /dev/null +++ b/.cursor/rules/hapi-session.mdc @@ -0,0 +1,19 @@ +--- +alwaysApply: true +--- + + +# Session status summary + +End every response with a single machine-readable status line so this +workspace's session tracking can record progress. Put it on its own final +line, after all other content, with no code fences or backticks: + +AGENT_NOTIFY_SUMMARY {"version":1,"agent":"","project":"overseer-converse-focus","status":"done|blocked|needs_review|needs_decision|failed|stalled","action":"<=12 words","summary":"one-line triage"} + +Guidance: +- status: pick the closest value; use "blocked" if unsure. +- action: concrete next step (required when status is "done" and follow-up + remains); 12 words or fewer. +- summary: one-line triage of what this turn did. +- Keep it as the very last line of every response. diff --git a/hub/src/overseer/converse.test.ts b/hub/src/overseer/converse.test.ts index 7e97562d4c..2912e1861b 100644 --- a/hub/src/overseer/converse.test.ts +++ b/hub/src/overseer/converse.test.ts @@ -413,7 +413,7 @@ describe('runOverseerConverse', () => { expect(focus?.source).toBe('tool_resolve') }) - it('unlocks ping mid-turn after a subject-resolving read establishes focus', async () => { + 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, @@ -461,7 +461,7 @@ describe('runOverseerConverse', () => { })) .mockResolvedValueOnce(chatResponse({ role: 'assistant', - content: 'Relayed.' + content: 'Need focus from a prior turn to relay.' })) setFetch(fetchMock) @@ -473,8 +473,10 @@ describe('runOverseerConverse', () => { }) expect(toolTrace.find((t) => t.tool === 'explain_priority')?.ok).toBe(true) - expect(toolTrace.find((t) => t.tool === 'ping_session')?.ok).toBe(true) - expect(pingSession).toHaveBeenCalledOnce() + 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) }) }) diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index 1219bad2d8..6e46884856 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -126,19 +126,24 @@ export async function runOverseerConverse(params: { const { overseer, config, messages, maxIterations = 6, signal, allowWrites } = params const latestOperatorText = [...messages].reverse().find((m) => m.role === 'operator')?.content ?? '' + /** 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 const writeAuthFor = (): OverseerWriteAuthorization => resolveOverseerWriteAuthorization({ latestOperatorText, allowWrites, - focus + focus: writeFocus }) - // Always expose the full tool catalog. Write authorization is enforced at - // call time against hub focus (which may be established mid-turn by a - // successful subject-resolving read). Filtering writes out of `tools` when - // focus starts empty would prevent the brain from acting after resolve. + // 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) diff --git a/hub/src/store/settingsStore.test.ts b/hub/src/store/settingsStore.test.ts index 2aca70213a..22520fea53 100644 --- a/hub/src/store/settingsStore.test.ts +++ b/hub/src/store/settingsStore.test.ts @@ -73,6 +73,49 @@ describe('SettingsStore', () => { expect(s.getConverseFocus()).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') + }) + + 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 5e8716209d..07519a2845 100644 --- a/hub/src/store/settingsStore.ts +++ b/hub/src/store/settingsStore.ts @@ -103,7 +103,51 @@ export class SettingsStore { ) } + /** + * Persist focus only when it is strictly newer than the durable row. + * Prevents an older overlapping converse request from rolling focus back. + */ + 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 + } + clearConverseFocus(namespace = 'default'): void { this.delete(converseFocusKey(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: Date.now() + }, 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: Date.now() + }, namespace) + return + } + this.clearConverseFocus(namespace) + } } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 0d2da5bd86..f99a0ea10d 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -803,6 +803,8 @@ export class SessionCache { throw new Error('Failed to delete session') } + this.store.settings.clearConverseFocusIfSession(sessionId, session.namespace) + this.sessions.delete(sessionId) this.lastBroadcastAtBySessionId.delete(sessionId) this.todoBackfillAttemptedSessionIds.delete(sessionId) @@ -950,6 +952,7 @@ export class SessionCache { if (options.deleteOldSession) { this.store.events.repointSession(oldSessionId, newSessionId) this.store.inbox.repointSession(oldSessionId, newSessionId) + this.store.settings.repointConverseFocusSession(oldSessionId, newSessionId, namespace) const deleted = this.store.sessions.deleteSession(oldSessionId, namespace) if (!deleted) { throw new Error('Failed to delete old session during merge') diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index fd5417709b..faff7d4f45 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -260,6 +260,7 @@ 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, @@ -285,8 +286,11 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho focus: priorFocus }) - if (focus) settings.setConverseFocus(focus, namespace) - else settings.clearConverseFocus(namespace) + 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, @@ -324,6 +328,7 @@ 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.' + if (priorFocus) settings.setConverseFocusIfNewer(priorFocus, namespace) persistOverseerConvoExchange(overseer, assembled, { operatorText: lastOperator, overseerText: reply, diff --git a/shared/src/overseerConverseFocus.test.ts b/shared/src/overseerConverseFocus.test.ts index cb0ee3eda9..215a3e36cc 100644 --- a/shared/src/overseerConverseFocus.test.ts +++ b/shared/src/overseerConverseFocus.test.ts @@ -108,6 +108,37 @@ describe('hub-owned conversational focus (capability, not pattern matching)', () 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', () => { diff --git a/shared/src/overseerConverseFocus.ts b/shared/src/overseerConverseFocus.ts index b036c60403..99363d9a13 100644 --- a/shared/src/overseerConverseFocus.ts +++ b/shared/src/overseerConverseFocus.ts @@ -78,12 +78,16 @@ export function applyFocusFromClientSession( ): OverseerConverseFocus | null { const id = typeof sessionId === 'string' ? sessionId.trim() : '' if (!id) return previous - return buildFocus({ - 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', - now - }) + updatedAt: now + } } function sessionFromResult(result: unknown): string | null { @@ -91,11 +95,21 @@ function sessionFromResult(result: unknown): string | null { if (typeof result.sessionId === 'string' && result.sessionId.trim()) { return result.sessionId.trim() } - if (isObj(result.state) && typeof result.state.id === 'string' && result.state.id.trim()) { - return result.state.id.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) && typeof result.health.id === 'string' && result.health.id.trim()) { - return result.health.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) && @@ -137,6 +151,7 @@ export function applyFocusFromToolResolve( 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) @@ -151,15 +166,10 @@ export function applyFocusFromToolResolve( } } - if ( - tool === 'get_session_state' || - tool === 'get_session_recent_output' || - tool === 'get_worker_health' - ) { - const fromArgs = typeof args.sessionId === 'string' ? args.sessionId.trim() : '' - const sessionId = sessionFromResult(result) ?? (fromArgs || null) + if (tool === 'get_session_state') { + if (!isObj(result) || result.state == null) return previous + const sessionId = sessionFromResult(result) if (!sessionId) return previous - // Session probe replaces session; clear item unless same session keeps prior item. const keepItem = previous?.sessionId && previous.sessionId.toLowerCase() === sessionId.toLowerCase() @@ -173,6 +183,29 @@ export function applyFocusFromToolResolve( } } + 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) ?? From 94bc2f9ded760254505dc3110cfa658c0c651d2b Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:06:09 +0000 Subject: [PATCH 04/14] chore: drop accidental .cursor files from focus PR Co-authored-by: Cursor --- .cursor/mcp.json | 17 ----------------- .cursor/rules/hapi-session.mdc | 19 ------------------- 2 files changed, 36 deletions(-) delete mode 100644 .cursor/mcp.json delete mode 100644 .cursor/rules/hapi-session.mdc diff --git a/.cursor/mcp.json b/.cursor/mcp.json deleted file mode 100644 index 9f7ad3e557..0000000000 --- a/.cursor/mcp.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "mcpServers": { - "hapi-15e236e1-5f2b-4738-966f-ed09f047920b": { - "command": "/home/heavygee/.bun/bin/bun", - "args": [ - "--cwd", - "/home/heavygee/coding/hapi/driver/cli", - "/home/heavygee/coding/hapi/driver/cli/src/index.ts", - "mcp", - "--url", - "http://127.0.0.1:35013/", - "--tools", - "link_pr,display_image,display_video,ping_peer,inspect_peer,skill_lookup" - ] - } - } -} diff --git a/.cursor/rules/hapi-session.mdc b/.cursor/rules/hapi-session.mdc deleted file mode 100644 index 0d4e7d1691..0000000000 --- a/.cursor/rules/hapi-session.mdc +++ /dev/null @@ -1,19 +0,0 @@ ---- -alwaysApply: true ---- - - -# Session status summary - -End every response with a single machine-readable status line so this -workspace's session tracking can record progress. Put it on its own final -line, after all other content, with no code fences or backticks: - -AGENT_NOTIFY_SUMMARY {"version":1,"agent":"","project":"overseer-converse-focus","status":"done|blocked|needs_review|needs_decision|failed|stalled","action":"<=12 words","summary":"one-line triage"} - -Guidance: -- status: pick the closest value; use "blocked" if unsure. -- action: concrete next step (required when status is "done" and follow-up - remains); 12 words or fewer. -- summary: one-line triage of what this turn did. -- Keep it as the very last line of every response. From 783ef8f147e510505c0ea72d5bb1d03749f26a9a Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:07:30 +0000 Subject: [PATCH 05/14] fix(overseer): tombstone focus clears and stamp tool resolves at turn start Keep setConverseFocusIfNewer able to reject older in-flight turns after session delete, stamp focus updates with turnStartedAt (not tool-completion wall clock), and promote singleton list_active_workers like query_inbox. Co-authored-by: Cursor --- hub/src/overseer/converse.ts | 18 ++++++--- hub/src/store/settingsStore.test.ts | 27 ++++++++++++- hub/src/store/settingsStore.ts | 15 ++++++- shared/src/overseerConverseFocus.test.ts | 51 ++++++++++++++++++++++++ shared/src/overseerConverseFocus.ts | 47 +++++++++++++++++++--- shared/src/overseerWriteIntent.ts | 6 +-- 6 files changed, 148 insertions(+), 16 deletions(-) diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index 6e46884856..124ee67227 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -134,6 +134,8 @@ export async function runOverseerConverse(params: { * Cross-turn "tell it…" uses the focus persisted from the prior turn. */ const writeFocus = params.focus ?? null + /** Monotonic turn token — tool resolves stamp this, not wall-clock at completion. */ + const turnStartedAt = Date.now() const writeAuthFor = (): OverseerWriteAuthorization => resolveOverseerWriteAuthorization({ @@ -257,12 +259,16 @@ export async function runOverseerConverse(params: { ...(ok ? {} : { error: toolResultError(result) }) }) if (ok) { - focus = applyFocusFromToolResolve(focus, { - tool: name, - ok: true, - args, - result - }) + focus = applyFocusFromToolResolve( + focus, + { + tool: name, + ok: true, + args, + result + }, + turnStartedAt + ) } if (ok && isOverseerWriteTool(name)) { consumedWriteFingerprints.add(fingerprintWriteToolCall(name, args)) diff --git a/hub/src/store/settingsStore.test.ts b/hub/src/store/settingsStore.test.ts index 22520fea53..cc55326fe7 100644 --- a/hub/src/store/settingsStore.test.ts +++ b/hub/src/store/settingsStore.test.ts @@ -70,7 +70,32 @@ describe('SettingsStore', () => { expect(s.getConverseFocus('ns-a')?.sessionId).toBe('other') expect(s.getConverseFocus()?.itemId).toBe(118) s.clearConverseFocus() - expect(s.getConverseFocus()).toBeNull() + 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', () => { diff --git a/hub/src/store/settingsStore.ts b/hub/src/store/settingsStore.ts index 07519a2845..941153aa10 100644 --- a/hub/src/store/settingsStore.ts +++ b/hub/src/store/settingsStore.ts @@ -114,8 +114,21 @@ export class SettingsStore { return true } + /** + * 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.delete(converseFocusKey(namespace)) + this.setConverseFocus( + { + sessionId: null, + itemId: null, + source: 'client', + updatedAt: Date.now() + }, + namespace + ) } /** After session merge/resume remaps ids — keep anaphoric writes on the live session. */ diff --git a/shared/src/overseerConverseFocus.test.ts b/shared/src/overseerConverseFocus.test.ts index 215a3e36cc..d4b950e04f 100644 --- a/shared/src/overseerConverseFocus.test.ts +++ b/shared/src/overseerConverseFocus.test.ts @@ -3,6 +3,8 @@ import { applyFocusFromClientSession, applyFocusFromToolResolve, formatConverseFocusDirective, + hasConverseFocusSubject, + parseConverseFocus, type OverseerConverseFocus } from './overseerConverseFocus' import { @@ -147,4 +149,53 @@ describe('hub-owned conversational focus (capability, not pattern matching)', () 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('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 index 99363d9a13..7a5373515a 100644 --- a/shared/src/overseerConverseFocus.ts +++ b/shared/src/overseerConverseFocus.ts @@ -17,6 +17,14 @@ export type OverseerConverseFocus = { 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 @@ -255,12 +263,32 @@ export function applyFocusFromToolResolve( } } + // 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 + } + } + return previous } /** System-prompt / assemble hint so the brain shares the hub referent. */ export function formatConverseFocusDirective(focus: OverseerConverseFocus | null): string | null { - if (!focus || (!focus.sessionId && focus.itemId == null)) return 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', @@ -269,12 +297,16 @@ export function formatConverseFocusDirective(focus: OverseerConverseFocus | null '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}`) + 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 = @@ -283,12 +315,17 @@ export function parseConverseFocus(raw: unknown): OverseerConverseFocus | null { typeof raw.itemId === 'number' && Number.isFinite(raw.itemId) && raw.itemId > 0 ? raw.itemId : null - if (!sessionId && itemId == null) return 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.ts b/shared/src/overseerWriteIntent.ts index d0718c3db4..b17377b9fc 100644 --- a/shared/src/overseerWriteIntent.ts +++ b/shared/src/overseerWriteIntent.ts @@ -18,6 +18,7 @@ import { isOverseerWriteTool, type OverseerWriteToolName } from './overseerEntity' import type { OverseerConverseFocus } from './overseerConverseFocus' +import { hasConverseFocusSubject } from './overseerConverseFocus' export type OverseerWriteAuthorization = { allowed: ReadonlySet @@ -44,15 +45,14 @@ function focusTargets(focus: OverseerConverseFocus | null | undefined): { } { const sessionIdPrefixes: string[] = [] const itemIds: number[] = [] - if (!focus) return { sessionIdPrefixes, itemIds } + 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 { - const t = focusTargets(focus) - return t.sessionIdPrefixes.length > 0 || t.itemIds.length > 0 + return hasConverseFocusSubject(focus) } /** From 3ffcde90dc927dbc0e174d3f417b1b3af95afae0 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:19:30 +0000 Subject: [PATCH 06/14] fix(overseer): close Codex P2 gaps on focus delete, writes, and brain fail Route session deletes through Store.deleteSession so cache-bypass paths clear focus; replace the whole session/item pair on subject-changing writes; promote singleton query_open_loops; persist mid-turn focus when a later brain call fails. Co-authored-by: Cursor --- hub/src/overseer/brainClient.ts | 5 +++ hub/src/overseer/converse.test.ts | 40 +++++++++++++++++ hub/src/overseer/converse.ts | 9 ++++ hub/src/store/index.ts | 12 ++++++ hub/src/sync/sessionCache.ts | 6 +-- hub/src/web/routes/codexDesktop.ts | 2 +- hub/src/web/routes/overseer.ts | 8 ++-- shared/src/overseerConverseFocus.test.ts | 55 ++++++++++++++++++++++++ shared/src/overseerConverseFocus.ts | 44 ++++++++++++++----- 9 files changed, 163 insertions(+), 18 deletions(-) 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 2912e1861b..0385d4e7df 100644 --- a/hub/src/overseer/converse.test.ts +++ b/hub/src/overseer/converse.test.ts @@ -479,4 +479,44 @@ describe('runOverseerConverse', () => { expect(focus?.itemId).toBe(118) expect(focus?.sessionId).toBe(sessionId) }) + + 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 124ee67227..3188af43de 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -27,6 +27,7 @@ import { isOverseerToolName, runOverseerTool } from './runOverseerTool' import { projectToolResultForBrain } from './toolProjection' import { callBrain, + BrainUnavailableError, type BrainConfig, type OpenAiChatMessage, type OverseerOpenAiToolLike @@ -189,6 +190,11 @@ export async function runOverseerConverse(params: { if (hasSuccessfulWrite(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 } const calls = message.tool_calls ?? [] @@ -305,6 +311,9 @@ export async function runOverseerConverse(params: { if (hasSuccessfulWrite(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/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index f99a0ea10d..8c2ee0b5e4 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -798,13 +798,11 @@ 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') } - this.store.settings.clearConverseFocusIfSession(sessionId, session.namespace) - this.sessions.delete(sessionId) this.lastBroadcastAtBySessionId.delete(sessionId) this.todoBackfillAttemptedSessionIds.delete(sessionId) @@ -953,7 +951,7 @@ export class SessionCache { this.store.events.repointSession(oldSessionId, newSessionId) this.store.inbox.repointSession(oldSessionId, newSessionId) this.store.settings.repointConverseFocusSession(oldSessionId, newSessionId, namespace) - const deleted = this.store.sessions.deleteSession(oldSessionId, 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 faff7d4f45..1f709637be 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -328,11 +328,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.' - if (priorFocus) settings.setConverseFocusIfNewer(priorFocus, namespace) + const focusToPersist = error.converseFocus ?? priorFocus + if (focusToPersist) settings.setConverseFocusIfNewer(focusToPersist, namespace) persistOverseerConvoExchange(overseer, assembled, { operatorText: lastOperator, overseerText: reply, - relatedSessionId: parsed.data.relatedSessionId ?? priorFocus?.sessionId ?? null + relatedSessionId: + parsed.data.relatedSessionId ?? focusToPersist?.sessionId ?? null }) return c.json({ reply, @@ -341,7 +343,7 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho brainOnline: error.reachable, hydratedTurns: assembled.hydratedTurns, truncated: assembled.truncated, - focus: priorFocus + focus: focusToPersist }) } throw error diff --git a/shared/src/overseerConverseFocus.test.ts b/shared/src/overseerConverseFocus.test.ts index d4b950e04f..4eaa21c5fe 100644 --- a/shared/src/overseerConverseFocus.test.ts +++ b/shared/src/overseerConverseFocus.test.ts @@ -181,6 +181,61 @@ describe('hub-owned conversational focus (capability, not pattern matching)', () ).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' + }) + ) + }) + + 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('parses clear-tombstones and ignores them as write subjects', () => { const tomb = parseConverseFocus({ sessionId: null, diff --git a/shared/src/overseerConverseFocus.ts b/shared/src/overseerConverseFocus.ts index 7a5373515a..77158ee087 100644 --- a/shared/src/overseerConverseFocus.ts +++ b/shared/src/overseerConverseFocus.ts @@ -222,24 +222,28 @@ export function applyFocusFromToolResolve( itemFromResult(result) ?? (typeof args.itemId === 'number' ? args.itemId : null) if (!sessionId && itemId == null) return previous - return buildFocus({ - previous, - sessionId: sessionId || undefined, - itemId: itemId ?? undefined, + // Subject-changing write replaces the whole pair — do not inherit a + // stale counterpart from the prior focus (Codex P2). + return { + sessionId: sessionId || null, + itemId: itemId ?? null, source: 'tool_resolve', - now - }) + updatedAt: now + } } if (tool === 'record_disposition') { const itemId = typeof args.itemId === 'number' ? args.itemId : itemFromResult(result) if (itemId == null) return previous - return buildFocus({ - previous, + const sessionId = + sessionFromResult(result) ?? + (typeof args.sessionId === 'string' ? args.sessionId.trim() : null) + return { + sessionId: sessionId || null, itemId, source: 'tool_resolve', - now - }) + updatedAt: now + } } // query_inbox: only retarget when exactly one subject. @@ -283,6 +287,26 @@ export function applyFocusFromToolResolve( } } + // 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 + } + } + return previous } From 89521cf38a09e737923be15b576b19fda929c56c Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:27:45 +0000 Subject: [PATCH 07/14] fix(overseer): strict focus versions and singleton disposition focus Refuse equal updatedAt on persist (first writer wins), bump clears/repoints above the durable version, and promote singleton list-mode query_dispositions. Co-authored-by: Cursor --- hub/src/overseer/converse.ts | 4 ++-- hub/src/store/settingsStore.test.ts | 9 ++++++++ hub/src/store/settingsStore.ts | 18 +++++++++++----- hub/src/web/routes/overseer.ts | 6 ++++-- shared/src/overseerConverseFocus.test.ts | 27 ++++++++++++++++++++++++ shared/src/overseerConverseFocus.ts | 20 ++++++++++++++++++ 6 files changed, 75 insertions(+), 9 deletions(-) diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index 3188af43de..497fa5980e 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -135,8 +135,8 @@ export async function runOverseerConverse(params: { * Cross-turn "tell it…" uses the focus persisted from the prior turn. */ const writeFocus = params.focus ?? null - /** Monotonic turn token — tool resolves stamp this, not wall-clock at completion. */ - const turnStartedAt = Date.now() + /** 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({ diff --git a/hub/src/store/settingsStore.test.ts b/hub/src/store/settingsStore.test.ts index cc55326fe7..88c31dbbdb 100644 --- a/hub/src/store/settingsStore.test.ts +++ b/hub/src/store/settingsStore.test.ts @@ -124,6 +124,15 @@ describe('SettingsStore', () => { }) ).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', () => { diff --git a/hub/src/store/settingsStore.ts b/hub/src/store/settingsStore.ts index 941153aa10..028de9058d 100644 --- a/hub/src/store/settingsStore.ts +++ b/hub/src/store/settingsStore.ts @@ -105,15 +105,23 @@ export class SettingsStore { /** * Persist focus only when it is strictly newer than the durable row. - * Prevents an older overlapping converse request from rolling focus back. + * 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 + 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 @@ -125,7 +133,7 @@ export class SettingsStore { sessionId: null, itemId: null, source: 'client', - updatedAt: Date.now() + updatedAt: this.nextFocusUpdatedAt(namespace) }, namespace ) @@ -143,7 +151,7 @@ export class SettingsStore { this.setConverseFocus({ ...focus, sessionId: newSessionId, - updatedAt: Date.now() + updatedAt: this.nextFocusUpdatedAt(namespace) }, namespace) } @@ -157,7 +165,7 @@ export class SettingsStore { sessionId: null, itemId: focus.itemId, source: focus.source, - updatedAt: Date.now() + updatedAt: this.nextFocusUpdatedAt(namespace) }, namespace) return } diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index 1f709637be..0998d5742d 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -240,9 +240,11 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho }) const messages = assembled.messages const lastOperator = [...messages].reverse().find((m) => m.role === 'operator')?.content ?? '' + const durableFocus = settings.getConverseFocus(namespace) const priorFocus = applyFocusFromClientSession( - settings.getConverseFocus(namespace), - parsed.data.relatedSessionId + durableFocus, + parsed.data.relatedSessionId, + Math.max(Date.now(), (durableFocus?.updatedAt ?? 0) + 1) ) const active = getSanitizedActiveBrain(engine, c.get('namespace')) diff --git a/shared/src/overseerConverseFocus.test.ts b/shared/src/overseerConverseFocus.test.ts index 4eaa21c5fe..029e602309 100644 --- a/shared/src/overseerConverseFocus.test.ts +++ b/shared/src/overseerConverseFocus.test.ts @@ -236,6 +236,33 @@ describe('hub-owned conversational focus (capability, not pattern matching)', () ).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, diff --git a/shared/src/overseerConverseFocus.ts b/shared/src/overseerConverseFocus.ts index 77158ee087..a89d95cf34 100644 --- a/shared/src/overseerConverseFocus.ts +++ b/shared/src/overseerConverseFocus.ts @@ -307,6 +307,26 @@ export function applyFocusFromToolResolve( } } + // 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 + } + } + return previous } From cae4a1b642832bd14cdec85e33c18d68d130df41 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:36:30 +0000 Subject: [PATCH 08/14] fix(overseer): publish client focus early; tighten ping binding Persist relatedSessionId focus before awaiting the brain; accept unique session-id prefixes; require all supplied write selectors to match focus; promote singleton query_events; never adopt ungranted ping itemIds. Co-authored-by: Cursor --- hub/src/web/routes/overseer.ts | 4 ++ shared/src/overseerConverseFocus.test.ts | 24 +++++++++++ shared/src/overseerConverseFocus.ts | 52 +++++++++++++++++++++--- shared/src/overseerWriteIntent.test.ts | 24 +++++++++++ shared/src/overseerWriteIntent.ts | 17 +++++++- 5 files changed, 114 insertions(+), 7 deletions(-) diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index 0998d5742d..648794bbde 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -279,6 +279,10 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho }) } + // 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, focus } = await runOverseerConverse({ overseer, diff --git a/shared/src/overseerConverseFocus.test.ts b/shared/src/overseerConverseFocus.test.ts index 029e602309..13987362e8 100644 --- a/shared/src/overseerConverseFocus.test.ts +++ b/shared/src/overseerConverseFocus.test.ts @@ -210,6 +210,30 @@ describe('hub-owned conversational focus (capability, not pattern matching)', () ) }) + 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('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, { diff --git a/shared/src/overseerConverseFocus.ts b/shared/src/overseerConverseFocus.ts index a89d95cf34..d95af1382a 100644 --- a/shared/src/overseerConverseFocus.ts +++ b/shared/src/overseerConverseFocus.ts @@ -218,15 +218,33 @@ export function applyFocusFromToolResolve( const sessionId = sessionFromResult(result) ?? (typeof args.sessionId === 'string' ? args.sessionId.trim() : null) - const itemId = - itemFromResult(result) ?? - (typeof args.itemId === 'number' ? args.itemId : 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 + // Never adopt a model-supplied itemId that was not already the focused item. + // Session-matched pings keep the prior item; off-session writes clear it. + let itemId: number | null = resultItem + if (itemId == null) { + if (argsItem != null && previous?.itemId === argsItem) { + 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 - // Subject-changing write replaces the whole pair — do not inherit a - // stale counterpart from the prior focus (Codex P2). return { sessionId: sessionId || null, - itemId: itemId ?? null, + itemId, source: 'tool_resolve', updatedAt: now } @@ -327,6 +345,28 @@ export function applyFocusFromToolResolve( } } + // 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 } diff --git a/shared/src/overseerWriteIntent.test.ts b/shared/src/overseerWriteIntent.test.ts index e7d62d2624..c5d4201934 100644 --- a/shared/src/overseerWriteIntent.test.ts +++ b/shared/src/overseerWriteIntent.test.ts @@ -80,4 +80,28 @@ describe('resolveOverseerWriteAuthorization (focus-owned, not regex)', () => { 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('accepts a unique short prefix of the focused session id', () => { + const auth = resolveOverseerWriteAuthorization({ + focus: { ...focused, itemId: null } + }) + 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 b17377b9fc..9f67ee06b0 100644 --- a/shared/src/overseerWriteIntent.ts +++ b/shared/src/overseerWriteIntent.ts @@ -99,7 +99,14 @@ export function resolveOverseerWriteAuthorization(opts: { 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 { @@ -147,6 +154,14 @@ export function isWriteToolCallAuthorized( } const sessionOk = sessionId.length > 0 && sessionIdMatchesGrant(sessionId, auth.sessionIdPrefixes) const itemOk = itemId != null && auth.itemIds.includes(itemId) + // 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 (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' } } From 6f80eafc57df6cbc424a13d0d61d7288b5e7ffc9 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:42:08 +0000 Subject: [PATCH 09/14] fix(overseer): refuse last-wins focus on multi-subject turns When one turn successfully resolves more than one distinct session/item, keep the turn-start focus instead of persisting whichever probe finished last. Co-authored-by: Cursor --- hub/src/overseer/converse.test.ts | 49 +++++++++++++++++++++++++++++++ hub/src/overseer/converse.ts | 41 +++++++++++++++++++------- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/hub/src/overseer/converse.test.ts b/hub/src/overseer/converse.test.ts index 0385d4e7df..10ae398557 100644 --- a/hub/src/overseer/converse.test.ts +++ b/hub/src/overseer/converse.test.ts @@ -480,6 +480,55 @@ describe('runOverseerConverse', () => { 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('attaches mid-turn focus onto BrainUnavailableError after a resolving tool', async () => { const sessionId = '6cd8d0c3-aaaa-bbbb-cccc-ddddeeeeffff' const overseer = { diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index 497fa5980e..bc66e9b76a 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -13,6 +13,7 @@ import { buildOverseerSystemPrompt, fingerprintWriteToolCall, formatConverseFocusDirective, + hasConverseFocusSubject, isOverseerWriteTool, isWriteToolCallAuthorized, resolveOverseerWriteAuthorization, @@ -109,6 +110,11 @@ function hasSuccessfulWrite(toolTrace: OverseerToolTraceEntry[]): boolean { return toolTrace.some((entry) => entry.ok && isOverseerWriteTool(entry.tool as OverseerToolName)) } +function focusSubjectKey(focus: OverseerConverseFocus | null | undefined): string | null { + if (!hasConverseFocusSubject(focus) || !focus) return null + return `${(focus.sessionId ?? '').trim().toLowerCase()}#${focus.itemId ?? ''}` +} + export async function runOverseerConverse(params: { overseer: OverseerEntity config: BrainConfig @@ -171,6 +177,11 @@ export async function runOverseerConverse(params: { const writeConfirmations: string[] = [] /** Successful irreversible call fingerprints — reject duplicates in this turn. */ const consumedWriteFingerprints = new Set() + /** + * Distinct subjects established by successful tool resolves this turn. + * More than one → do not last-win; keep turn-start focus (Codex P2). + */ + const subjectsResolvedThisTurn = new Set() // 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 @@ -180,6 +191,20 @@ export async function runOverseerConverse(params: { const finish = (reply: string) => ({ reply, toolTrace, focus }) + const applyToolFocus = ( + previous: OverseerConverseFocus | null, + event: Parameters[1] + ): OverseerConverseFocus | null => { + const next = applyFocusFromToolResolve(previous, event, turnStartedAt) + const key = focusSubjectKey(next) + if (key) subjectsResolvedThisTurn.add(key) + if (subjectsResolvedThisTurn.size > 1) { + // 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 { @@ -265,16 +290,12 @@ export async function runOverseerConverse(params: { ...(ok ? {} : { error: toolResultError(result) }) }) if (ok) { - focus = applyFocusFromToolResolve( - focus, - { - tool: name, - ok: true, - args, - result - }, - turnStartedAt - ) + focus = applyToolFocus(focus, { + tool: name, + ok: true, + args, + result + }) } if (ok && isOverseerWriteTool(name)) { consumedWriteFingerprints.add(fingerprintWriteToolCall(name, args)) From d74d56734c952b35e51269f8c96cd948d8b683ce Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:47:11 +0000 Subject: [PATCH 10/14] fix(overseer): count only actual subject changes for multi-focus Unchanged tool passthroughs (multi-item query_inbox) must not pollute the per-turn subject set, or a later real retarget incorrectly restores turn-start. Co-authored-by: Cursor --- hub/src/overseer/converse.test.ts | 60 +++++++++++++++++++++++++++++++ hub/src/overseer/converse.ts | 10 ++++-- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/hub/src/overseer/converse.test.ts b/hub/src/overseer/converse.test.ts index 10ae398557..98b6f6d00d 100644 --- a/hub/src/overseer/converse.test.ts +++ b/hub/src/overseer/converse.test.ts @@ -529,6 +529,66 @@ describe('runOverseerConverse', () => { 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 = { diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index bc66e9b76a..6a5035206d 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -196,8 +196,14 @@ export async function runOverseerConverse(params: { event: Parameters[1] ): OverseerConverseFocus | null => { const next = applyFocusFromToolResolve(previous, event, turnStartedAt) - const key = focusSubjectKey(next) - if (key) subjectsResolvedThisTurn.add(key) + const prevKey = focusSubjectKey(previous) + const nextKey = focusSubjectKey(next) + // Count only when this tool actually established/changed a subject — + // unchanged passthrough (e.g. multi-item query_inbox) must not pollute + // the multi-subject set (Codex P1). + if (nextKey && nextKey !== prevKey) { + subjectsResolvedThisTurn.add(nextKey) + } if (subjectsResolvedThisTurn.size > 1) { // Multi-subject comparison turn — refuse to invent a last-wins referent. return params.focus ?? null From 5479300771764cf9c133dec8e9852d4224e7c797 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:54:54 +0000 Subject: [PATCH 11/14] fix(overseer): multi-subject identity, canonical client seed, item-only ping Count subjects from apply-from-null (not passthrough); resolve relatedSessionId to a unique canonical id before write grants; keep itemId on item-only pings. Co-authored-by: Cursor --- hub/src/overseer/converse.test.ts | 57 ++++++++++++++++++++++++ hub/src/overseer/converse.ts | 13 +++--- hub/src/sync/overseerEntity.ts | 8 ++++ hub/src/web/routes/overseer.ts | 26 ++++++++++- shared/src/overseerConverseFocus.test.ts | 16 +++++++ shared/src/overseerConverseFocus.ts | 8 +++- 6 files changed, 117 insertions(+), 11 deletions(-) diff --git a/hub/src/overseer/converse.test.ts b/hub/src/overseer/converse.test.ts index 98b6f6d00d..7620d56bfe 100644 --- a/hub/src/overseer/converse.test.ts +++ b/hub/src/overseer/converse.test.ts @@ -529,6 +529,63 @@ describe('runOverseerConverse', () => { 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('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' diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index 6a5035206d..8a693fd170 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -195,15 +195,12 @@ export async function runOverseerConverse(params: { 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) + const identifiedKey = focusSubjectKey(identifiedAlone) + if (identifiedKey) subjectsResolvedThisTurn.add(identifiedKey) + const next = applyFocusFromToolResolve(previous, event, turnStartedAt) - const prevKey = focusSubjectKey(previous) - const nextKey = focusSubjectKey(next) - // Count only when this tool actually established/changed a subject — - // unchanged passthrough (e.g. multi-item query_inbox) must not pollute - // the multi-subject set (Codex P1). - if (nextKey && nextKey !== prevKey) { - subjectsResolvedThisTurn.add(nextKey) - } if (subjectsResolvedThisTurn.size > 1) { // Multi-subject comparison turn — refuse to invent a last-wins referent. return params.focus ?? null diff --git a/hub/src/sync/overseerEntity.ts b/hub/src/sync/overseerEntity.ts index d595f7d3ee..9a0120b92d 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. diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index 648794bbde..28b381d16f 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -16,6 +16,26 @@ import { assembleOverseerConverseMessages, listRecentConvoTurns, persistOverseer 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' + +const FULL_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** + * Client relatedSessionId must resolve to one canonical session before it can + * authorize write prefixes. Ambiguous/unknown short prefixes are refused; + * unknown full UUIDs are kept (session may not be loaded yet). + */ +function resolveClientRelatedSessionId( + overseer: OverseerEntity, + relatedSessionId: string | null | undefined +): string | null { + const raw = typeof relatedSessionId === 'string' ? relatedSessionId.trim() : '' + if (!raw) return null + const canonical = overseer.resolveCanonicalSessionId(raw) + if (canonical) return canonical + if (FULL_SESSION_ID_RE.test(raw)) return raw + return null +} const convoTurnBodySchema = z.object({ operatorText: z.string().max(8000).default(''), @@ -241,9 +261,13 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho const messages = assembled.messages const lastOperator = [...messages].reverse().find((m) => m.role === 'operator')?.content ?? '' const durableFocus = settings.getConverseFocus(namespace) + const clientSessionSeed = resolveClientRelatedSessionId( + overseer, + parsed.data.relatedSessionId + ) const priorFocus = applyFocusFromClientSession( durableFocus, - parsed.data.relatedSessionId, + clientSessionSeed, Math.max(Date.now(), (durableFocus?.updatedAt ?? 0) + 1) ) diff --git a/shared/src/overseerConverseFocus.test.ts b/shared/src/overseerConverseFocus.test.ts index 13987362e8..d2c3680a22 100644 --- a/shared/src/overseerConverseFocus.test.ts +++ b/shared/src/overseerConverseFocus.test.ts @@ -221,6 +221,22 @@ describe('hub-owned conversational focus (capability, not pattern matching)', () 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, { diff --git a/shared/src/overseerConverseFocus.ts b/shared/src/overseerConverseFocus.ts index d95af1382a..b61013b08f 100644 --- a/shared/src/overseerConverseFocus.ts +++ b/shared/src/overseerConverseFocus.ts @@ -224,12 +224,16 @@ export function applyFocusFromToolResolve( } const resultItem = itemFromResult(result) const argsItem = typeof args.itemId === 'number' && args.itemId > 0 ? args.itemId : null - // Never adopt a model-supplied itemId that was not already the focused item. - // Session-matched pings keep the prior item; off-session writes clear it. + 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 && From 488612d20c2a2d5df1b163d6d3b17f2519076d13 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:01:44 +0000 Subject: [PATCH 12/14] fix(overseer): compatible multi-subject focus and case-insensitive session ids Treat same-session or same-item resolutions as one referent; match and seed session UUIDs case-insensitively so uppercase client ids resolve. Co-authored-by: Cursor --- hub/src/overseer/converse.test.ts | 54 +++++++++++++++++++++++++++++++ hub/src/overseer/converse.ts | 35 +++++++++++++------- hub/src/sync/overseerEntity.ts | 5 +-- hub/src/web/routes/overseer.ts | 2 +- 4 files changed, 82 insertions(+), 14 deletions(-) diff --git a/hub/src/overseer/converse.test.ts b/hub/src/overseer/converse.test.ts index 7620d56bfe..c21547bac3 100644 --- a/hub/src/overseer/converse.test.ts +++ b/hub/src/overseer/converse.test.ts @@ -586,6 +586,60 @@ describe('runOverseerConverse', () => { 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('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' diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index 8a693fd170..a92a1478ab 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -110,9 +110,16 @@ function hasSuccessfulWrite(toolTrace: OverseerToolTraceEntry[]): boolean { return toolTrace.some((entry) => entry.ok && isOverseerWriteTool(entry.tool as OverseerToolName)) } -function focusSubjectKey(focus: OverseerConverseFocus | null | undefined): string | null { - if (!hasConverseFocusSubject(focus) || !focus) return null - return `${(focus.sessionId ?? '').trim().toLowerCase()}#${focus.itemId ?? ''}` +/** True when two resolved subjects refer to the same conversational referent. */ +function subjectsCompatible( + a: OverseerConverseFocus, + b: OverseerConverseFocus +): boolean { + const aSession = a.sessionId?.trim().toLowerCase() || null + const bSession = b.sessionId?.trim().toLowerCase() || null + if (aSession && bSession && aSession === bSession) return true + if (a.itemId != null && b.itemId != null && a.itemId === b.itemId) return true + return false } export async function runOverseerConverse(params: { @@ -178,10 +185,11 @@ export async function runOverseerConverse(params: { /** Successful irreversible call fingerprints — reject duplicates in this turn. */ const consumedWriteFingerprints = new Set() /** - * Distinct subjects established by successful tool resolves this turn. - * More than one → do not last-win; keep turn-start focus (Codex P2). + * 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 = new Set() + 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 @@ -197,13 +205,18 @@ export async function runOverseerConverse(params: { ): OverseerConverseFocus | null => { // What subject did THIS tool identify on its own (ignore prior focus passthrough)? const identifiedAlone = applyFocusFromToolResolve(null, event, turnStartedAt) - const identifiedKey = focusSubjectKey(identifiedAlone) - if (identifiedKey) subjectsResolvedThisTurn.add(identifiedKey) + if (hasConverseFocusSubject(identifiedAlone) && identifiedAlone) { + subjectsResolvedThisTurn.push(identifiedAlone) + } const next = applyFocusFromToolResolve(previous, event, turnStartedAt) - if (subjectsResolvedThisTurn.size > 1) { - // Multi-subject comparison turn — refuse to invent a last-wins referent. - return params.focus ?? null + 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 } diff --git a/hub/src/sync/overseerEntity.ts b/hub/src/sync/overseerEntity.ts index 9a0120b92d..2999d565e2 100644 --- a/hub/src/sync/overseerEntity.ts +++ b/hub/src/sync/overseerEntity.ts @@ -879,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/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index 28b381d16f..34121c845e 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -33,7 +33,7 @@ function resolveClientRelatedSessionId( if (!raw) return null const canonical = overseer.resolveCanonicalSessionId(raw) if (canonical) return canonical - if (FULL_SESSION_ID_RE.test(raw)) return raw + if (FULL_SESSION_ID_RE.test(raw)) return raw.toLowerCase() return null } From dbb5aac170059c13eb0b5ad2136e9a5559941031 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:09:35 +0000 Subject: [PATCH 13/14] fix(overseer): tighten subject compatibility and client focus fail-closed Conflicting itemIds on one session are multi-subject; disposition ignores raw sessionId args; unresolved relatedSessionId clears turn write focus instead of inheriting durable, and never seeds nonexistent UUIDs. Co-authored-by: Cursor --- hub/src/overseer/converse.test.ts | 44 ++++++++++++++++++++++++ hub/src/overseer/converse.ts | 7 +++- hub/src/web/routes/overseer.ts | 35 +++++++++---------- shared/src/overseerConverseFocus.test.ts | 9 +++++ shared/src/overseerConverseFocus.ts | 4 ++- 5 files changed, 79 insertions(+), 20 deletions(-) diff --git a/hub/src/overseer/converse.test.ts b/hub/src/overseer/converse.test.ts index c21547bac3..d2ac1dd3d5 100644 --- a/hub/src/overseer/converse.test.ts +++ b/hub/src/overseer/converse.test.ts @@ -640,6 +640,50 @@ describe('runOverseerConverse', () => { 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' diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index a92a1478ab..a6c916ac5a 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -115,10 +115,15 @@ 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 (a.itemId != null && b.itemId != null && a.itemId === b.itemId) return true + if (aItem != null && bItem != null && aItem === bItem) return true return false } diff --git a/hub/src/web/routes/overseer.ts b/hub/src/web/routes/overseer.ts index 34121c845e..c929e9bc61 100644 --- a/hub/src/web/routes/overseer.ts +++ b/hub/src/web/routes/overseer.ts @@ -18,12 +18,11 @@ import type { ActiveBrainSetting } from '../../store/settingsStore' import { applyFocusFromClientSession } from '@hapi/protocol' import type { OverseerEntity } from '../../sync/overseerEntity' -const FULL_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - /** - * Client relatedSessionId must resolve to one canonical session before it can - * authorize write prefixes. Ambiguous/unknown short prefixes are refused; - * unknown full UUIDs are kept (session may not be loaded yet). + * 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, @@ -31,10 +30,7 @@ function resolveClientRelatedSessionId( ): string | null { const raw = typeof relatedSessionId === 'string' ? relatedSessionId.trim() : '' if (!raw) return null - const canonical = overseer.resolveCanonicalSessionId(raw) - if (canonical) return canonical - if (FULL_SESSION_ID_RE.test(raw)) return raw.toLowerCase() - return null + return overseer.resolveCanonicalSessionId(raw) } const convoTurnBodySchema = z.object({ @@ -261,15 +257,18 @@ export function createOverseerRoutes(getSyncEngine: () => SyncEngine | null): Ho const messages = assembled.messages const lastOperator = [...messages].reverse().find((m) => m.role === 'operator')?.content ?? '' const durableFocus = settings.getConverseFocus(namespace) - const clientSessionSeed = resolveClientRelatedSessionId( - overseer, - parsed.data.relatedSessionId - ) - const priorFocus = applyFocusFromClientSession( - durableFocus, - clientSessionSeed, - Math.max(Date.now(), (durableFocus?.updatedAt ?? 0) + 1) - ) + 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, { diff --git a/shared/src/overseerConverseFocus.test.ts b/shared/src/overseerConverseFocus.test.ts index d2c3680a22..71367325c8 100644 --- a/shared/src/overseerConverseFocus.test.ts +++ b/shared/src/overseerConverseFocus.test.ts @@ -208,6 +208,15 @@ describe('hub-owned conversational focus (capability, not pattern matching)', () 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', () => { diff --git a/shared/src/overseerConverseFocus.ts b/shared/src/overseerConverseFocus.ts index b61013b08f..0ac71dc19a 100644 --- a/shared/src/overseerConverseFocus.ts +++ b/shared/src/overseerConverseFocus.ts @@ -257,9 +257,11 @@ export function applyFocusFromToolResolve( 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) ?? - (typeof args.sessionId === 'string' ? args.sessionId.trim() : null) + (previous?.itemId === itemId ? previous.sessionId : null) return { sessionId: sessionId || null, itemId, From 5d8f7940313270abf7f66b35ae2bf10dc208ee09 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:01:49 +0000 Subject: [PATCH 14/14] chore: retrigger Codex review on tip Meta: tip dbb5aac17 had CI green and 0 threads but no bot review of that commit (last Codex pass was 488612d20). Empty push to wake the reviewer. Co-authored-by: Cursor