From 4d176bec67de55174dade1840352c262335f7d18 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Thu, 29 Oct 2026 18:42:33 +0100 Subject: [PATCH 1/2] Add private chats that keep nothing A Private toggle on Home (or Shift+Cmd+N) starts a chat that is in no list, writes no transcript, memory, follow-up, notice or commit, and is wiped with its messages, uploads, run logs and Claude Code session once you leave it. Leftovers from a crash are wiped when Godmode starts. Runs keep only their cost so budgets stay right. --- .../components/chat/conversation-header.tsx | 99 ++++++++++------- .../src/components/chat/private-chat.tsx | 104 ++++++++++++++++++ .../src/components/layout/app-shell.tsx | 3 + .../src/components/layout/command-palette.tsx | 4 + .../components/layout/shortcuts-dialog.tsx | 1 + apps/desktop/src/lib/api.ts | 2 + .../src/pages/chat/chat-conversation.tsx | 5 +- apps/desktop/src/pages/chat/chat-home.tsx | 72 ++++++++---- packages/core/src/db/migrations.ts | 8 ++ packages/core/src/index.ts | 3 + packages/core/src/mcp/tools.ts | 4 +- packages/core/src/memory/dreaming.ts | 2 +- packages/core/src/runner/prompt.ts | 8 +- packages/core/src/runner/runner.ts | 19 +++- packages/core/src/server/routes/chat.ts | 8 +- packages/core/src/services/conversations.ts | 83 +++++++++++++- packages/core/src/services/runNotices.ts | 7 +- packages/core/test/conversations.test.ts | 69 +++++++++++- packages/shared/src/api.ts | 2 + packages/shared/src/models.ts | 2 + 20 files changed, 421 insertions(+), 84 deletions(-) create mode 100644 apps/desktop/src/components/chat/private-chat.tsx diff --git a/apps/desktop/src/components/chat/conversation-header.tsx b/apps/desktop/src/components/chat/conversation-header.tsx index 0bad3875..4f0c9b87 100644 --- a/apps/desktop/src/components/chat/conversation-header.tsx +++ b/apps/desktop/src/components/chat/conversation-header.tsx @@ -3,7 +3,7 @@ import { Link, useNavigate } from "react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Agent, Conversation, ConversationWithMessages } from "@godmode/shared"; import { EFFORT_LABELS } from "@godmode/shared"; -import { Archive, ArchiveRestore, AudioLines, ChevronRight, Cpu, Ellipsis, HeartPulse, Laptop, Moon, Pencil, Pin, PinOff, Share2, SquareKanban, Trash2, Plug, Workflow } from "lucide-react"; +import { Archive, ArchiveRestore, AudioLines, ChevronRight, Cpu, Ellipsis, HeartPulse, Laptop, Moon, Pencil, Pin, PinOff, Share2, SquareKanban, Trash2, Plug, Workflow, X } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -20,6 +20,7 @@ import type { AgentMood } from "@/components/chat/conversation-mood"; import { PLATFORMS } from "@/components/messaging/platform"; import { THIS_COMPUTER_INLINE } from "@/components/runners/runner-parts"; import { DeleteChatDialog, useArchiveChat } from "@/components/chat/chat-actions"; +import { PrivateChatBadge } from "@/components/chat/private-chat"; import { useEffectiveModel } from "@/components/chat/model-picker"; import { useModelLabel } from "@/components/runs/run-status"; import { api, errorMessage } from "@/lib/api"; @@ -183,6 +184,7 @@ export function ConversationHeader({ Model, effort and Ultracode set for this chat — in the model picker or with /model and /effort )} + {conversation.private && } {conversation.archived && ( {browserToggle} - - - - - {conversation.pinned ? "Unpin" : "Pin to sidebar"} - + {!conversation.private && ( + + + + + {conversation.pinned ? "Unpin" : "Pin to sidebar"} + + )} {onVoiceMode && ( @@ -220,33 +224,44 @@ export function ConversationHeader({ Voice mode )} - - - - - - setEditing(true)}> - Rename - - update.mutate({ pinned: !conversation.pinned })}> - {conversation.pinned ? : } {conversation.pinned ? "Unpin" : "Pin"} - - setArchived(conversation, !conversation.archived)}> - {conversation.archived ? : } {conversation.archived ? "Unarchive" : "Archive"} - - {move && ( - - {move.label ?? `Move to ${THIS_COMPUTER_INLINE}`} + {conversation.private ? ( + + + + + End and wipe this chat + + ) : ( + + + + + + setEditing(true)}> + Rename + + update.mutate({ pinned: !conversation.pinned })}> + {conversation.pinned ? : } {conversation.pinned ? "Unpin" : "Pin"} - )} - - setConfirmDelete(true)}> - Delete - - - + setArchived(conversation, !conversation.archived)}> + {conversation.archived ? : } {conversation.archived ? "Unarchive" : "Archive"} + + {move && ( + + {move.label ?? `Move to ${THIS_COMPUTER_INLINE}`} + + )} + + setConfirmDelete(true)}> + Delete + + + + )} void; className?: string }) { + return ( + + + + + + {value ? "Back to a normal chat" : "Start a private chat. Nothing is saved."} + + ⇧ + {modKey}N + + + + ); +} + +/** Next to the title of a private chat. */ +export function PrivateChatBadge() { + return ( + + + + Private + + + {PRIVATE_CHAT_HINT} + + ); +} + +/** Above the thread of a private chat. */ +export function PrivateChatNote() { + return ( +
+ + + Private chat. {PRIVATE_CHAT_HINT} + +
+ ); +} + +const pending = new Map>(); + +function discard(id: string) { + void api.conversations.discard(id).catch(() => { + // Godmode wipes what is left of it when it starts again. + }); +} + +/** + * A private chat lives while it is open: leaving it (another chat, Home, closing the window) wipes it. A remount of the + * same chat right after (React's strict mode, a layout switch) keeps it. + */ +export function usePrivateChatLifetime(conversationId: string, isPrivate: boolean) { + const qc = useQueryClient(); + useEffect(() => { + if (!isPrivate) return; + clearTimeout(pending.get(conversationId)); + pending.delete(conversationId); + const onHide = () => discard(conversationId); + window.addEventListener("pagehide", onHide); + return () => { + window.removeEventListener("pagehide", onHide); + pending.set( + conversationId, + setTimeout(() => { + pending.delete(conversationId); + discard(conversationId); + qc.removeQueries({ queryKey: qk.conversation(conversationId) }); + }, 400), + ); + }; + }, [conversationId, isPrivate, qc]); +} diff --git a/apps/desktop/src/components/layout/app-shell.tsx b/apps/desktop/src/components/layout/app-shell.tsx index c48f6662..4267e377 100644 --- a/apps/desktop/src/components/layout/app-shell.tsx +++ b/apps/desktop/src/components/layout/app-shell.tsx @@ -169,6 +169,9 @@ export function AppShell({ children }: { children: ReactNode }) { } else if (mod && e.key.toLowerCase() === "n" && !e.shiftKey) { e.preventDefault(); navigate("/"); + } else if (mod && e.shiftKey && !e.altKey && letterOf(e) === "n") { + e.preventDefault(); + navigate("/?private=1"); } else if (mod && e.key === ",") { e.preventDefault(); navigate("/settings/general"); diff --git a/apps/desktop/src/components/layout/command-palette.tsx b/apps/desktop/src/components/layout/command-palette.tsx index f7ff92ab..5bdeb20b 100644 --- a/apps/desktop/src/components/layout/command-palette.tsx +++ b/apps/desktop/src/components/layout/command-palette.tsx @@ -17,6 +17,7 @@ import { DatabaseBackup, Fingerprint, Gauge, + Ghost, Globe, HeartPulse, Inbox, @@ -263,6 +264,9 @@ export function CommandPalette() { go("/")}> New chat {modKey}N + go("/?private=1")}> + New private chat ⇧{modKey}N + go("/agents/new")}> Create agent diff --git a/apps/desktop/src/components/layout/shortcuts-dialog.tsx b/apps/desktop/src/components/layout/shortcuts-dialog.tsx index 80223831..0dbd9d64 100644 --- a/apps/desktop/src/components/layout/shortcuts-dialog.tsx +++ b/apps/desktop/src/components/layout/shortcuts-dialog.tsx @@ -24,6 +24,7 @@ const GROUPS: { title: string; rows: { keys: ReactNode[]; what: string }[] }[] = rows: [ { keys: [`${modKey}K`], what: "Search chats, tickets, automations, settings — and what waits for you" }, { keys: [`${modKey}N`], what: "New chat" }, + { keys: ["⇧", `${modKey}N`], what: "New private chat (nothing is saved)" }, { keys: [`${modKey}B`], what: "Show or hide the sidebar" }, { keys: [`${modKey}1–9`], what: "Switch to a workspace (in A–Z order)" }, { keys: [`${modKey}0`], what: "All workspaces" }, diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index feb54581..d336f989 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -663,6 +663,8 @@ export const api = { create: (input: { agentId: string; title?: string; workspaceId?: string | null; projectId?: string | null }) => post("/api/conversations", input), update: (id: string, input: ConversationPatch) => patch(`/api/conversations/${id}`, input), delete: (id: string) => del<{ ok: true }>(`/api/conversations/${id}`), + /** Wipe a private chat; goes out even while the page closes. */ + discard: (id: string) => request<{ ok: true }>("DELETE", `/api/conversations/${id}`, undefined, { keepalive: true }), /** A chat on a runner moves to this computer, with its Claude session; the runner drops its copy. */ moveHere: (id: string) => post(`/api/conversations/${id}/move-here`), /** To another computer of the group or a runner (`to`; null = the primary / this computer). `force`: also while it works. */ diff --git a/apps/desktop/src/pages/chat/chat-conversation.tsx b/apps/desktop/src/pages/chat/chat-conversation.tsx index d81fb0fd..1271b295 100644 --- a/apps/desktop/src/pages/chat/chat-conversation.tsx +++ b/apps/desktop/src/pages/chat/chat-conversation.tsx @@ -19,6 +19,7 @@ import { Composer, type ComposerHandle } from "@/components/chat/composer"; import { QueueTray, type QueueTrayHandle } from "@/components/chat/queue-tray"; import { useArchiveChat } from "@/components/chat/chat-actions"; import { ConversationHeader } from "@/components/chat/conversation-header"; +import { PrivateChatNote, usePrivateChatLifetime } from "@/components/chat/private-chat"; import { useConversationMood } from "@/components/chat/conversation-mood"; import { FollowupBar } from "@/components/chat/followup"; import { HumanTaskBar } from "@/components/human-tasks/human-task-chat"; @@ -81,6 +82,7 @@ function ConversationView({ conversationId }: { conversationId: string }) { const markVoiceRun = useVoiceSession((s) => s.markVoiceRun); const composerRef = useRef(null); useViewing(conversationId); + usePrivateChatLifetime(conversationId, !!conv?.private); const queueRef = useRef(null); const mountedAt = useRef(Date.now()); // A profile picked mid-run applies from the next message: keep showing the browser the running agent drives. @@ -529,6 +531,7 @@ function ConversationView({ conversationId }: { conversationId: string }) { } /> + {conv.private && } setParams(on ? { private: "1" } : {}, { replace: true }); const { data: boot } = useBootstrap(); const { data: agents = [], isLoading: agentsLoading } = useAllAgents(); const markVoiceRun = useVoiceSession((s) => s.markVoiceRun); @@ -161,7 +165,8 @@ export default function ChatHome() { useEffect(() => { if (runnerGone) setRunnerId(hereId); }, [runnerGone, setRunnerId, hereId]); - const onRunner = runnerId !== hereId && !runnerGone; + // A private chat stays on this computer. + const onRunner = !privateChat && runnerId !== hereId && !runnerGone; // A folder, a shared screen, a VM and the phones belong to this computer: a chat elsewhere starts without them. const place = (): Pick => onRunner @@ -183,7 +188,7 @@ export default function ChatHome() { resetSetup(); if (agent) setAgentId(agent); if (prompt) requestAnimationFrame(() => composerRef.current?.setText(prompt)); - setParams({}, { replace: true }); + setParams(params.get("private") === "1" ? { private: "1" } : {}, { replace: true }); }, [params, setParams]); const start = useMutation({ @@ -212,6 +217,10 @@ export default function ChatHome() { composerRef.current?.addFiles(files)} disabled={voiceMode} className="relative min-h-full"> + + + +
@@ -225,26 +234,43 @@ export default function ChatHome() { )} - {selected && ( - - + {privateChat ? ( + + + + +

+ Private chat + Just between us. +

+

{PRIVATE_CHAT_HINT}

+ ) : ( + <> + {selected && ( + + + + )} + + {greeting(now)} + {userName ? `, ${userName}` : ""}. + What should we get done? + + )} - - {greeting(now)} - {userName ? `, ${userName}` : ""}. - What should we get done? - @@ -255,7 +281,7 @@ export default function ChatHome() { options={ !agentsLoading && ( <> - + {!privateChat && } {!onRunner && ( @@ -316,7 +343,7 @@ export default function ChatHome() { - {easy ? ( + {privateChat ? null : easy ? (

Try something like

@@ -366,12 +393,14 @@ export default function ChatHome() {
-
- - - - -
+ {!privateChat && ( +
+ + + + +
+ )} diff --git a/packages/core/src/db/migrations.ts b/packages/core/src/db/migrations.ts index 442a1369..878f0a6f 100644 --- a/packages/core/src/db/migrations.ts +++ b/packages/core/src/db/migrations.ts @@ -1736,4 +1736,12 @@ CREATE TABLE IF NOT EXISTS self_repair_tickets ( name: "feature_switches", sql: FEATURE_SWITCHES_SQL, }, + { + id: 106, + name: "private_chats", + sql: /* sql */ ` +-- Private chats keep nothing: no list entry, transcript, memory or commit, and they are wiped when the human leaves them. +ALTER TABLE conversations ADD COLUMN private INTEGER NOT NULL DEFAULT 0; +`, + }, ]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d131e849..d82d03da 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -28,6 +28,7 @@ import { applyRuntimeSettings, onSettingsApplied } from "./services/runtime"; import * as vault from "./vault/vault"; import { ensureDefaultAgent } from "./agents/service"; import { recoverInterruptedRuns, shutdownRunner } from "./runner/runner"; +import { discardPrivateChats } from "./services/conversations"; import { interruptedWork, resumeInterruptedWork, startOutageRecovery, stopOutageRecovery } from "./services/resume"; import { clearRunMods, startModUpdates, stopModUpdates } from "./mods/service"; import { reloadSchedules, startScheduler } from "./scheduler/scheduler"; @@ -191,6 +192,8 @@ async function serve(values: Record, role?: CoreConfig["role"]) if (!runner) adoptStoredSolverKey(); await ensureDefaultAgent(); recoverInterruptedRuns(); + // Nobody is in a private chat across a restart: it goes before anything could continue it. + await discardPrivateChats().catch((err) => log.warn("could not discard private chats", err)); // What the stop cut off, before the scheduler, follow-ups or automations start anything in those chats. const interrupted = runner ? null : interruptedWork(); clearRunMods(); diff --git a/packages/core/src/mcp/tools.ts b/packages/core/src/mcp/tools.ts index dd7aae80..b3440d4a 100644 --- a/packages/core/src/mcp/tools.ts +++ b/packages/core/src/mcp/tools.ts @@ -139,7 +139,7 @@ import { all, get } from "../db"; import { config } from "../config"; import { link } from "../remote/runners"; import { loginFillScope } from "../browser/fill"; -import { chatFillOnly, createConversation, maskSecretInChat, sendMessage } from "../services/conversations"; +import { chatFillOnly, createConversation, isPrivateChat, maskSecretInChat, sendMessage } from "../services/conversations"; import { assignVm, createVm, getVm, listVms, sharedDirOf, startVm, stopVm, suspendVm, vmInUse, vmOfRun, vmStatus } from "../vm/service"; import { resolveVmId } from "../vm/assignments"; import { androidStatus, assignDevice, freshDevices } from "../android/service"; @@ -3031,7 +3031,7 @@ Make it look like a polished 2026 product: clean layout, real hierarchy, readabl // Runs held because a monthly budget is used up: only the human raises it or lets them run. for (const r of runs) if (r.pause?.reason === "budget") waiting.set(r.id, "a monthly budget (the human decides)"); return json( - runs.map((r) => ({ + runs.map((r) => isPrivateChat(r.conversationId) ? { id: r.id, agent: names.get(r.agentId) ?? r.agentId, agentId: r.agentId, status: r.status, trigger: r.trigger, createdAt: r.createdAt, private: true } : ({ id: r.id, agent: names.get(r.agentId) ?? r.agentId, agentId: r.agentId, diff --git a/packages/core/src/memory/dreaming.ts b/packages/core/src/memory/dreaming.ts index ccedfc06..ac53e24d 100644 --- a/packages/core/src/memory/dreaming.ts +++ b/packages/core/src/memory/dreaming.ts @@ -273,7 +273,7 @@ function noteMemoryLeft(dreamId: string, repoPath: string) { /** Finished exchanges of the agent after the cursor — of conversations that still exist (a deleted chat is forgotten). */ const ACTIVITY_WHERE = `r.agent_id = ? AND r.trigger NOT IN (${NOT_ACTIVITY.map(() => "?").join(", ")}) AND r.status IN ('succeeded', 'failed') AND r.finished_at IS NOT NULL AND (? IS NULL OR r.finished_at > ?) - AND EXISTS (SELECT 1 FROM conversations c0 WHERE c0.id = r.conversation_id AND c0.origin != 'dream')`; + AND EXISTS (SELECT 1 FROM conversations c0 WHERE c0.id = r.conversation_id AND c0.origin != 'dream' AND c0.private = 0)`; export function pendingActivity(agentId: string): DreamOverview["pending"] { const since = dreamCursor(agentId); diff --git a/packages/core/src/runner/prompt.ts b/packages/core/src/runner/prompt.ts index fbe2fdbe..e7f0dd76 100644 --- a/packages/core/src/runner/prompt.ts +++ b/packages/core/src/runner/prompt.ts @@ -52,6 +52,8 @@ export interface PromptContext { memory?: PromptMemory | null; /** The run may schedule follow-ups (followup_schedule). */ followups?: boolean; + /** The human opened a private chat: nothing of it may be kept. */ + privateChat?: boolean; /** The run can build artifacts (artifact_create): small web apps shown in Godmode. */ artifacts?: boolean; /** Phone calls are set up: the run may place them (`owner`: and call the human). */ @@ -418,7 +420,11 @@ ${human} can write to you while you are working. Such a message reaches you betw const reflect = settings.memory.reflectAfterRun; const memoryFile = folder ? join(repo, "MEMORY.md") : "MEMORY.md"; const budget = memoryBudget(settings.memory.dreaming.maxMemoryChars); - out.push(`## Memory + const privateNote = ctx.privateChat + ? `## Private chat +${human} opened this as a private chat: nothing of it is kept, and Godmode wipes it once ${human} leaves. Don't write anything from it into \`${memoryFile}\`, \`memory/\` or other notes, don't commit, and don't schedule follow-ups or hand it to other agents, unless ${human} explicitly asks for exactly that in this chat. This overrides what is said below about keeping your memory up to date: read it, never update it here.${ctx.memory ? `\n\n${memoryBlock(ctx.memory, memoryFile, budget.target)}` : ""}` + : null; + out.push(privateNote ?? `## Memory ${reflect ? "At the end of every task" : "When you learn something durable"}, update \`${memoryFile}\` with learnings worth keeping: facts and preferences about ${human}, how specific websites and accounts work, recurring procedures, and open follow-ups. Keep it concise and organized (edit or remove stale entries instead of appending duplicates). ${settings.memory.injectMemory ? "It is loaded into the start of every chat, so keep" : "Keep"} it under ${budget.target.toLocaleString("en-US")} characters: one short line per fact, long details (playbooks, lists, the state of a project) in \`memory/.md\` with a link from it, and no log of finished work (your conversations and git keep that). Never store passwords, 2FA codes, tokens or other secrets in any file. Godmode commits your repository after each run.${settings.memory.dreaming.enabled ? " While you are idle, Godmode also lets you \"dream\": you review your recent conversations and consolidate this memory." : ""}${ctx.memory ? `\n\n${memoryBlock(ctx.memory, memoryFile, budget.target)}` : ""}`); out.push(`## Safety diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts index 6a17db4e..14a64279 100644 --- a/packages/core/src/runner/runner.ts +++ b/packages/core/src/runner/runner.ts @@ -401,6 +401,8 @@ interface Job { answersSettled?: boolean; /** The human stopped it (from a chat, the board or a chat platform), not Godmode. */ stoppedByHuman?: boolean; + /** Its chat is private: it leaves no transcript, memory, follow-up or commit behind. */ + private?: boolean; /** Godmode ended it because a `/goal` kept it going with nothing to do: it succeeded. */ goalPaused?: boolean; /** Resolves ids in tool input for the activity label (memoized per run). */ @@ -1981,11 +1983,13 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise( - "SELECT claude_session_id, claude_session_cost_usd, working_directory, model, effort, ultracode, instructions, instructions_digest, memory_digest, secret_access FROM conversations WHERE id = ?", + "SELECT claude_session_id, claude_session_cost_usd, working_directory, model, effort, ultracode, instructions, instructions_digest, memory_digest, secret_access, private FROM conversations WHERE id = ?", job.conversationId, ); if (!conv) return { status: "cancelled", error: "Conversation was deleted" }; + job.private = conv.private === 1; const dreaming = job.trigger === "dream"; // A dream always works in the agent's repository, on its memory files. const folder = dreaming ? null : (conv.working_directory ?? agent.workingDirectory); @@ -2216,7 +2220,8 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise typeof a === "string" && a.length > 0); const env = buildEnv(agent, !!folder || sources.length > 0, toolKeysInEnv); + // Claude Code's own auto memory would keep what a private chat said. + if (job.private) env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1"; // Without it a headless Claude Code keeps a mod's failures to its debug log: a hook that throws, a module that // doesn't load. With it they reach the chat as notes from the mod. It also watches the folders — the run's own copies. if (mods?.dirs.length) env.CLAUDE_CODE_PLUGIN_DIR_WATCH = "1"; @@ -2799,7 +2806,7 @@ async function finalize(job: Job, outcome: Outcome, agent: Agent | null, started }); safely("update conversation", () => saveConversation(job, agent, outcome.status === "succeeded", ts)); // Before run.finished goes out: whoever handles the end of the run (the task board) sees what waits for the human. - if (wasRunning && agent && outcome.status === "succeeded" && job.trigger !== "check" && job.trigger !== "dream") { + if (wasRunning && agent && outcome.status === "succeeded" && job.trigger !== "check" && job.trigger !== "dream" && !job.private) { safely("note an open decision", () => noteOpenDecision({ agentId: job.agentId, conversationId: job.conversationId, runId: job.runId, workspaceId: chatWorkspaceId(job.conversationId) ?? agent.workspaceId, attended: attendedBy(job) }, text), ); @@ -2835,7 +2842,7 @@ async function finalize(job: Job, outcome: Outcome, agent: Agent | null, started if (convAlive) emitConversationUpdated(job.conversationId); // Condition checks and dreams keep no transcript: checks only report a result, a dream's record is the dream itself. - if (wasRunning && finished && convAlive && job.trigger !== "check" && job.trigger !== "dream") { + if (wasRunning && finished && convAlive && job.trigger !== "check" && job.trigger !== "dream" && !job.private) { const done = finished; safely("append transcript", () => { const users = [job.userMessageId, ...job.alsoAnswers].flatMap((id) => (id ? [getMessage(id)] : [])); @@ -2858,7 +2865,7 @@ async function finalize(job: Job, outcome: Outcome, agent: Agent | null, started // Condition checks change nothing worth a commit; the task run that follows commits as usual. Dreams commit their // memory changes themselves (memory/dreaming.ts). - if (getSettings().memory.autoCommit && job.trigger !== "check" && job.trigger !== "dream") { + if (getSettings().memory.autoCommit && job.trigger !== "check" && job.trigger !== "dream" && !job.private) { const title = get<{ title: string }>("SELECT title FROM conversations WHERE id = ?", job.conversationId)?.title ?? job.trigger; const message = `Run ${job.runId.slice(-6)}: ${title}`; commitAgentRepo(job.agentId, message).catch((err) => log.warn(`auto-commit for agent ${job.agentId} failed`, err)); diff --git a/packages/core/src/server/routes/chat.ts b/packages/core/src/server/routes/chat.ts index 080fed7c..104ac8c6 100644 --- a/packages/core/src/server/routes/chat.ts +++ b/packages/core/src/server/routes/chat.ts @@ -4,6 +4,7 @@ import { EFFORT_OPTIONS, MAX_INSTRUCTIONS_LENGTH, RUN_STOPPED_BY_USER, isModelId import { createConversation, deleteConversation, + discardPrivateChat, getConversation, getConversationSummary, listConversations, @@ -23,7 +24,7 @@ import { replySettles } from "../../services/humanTasks"; import { continueConversation, pauseConversation, setAutoContinue } from "../../services/pauses"; import { cancelRun, findRunLog, getRun, listRuns, untilAsked } from "../../runner/runner"; import { cancelFollowup, listFollowups, rescheduleFollowup, runFollowupNow } from "../../services/followups"; -import { conflict, notFound } from "../../util"; +import { badRequest, conflict, notFound } from "../../util"; import { getAgent } from "../../agents/service"; import { listAttention } from "../../services/attention"; import { awaySummary } from "../../services/away"; @@ -179,7 +180,8 @@ export function registerChatRoutes(app: Hono): void { }); app.delete("/api/conversations/:id", async (c) => { - await deleteConversation(c.req.param("id")); + const id = c.req.param("id"); + if (!(await discardPrivateChat(id))) await deleteConversation(id); return c.json({ ok: true as const }); }); @@ -262,9 +264,11 @@ export function registerChatRoutes(app: Hono): void { instructions, /** Work on this runner (another computer) instead of this one. */ runnerId: z.string().trim().min(1).max(100).nullable().optional(), + private: z.boolean().optional(), ...modelChoice, }), ); + if (input.private && input.runnerId && input.runnerId !== selfDeviceId()) throw badRequest("A private chat stays on this computer"); // A secondary names itself by its id in the primary's list: a chat for it starts here. if (input.runnerId && input.runnerId === selfDeviceId()) input.runnerId = null; if (input.runnerId) return c.json(await startRemoteChat(input.runnerId, input), 201); diff --git a/packages/core/src/services/conversations.ts b/packages/core/src/services/conversations.ts index e7e604b1..21b8895c 100644 --- a/packages/core/src/services/conversations.ts +++ b/packages/core/src/services/conversations.ts @@ -3,7 +3,7 @@ * A conversation belongs to one agent; every user turn starts a run (runner/runner.ts) that streams * into one assistant message. A human-readable transcript is kept in the agent repo. */ -import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { basename, extname, join } from "node:path"; import type { Agent, @@ -44,6 +44,7 @@ import { audit } from "./audit"; import { getSettings } from "./settings"; import { clearQueue, listQueue } from "./messageQueue"; import { requireLicense } from "../license/license"; +import { claudeConfigDir } from "./claudeUpdate"; import { continueConversation, pauseOf, PAUSE_QUESTION_JOIN, PAUSE_QUESTION_SQL, toPause, type PauseQuestionCols } from "./pauses"; const log = logger("chat"); @@ -78,6 +79,7 @@ interface ConversationRow extends PauseQuestionCols { context_window: number | null; pinned: number; archived: number; + private: number; last_message_at: string | null; created_at: string; updated_at: string; @@ -168,6 +170,7 @@ function toConversation(r: ConversationRow): Conversation { runnerToolsId: r.runner_tools_id ?? null, pinned: bool(r.pinned), archived: bool(r.archived), + private: bool(r.private), lastMessageAt: r.last_message_at, createdAt: r.created_at, updatedAt: r.updated_at, @@ -329,6 +332,7 @@ export function createConversation( instructions?: string; /** No raw secrets in this chat, whatever its agent may read (see `chatFillOnly`). */ fillOnly?: boolean; + private?: boolean; } & ModelChoice, ): Conversation { const agent = getAgent(input.agentId); // 404 if the agent doesn't exist @@ -361,6 +365,7 @@ export function createConversation( instructions: input.instructions?.trim() ?? "", pinned: 0, archived: 0, + private: int(!!input.private)!, last_message_at: null, created_at: ts, updated_at: ts, @@ -484,7 +489,7 @@ function imageMime(data: Uint8Array): string { export function listConversations( opts: { agentId?: string; workspaceId?: string; projectId?: string; search?: string; limit?: number; archived?: boolean; ongoing?: boolean } = {}, ): Conversation[] { - const where: string[] = ["c.archived = ?"]; + const where: string[] = ["c.private = 0", "c.archived = ?"]; const params: (string | number)[] = [opts.archived ? 1 : 0]; if (opts.ongoing) where.push(`(${ONGOING_SQL})`); if (opts.agentId) { @@ -660,6 +665,76 @@ export async function deleteConversation(id: string): Promise { if (parseDeviceIds(row.android_device_ids).length) bus.changed("android"); } +function readUpTo(path: string): string | null { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } +} + +export function isPrivateChat(id: string): boolean { + return bool(get<{ private: number }>("SELECT private FROM conversations WHERE id = ?", id)?.private); +} + +/** Claude Code's own record of a session: its JSONL and the folder of its subagents and tool output, in any project. */ +function claudeSessionPaths(sessionId: string): string[] { + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/.test(sessionId)) return []; + const root = join(claudeConfigDir(), "projects"); + let dirs: string[]; + try { + dirs = readdirSync(root); + } catch { + return []; + } + return dirs.flatMap((dir) => [join(root, dir, `${sessionId}.jsonl`), join(root, dir, sessionId)].filter((p) => existsSync(p))); +} + +/** + * Wipe a private chat: its messages, uploads, run logs and Claude Code session go, and its runs keep only what they + * cost (budgets count it). Other chats are left alone. + */ +export async function discardPrivateChat(id: string): Promise { + const row = conversationRow(id); + if (!row?.private) return false; + let agent: Agent | null = null; + try { + agent = getAgent(row.agent_id); + } catch { + // Its agent is gone already. + } + const uploads = all<{ attachments: string }>("SELECT attachments FROM messages WHERE conversation_id = ?", id).flatMap((m) => + parseJson(m.attachments, []).map((a) => a.path), + ); + await deleteConversation(id); + const logs = listRuns({ conversationId: id, limit: 500 }).flatMap((r) => findRunLog(r) ?? []); + // A chat may have gone through more than one session (a fresh one after compaction): each run log names its own. + const sessions = new Set(row.claude_session_id ? [row.claude_session_id] : []); + for (const path of logs) for (const m of (readUpTo(path) ?? "").matchAll(/"session_id":"([A-Za-z0-9_-]+)"/g)) sessions.add(m[1]!); + const files = [ + ...logs, + ...(agent ? uploads.filter((p) => p.startsWith("workspace/uploads/")).map((p) => join(agent.repoPath, p)) : []), + ...[...sessions].flatMap(claudeSessionPaths), + ]; + for (const file of new Set(files)) { + try { + rmSync(file, { recursive: true, force: true }); + } catch (err) { + log.warn(`could not remove ${basename(file)} of private chat ${id}`, err); + } + } + sql("UPDATE runs SET prompt = 'Private chat', result = NULL, error = NULL, note = NULL WHERE conversation_id = ?", id); + bus.changed("runs"); + return true; +} + +/** Private chats left over from before a restart or crash: nobody is in them anymore. */ +export async function discardPrivateChats(): Promise { + const ids = all<{ id: string }>("SELECT id FROM conversations WHERE private = 1").map((r) => r.id); + for (const id of ids) await discardPrivateChat(id).catch((err) => log.warn(`could not discard private chat ${id}`, err)); + return ids.length; +} + /* ------------------------------------------------------------------ */ /* Messages */ /* ------------------------------------------------------------------ */ @@ -945,6 +1020,7 @@ export async function startChat( /** Android phones for this chat, in addition to the agent's. */ androidDeviceIds?: string[]; instructions?: string; + private?: boolean; } & ModelChoice, ): Promise { requireLicense(); @@ -970,6 +1046,7 @@ export async function startChat( model: input.model, effort: input.effort, ultracode: input.ultracode, + private: input.private, }); if (input.computerTarget) { update("conversations", conversation.id, { computer_target: JSON.stringify(parseComputerTarget(input.computerTarget)) }); @@ -1025,7 +1102,7 @@ function speaker(m?: Pick, runTrigger?: RunTrigger): string { /** Append one finished exchange (user turn + assistant turn) to the human-readable transcript. */ export function appendTranscript(conversationId: string, finishedRun: Run, userMessages: Message[], assistant: Message | null) { const row = conversationRow(conversationId); - if (!row) return; + if (!row || row.private) return; const agent = getAgent(row.agent_id); const path = transcriptPath(agent, conversationId); mkdirSync(join(agent.repoPath, "conversations"), { recursive: true }); diff --git a/packages/core/src/services/runNotices.ts b/packages/core/src/services/runNotices.ts index e0e2870d..84e30c5c 100644 --- a/packages/core/src/services/runNotices.ts +++ b/packages/core/src/services/runNotices.ts @@ -28,11 +28,12 @@ const shorten = (s: string, max = 160) => (s.length > max ? `${s.slice(0, max - function afterRun(r: Run): void { if (r.status !== "succeeded" && r.status !== "failed") return; - const conv = get<{ id: string; title: string; origin: string; archived: number; agent: string | null }>( - "SELECT c.id, c.title, c.origin, c.archived, a.name AS agent FROM conversations c LEFT JOIN agents a ON a.id = c.agent_id WHERE c.id = ?", + const conv = get<{ id: string; title: string; origin: string; archived: number; private: number; agent: string | null }>( + "SELECT c.id, c.title, c.origin, c.archived, c.private, a.name AS agent FROM conversations c LEFT JOIN agents a ON a.id = c.agent_id WHERE c.id = ?", r.conversationId, ); - if (!conv) return; + // A private chat leaves no unread mark or notification behind: its text would outlive it there. + if (!conv || conv.private) return; const viewed = isConversationViewed(conv.id); const agent = conv.agent ?? "The agent"; diff --git a/packages/core/test/conversations.test.ts b/packages/core/test/conversations.test.ts index fac0172a..6392d9f8 100644 --- a/packages/core/test/conversations.test.ts +++ b/packages/core/test/conversations.test.ts @@ -1,23 +1,28 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { Agent, Conversation, ConversationWithMessages, MessageBlock, MessagePage, Run, StartChatResult } from "@godmode/shared"; -import { makeAgent, setupEnv, until, type TestEnv } from "./fixtures/runner-harness"; +import { invocations, makeAgent, setupEnv, until, type TestEnv } from "./fixtures/runner-harness"; import { run as sql } from "../src/db"; import { DEFAULT_CONVERSATION_TITLE, addMessage, + conversationExists, createConversation, + discardPrivateChat, + discardPrivateChats, getConversation, listConversations, listMessagePage, safeFileName, sendMessage, setConversationState, + startChat, titleFromContent, updateConversation, } from "../src/services/conversations"; import { cancelFollowup, scheduleFollowup } from "../src/services/followups"; -import { cancelRun, getRun, waitForRun } from "../src/runner/runner"; +import { cancelRun, findRunLog, getRun, waitForRun } from "../src/runner/runner"; import { ensureDefaultAgent } from "../src/agents/service"; import { createWorkspace } from "../src/services/workspaces"; import { getAccessToken } from "../src/server/auth"; @@ -364,3 +369,63 @@ describe("plainPreview", () => { expect(out).not.toMatch(/\*\*|`|\]\(|const x/); }); }); + +describe("private chats", () => { + test("stay out of every list, keep no transcript and are wiped with their session, uploads and run logs", async () => { + const claudeDir = join(env.dataDir, "claude-config"); + const before = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + try { + const chat = await startChat({ + agentId: agent.id, + content: "Something just between us", + attachments: [{ name: "secret.txt", mime: "text/plain", data: Buffer.from("hush").toString("base64") }], + private: true, + }); + expect(chat.conversation.private).toBe(true); + await waitForRun(chat.run.id, 20_000); + const conv = getConversation(chat.conversation.id); + expect(conv.private).toBe(true); + expect(conv.messages.length).toBe(2); + expect(listConversations({ agentId: agent.id }).some((c) => c.id === conv.id)).toBe(false); + expect(listConversations({ search: "between us" })).toEqual([]); + expect(existsSync(join(agent.repoPath, "conversations", `${conv.id}.md`))).toBe(false); + + const prompt = invocations(env).findLast((i) => i.prompt.includes("Something just between us"))!; + expect(prompt.args.join("\n")).toContain("## Private chat"); + expect(prompt.args.join("\n")).not.toContain("update `MEMORY.md` with learnings"); + + const run = getRun(chat.run.id); + const log = findRunLog(run)!; + expect(existsSync(log)).toBe(true); + const upload = join(agent.repoPath, conv.messages[0]!.attachments[0]!.path); + expect(existsSync(upload)).toBe(true); + const sessionId = getConversation(conv.id).claudeSessionId!; + const session = join(claudeDir, "projects", "-some-project", `${sessionId}.jsonl`); + mkdirSync(join(session, ".."), { recursive: true }); + writeFileSync(session, "{}\n"); + + expect(await discardPrivateChat(conv.id)).toBe(true); + expect(conversationExists(conv.id)).toBe(false); + expect(existsSync(log)).toBe(false); + expect(existsSync(upload)).toBe(false); + expect(existsSync(session)).toBe(false); + const wiped = getRun(run.id); + expect(wiped.prompt).toBe("Private chat"); + expect(wiped.result).toBeNull(); + } finally { + if (before === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = before; + } + }); + + test("a normal chat is not discarded, and leftovers go at startup", async () => { + const normal = createConversation({ agentId: agent.id, title: "Keep me" }); + expect(await discardPrivateChat(normal.id)).toBe(false); + expect(conversationExists(normal.id)).toBe(true); + const left = createConversation({ agentId: agent.id, private: true }); + expect(await discardPrivateChats()).toBeGreaterThanOrEqual(1); + expect(conversationExists(left.id)).toBe(false); + expect(conversationExists(normal.id)).toBe(true); + }); +}); diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index d77f8c63..b754afbe 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -393,6 +393,8 @@ export interface StartChatInput { instructions?: string; /** Work on this runner instead of this computer. */ runnerId?: ID | null; + /** A private chat: nothing of it is kept (see Conversation.private). */ + private?: boolean; } /** Move a chat's follow-up to another time. */ diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index 7d0608b7..3c023935 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -417,6 +417,8 @@ export interface Conversation { runnerToolsId: ID | null; pinned: boolean; archived: boolean; + /** A private chat keeps nothing: it is in no list, writes no transcript or memory, and is wiped once the human leaves it. */ + private?: boolean; lastMessageAt: ISODate | null; createdAt: ISODate; updatedAt: ISODate; From 9377cd8e1cfee5530b7bdbdf00f7d6f8659b34ee Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Thu, 29 Oct 2026 18:53:17 +0100 Subject: [PATCH 2/2] Expect the private flag in the chat the API returns --- packages/core/test/remote-mirror.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/test/remote-mirror.test.ts b/packages/core/test/remote-mirror.test.ts index a70e943a..803477a9 100644 --- a/packages/core/test/remote-mirror.test.ts +++ b/packages/core/test/remote-mirror.test.ts @@ -558,6 +558,7 @@ describe("the chat the API returns", () => { runnerToolsId: null, pinned: false, archived: false, + private: false, lastMessageAt: null, createdAt: local.createdAt, updatedAt: local.updatedAt,