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:
| Field | Type | Description |
|---|---|---|
systemPrompt | string? | Custom system prompt override |
title | string? | Initial session title |
userId | string? | User context |
orgId | string? | Organisation context |
hidden | boolean? | Exclude from session list (e.g. onboarding) |
onboarding | boolean? | 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:
| Type | Payload | Description |
|---|---|---|
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 Mode | Transport | How credentials are obtained |
|---|---|---|
SERVER_MEDIATED | STDIO / SSE | Fetches OAuth tokens from GET /api/v1/connectors/:id/credentials |
REMOTE_OAUTH | HTTP (streamable) | Loads tokens from userData/mcp-tokens/{instanceId}.json |
API_KEY | STDIO | Passes key directly as env var |
NONE | STDIO | No 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)
| Tool | Action |
|---|---|
create_task | POST /api/v1/tasks |
update_task | PATCH /api/v1/tasks/:id |
delete_task | DELETE /api/v1/tasks/:id |
list_tasks | GET /api/v1/tasks |
reprioritize_tasks | POST /api/v1/tasks/reprioritize |
buildWorkspaceDelegate(orgId)
| Tool | Action |
|---|---|
create_workspace | POST /api/v1/workspaces |
list_workspaces | GET /api/v1/workspaces |
set_active_workspace | Updates session context |
buildTeamDelegate(orgId)
| Tool | Action |
|---|---|
list_teams | GET /api/v1/teams |
list_team_members | GET /api/v1/teams/:id/members |
add_team_member | POST /api/v1/teams/:id/members |
remove_team_member | DELETE /api/v1/teams/:id/members/:userId |
buildArtifactDelegate(orgId)
| Tool | Action |
|---|---|
create_artifact | ArtifactManager.create() + emits artifacts:created |
update_artifact | ArtifactManager.update() |
list_artifacts | ArtifactManager.list() |
move_artifact | ArtifactManager.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:
| Type | Purpose |
|---|---|
next_actions | Suggested follow-up actions surfaced in the Hub |
heads_up | Warnings or important notices |
connector_required | Prompts user to add a specific MCP connector |
Silent Tools
Two tools are handled silently (no UI shown):
| Tool | Effect |
|---|---|
cosmo_use_model | Upgrades the active model for the remainder of the session |
cosmo_use_skill | Invokes 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
messagesis 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 bylastUsedAt). Comparing against the server's count — not the always- emptymessagesarray — is what stops both an every-launch push storm and a stale device truncating newer history. - Lazy hydration.
getMessages()fetches full bodies viaGET /chat-sessions/:idwhenever the local cache is incomplete (messages.length < messageCount), which also self-heals a sidebar count that doesn't match the visible messages. messageCountis derived frommessages.lengthon 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.