Cosmo Docs
Desktop App

ChatManager

LLM session management, streaming, tool execution, and memory

packages/desktop/src/main/chat-manager.ts manages all AI chat interactions in the desktop app. It owns session state, connects MCP tools, streams LLM responses, and persists conversation history.

Session Lifecycle

createSession(opts?)

Creates a new chat session. Options:

FieldTypeDescription
systemPromptstring?Custom system prompt override
titlestring?Initial session title
userIdstring?User context
orgIdstring?Organisation context
hiddenboolean?Exclude from session list (e.g. onboarding)
onboardingboolean?Mark as an onboarding session

Returns SessionInfo with id, title, createdAt, lastUsedAt, messageCount.

Sessions are persisted to userData/cosmo-chat-sessions.json.

sendMessage(sessionId, content, context?, model?)

Async generator that yields ChatEvent objects as the LLM streams its response.

Event types:

TypePayloadDescription
text{ text }Partial text chunk
thinking{ thinking }Extended thinking content (Opus)
tool_call{ toolName, toolCallId, toolArgs }LLM invoked a tool
tool_result{ toolName, result }Tool execution completed
tool_confirm{ toolName, toolCallId, toolArgs, confirmId, risk, explanation, tier }Tool awaiting user confirmation
title-updated{ title }Session title auto-generated
done{ usage, messageId, outcome? }Stream finished; includes token counts
error{ message, messageId }Error during generation

Both terminal events carry messageId — the id of the assistant message just persisted — so the renderer appends idempotently instead of minting its own.

Turn outcomes (COS-274)

A turn that does not end normally records a TurnOutcome on the assistant message it persists:

interface TurnOutcome {
  reason: 'error' | 'stopped' | 'declined'
  detail?: string              // user-facing reason
  promptMessageId?: string     // the user message to re-send on retry
  model?: string               // model the turn ran on
  createdArtifacts?: Array<{ id: string; workspaceId: string }>
}

Every exit that reached a session funnels through persistAssistantTurn, whose empty-message rule differs by outcome: a turn that completed with nothing to say is skipped, but a turn that ended badly is persisted even when empty. A failure that leaves no row is the original defect — the user is left with a prompt, no reply, and nothing for Retry to attach to.

The exceptions are the two failures that happen before a turn exists — Session not found, and a retry whose prompt has since been deleted. Those emit an error with no messageId, and the renderer marks them retryable: false so they render as a notice without a Retry button that could not work.

Because loadHistory runs only when getEngine builds an engine, a retry — which edits history on an already-cached engine — must call resyncEngineHistory after removing the failed attempt. Skipping it leaves the engine's conversationHistory ending in the dead turn, which also defeats engine.chat()'s duplicate-user guard and sends the prompt twice.

outcome is sent on the terminal event rather than inferred, because a stopped turn arrives as done: aborting only breaks the forwarding loop, so control still reaches the normal completion path.

stopGeneration(sessionId)

Aborts the AbortController tied to the active stream for a session, and records that controller in stoppedTurns (a WeakSet) so the persist path marks the message stopped. Only this method writes it — never the supersede-abort sendMessage performs on a rapid resend, which is not a turn the user stopped.

Keyed on the turn rather than the session on purpose: a session-keyed flag had to be cleared at the start of each turn, and Stop-then-immediately-send wiped it before the stopped turn read it. turnArtifacts is keyed the same way, for the same reason — session-keyed, a new send could hand a stopped turn the new turn's artifacts, so retrying the old one would soft-delete a live document.

Note the abort is checked only at the top of the forwarding loop and is not passed to engine.chat(), so the underlying LLM turn still runs to completion. Stop mutes forwarding; it does not cancel the request.

prepareRetry(sessionId, failedMessageId)

Resolves what re-running a failed turn should send: { content, model, promptMessageId }, or null when there is nothing sane to retry (message gone, completed normally, prompt since deleted). The content is the original user text byte-for-byte — attachments are inlined into it as <file> blocks, so re-sending it carries the files with no re-upload.

Callers pass the result back through sendMessage(..., { retryOf }), which reuses the existing prompt row instead of appending a copy, and removes the failed attempt — first soft-deleting any artifacts that attempt created, so the retry cannot produce a duplicate document.

generateTitle(sessionId, userMsg, assistantMsg)

Calls the LLM with a compact prompt to produce a short (≤6 words) session title. Fires title-updated event back to the renderer.

extractAndSaveMemory(sessionId)

Called at onboarding completion. Sends a structured extraction prompt to the LLM asking it to produce JSON with fields: name, company, role, useCase, painPoints, preferences. Stores the result via OnboardingManager.

MCP Engine Initialisation

Each session gets its own @cosmohq/core engine instance. On session creation, connectCloudConnectors(engine, orgId) is called to attach all of the org's active MCP connectors.

Auth Mode Routing

Auth ModeTransportHow credentials are obtained
SERVER_MEDIATEDSTDIO / SSEFetches OAuth tokens from GET /api/v1/connectors/:id/credentials
REMOTE_OAUTHHTTP (streamable)Loads tokens from userData/mcp-tokens/{instanceId}.json
API_KEYSTDIOPasses key directly as env var
NONESTDIONo credentials required

Tool Delegates

ChatManager wires AI tool calls to real app features via delegate factories. Each delegate implements ToolDelegate from @cosmohq/core.

buildTaskDelegate(orgId)

ToolAction
create_taskPOST /api/v1/tasks
update_taskPATCH /api/v1/tasks/:id
delete_taskDELETE /api/v1/tasks/:id
list_tasksGET /api/v1/tasks
reprioritize_tasksPOST /api/v1/tasks/reprioritize

buildWorkspaceDelegate(orgId)

ToolAction
create_workspacePOST /api/v1/workspaces
list_workspacesGET /api/v1/workspaces
set_active_workspaceUpdates session context

buildTeamDelegate(orgId)

ToolAction
list_teamsGET /api/v1/teams
list_team_membersGET /api/v1/teams/:id/members
add_team_memberPOST /api/v1/teams/:id/members
remove_team_memberDELETE /api/v1/teams/:id/members/:userId

buildArtifactDelegate(orgId)

ToolAction
create_artifactArtifactManager.create() + emits artifacts:created
update_artifactArtifactManager.update()
list_artifactsArtifactManager.list()
move_artifactArtifactManager.move()

buildFeedbackDelegate()

Exposes cosmo_feedback_submit which forwards structured survey responses to PostHog.

Tool Confirmation

Tools that could be destructive (shell commands, operations guarded by billing tiers) emit a tool_confirm event with a confirmId. Execution is paused until the renderer calls chat:confirm-tool with { confirmId, approved: boolean }.

Advisory Cards

After every assistant turn, ChatManager inspects the final message for structured advisory content and emits it as ChatEvent segments:

TypePurpose
next_actionsSuggested follow-up actions surfaced in the Hub
heads_upWarnings or important notices
connector_requiredPrompts user to add a specific MCP connector

Silent Tools

Two tools are handled silently (no UI shown):

ToolEffect
cosmo_use_modelUpgrades the active model for the remainder of the session
cosmo_use_skillInvokes a skill from the skill registry

Session Persistence

Sessions and messages are saved locally to:

userData/
└── cosmo-chat-sessions.json   ← Array of { id, title, messages, ... }

They also sync to the server (ChatSession table) so history survives logout, reinstall, and moving between devices. Onboarding conversations sync like normal chats; only hidden pre-onboarding placeholders stay local.

Sync & hydration rules

The list endpoint (GET /chat-sessions) is metadata-only — every row comes back with messages: [] but a truthful messageCount. Two consequences the reconciliation logic (refreshFromServer) and the server guard depend on:

  • Never blank or shrink real history. A push whose messages is shorter than the stored row is refused server-side (messageWriteFields) — it would be a stale device or an un-hydrated placeholder, and there is no legitimate flow that shortens a session. Shrinking writes are logged.
  • Heal only when local holds MORE than the server. On refresh, a local session is pushed up only when messages.length > serverMessageCount (server genuinely lost bodies) or when it is a strictly newer edit (last-writer-wins by lastUsedAt). Comparing against the server's count — not the always- empty messages array — is what stops both an every-launch push storm and a stale device truncating newer history.
  • Lazy hydration. getMessages() fetches full bodies via GET /chat-sessions/:id whenever the local cache is incomplete (messages.length < messageCount), which also self-heals a sidebar count that doesn't match the visible messages.
  • messageCount is derived from messages.length on write, so the count can never drift from the stored array.

resetEngines() disconnects all MCP clients and clears engine instances. clearAll() also deletes the session file.