/** * `dench chat ` — CLI surface for browsing and managing * past chat threads in the user's workspace. * * Mirrors the `dench crm` pattern: a thin command dispatcher on top of * Convex public queries/mutations (`functions/chat:listPastChats`, * `searchPastChats`, `readPastChat`, `renameThreads`, `deleteThreads`) * which themselves enforce the "own + shared in this org" visibility * rule (`requireThreadAccess`). * * Auth: every call forwards a single bearer token via `sessionToken` * (either a `dch_agent_*` agent session minted by `dench login` or a * unified Dench API key like `DENCH_API_KEY` in a sandbox). When the * caller is using an API key, the server needs a `runId` to resolve * the acting user — the dispatcher in `cli/dench.ts` lifts that from * `DENCH_RUN_ID` and forwards it here so `dench chat` works seamlessly * inside a sandbox without extra flags. * * Subcommands: * chat list [--query ] [--limit N] [--include-archived] * chat search [--limit N] [--include-current] * chat read [--limit N] * chat rename # single rename * chat rename --from-stdin # batch via JSON {items:[...]} * chat delete [...] # 1–25 ids, --yes to skip prompt * chat delete --ids id1,id2,id3 # comma-separated form */ import type { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { CliArgError, getFlag, hasFlag, shift as shiftRaw, } from "./lib/cli-args"; type ChatCliContext = { convex: ConvexHttpClient; args: string[]; jsonOutput: boolean; sessionToken?: string; runId?: string; }; class ChatCliError extends Error {} function shift(args: string[], expected: string): string { try { return shiftRaw(args, expected); } catch (error) { if (error instanceof CliArgError) throw new ChatCliError(error.message); throw error; } } function parseLimit(raw: string | undefined): number | undefined { if (raw === undefined) return undefined; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) { throw new ChatCliError(`Invalid --limit value: ${raw}`); } return Math.floor(parsed); } const api = { listPastChats: makeFunctionReference<"query">("functions/chat:listPastChats"), searchPastChats: makeFunctionReference<"action">( "functions/chat:searchPastChatsComplete", ), readPastChat: makeFunctionReference<"query">("functions/chat:readPastChat"), renameThreads: makeFunctionReference<"mutation">( "functions/chat:renameThreads", ), deleteThreads: makeFunctionReference<"mutation">( "functions/chat:deleteThreads", ), }; function commonArgs(ctx: ChatCliContext) { return { ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), ...(ctx.runId ? { runId: ctx.runId } : {}), }; } async function callQuery( ctx: ChatCliContext, fn: Parameters[0], args: Record = {}, ): Promise { return ctx.convex.query(fn, { ...args, ...commonArgs(ctx) } as never); } async function callMutation( ctx: ChatCliContext, fn: Parameters[0], args: Record = {}, ): Promise { return ctx.convex.mutation(fn, { ...args, ...commonArgs(ctx) } as never); } function out(ctx: ChatCliContext, value: unknown): void { if (ctx.jsonOutput) { console.log(JSON.stringify(value, null, 2)); return; } if (value === null || value === undefined) { console.log("(empty)"); return; } console.log(JSON.stringify(value, null, 2)); } function chatHelp(): void { console.log(chatHelpText()); } export function chatHelpText(): string { return `Usage: dench chat List past chat threads (own + shared in this workspace): dench chat list [--query ] [--limit N] [--include-archived] [--include-automated] [--json] Full-text search across past chat messages (own + shared): dench chat search "" [--limit N] [--include-current] [--include-automated] [--json] Read messages from a specific past chat thread: dench chat read [--limit N] [--json] Rename one or more past chat threads (own + shared): dench chat rename [--json] dench chat rename --from-stdin [--json] Reads JSON {"items":[{"threadId":"...","title":"..."}, ...]} from stdin. Up to 50 items per call. Titles are trimmed and clamped to 200 chars. PERMANENTLY delete one or more past chat threads (and all their messages): dench chat delete [...] [--yes] [--include-automated] [--json] dench chat delete --ids id1,id2,id3 [--yes] [--include-automated] [--json] Up to 25 ids per call. IRREVERSIBLE — the messages are removed too. Without --yes, the CLI prompts for confirmation when stdin is a TTY. In non-interactive contexts (sandboxes, scripts), --yes is required. Fire-and-forget: deletion runs in the background. The CLI returns \`{scheduled, rejected, total}\` immediately; threads disappear from the sidebar live as each per-thread mutation completes. AUTOMATION THREADS PROTECTED: cron-spawned routine threads and heartbeat ticks are filtered out of \`list\` / \`search\` and the server REFUSES to delete them unless \`--include-automated\` is set. Spawn a new chat-turn workflow (same agent loop as the web UI): dench chat new "" [--environment NAME] [--model ] [--file-context ] [--visibility private|shared] [--title T] [--yolo] [--follow] [--json] dench chat send "" [--model ] [--yolo] [--follow] [--json] dench chat follow [--json] dench chat tree [] [--limit N] [--json] Aliased as: dench agent new / dench agent send / dench agent follow. Use \`follow\` to tail any live thread by id (e.g. a subagent spawned by another chat). Use \`tree\` to print the parent-child thread hierarchy from a root thread. Approval policy: Workspace agent runs use Full access by default. --yolo remains accepted for compatibility. Auth: Inside a sandbox, this just works — DENCH_API_KEY + DENCH_RUN_ID are used automatically. Locally, run \`dench login\` first. Global flags: --json Output raw JSON instead of pretty-printed text. --follow After spawning, stream the chat response to stdout. `; } export async function runChatCommand(opts: { convex: ConvexHttpClient; args: string[]; sessionToken?: string; runId?: string; }): Promise { const args = [...opts.args]; const jsonOutput = hasFlag(args, "--json"); const ctx: ChatCliContext = { convex: opts.convex, args, jsonOutput, sessionToken: opts.sessionToken, runId: opts.runId, }; const subcommand = args.shift(); if (!subcommand || subcommand === "help" || subcommand === "--help") { chatHelp(); return; } switch (subcommand) { case "list": return await runChatListCommand(ctx); case "search": return await runChatSearchCommand(ctx); case "read": return await runChatReadCommand(ctx); case "tree": return await runChatTreeCommand(ctx); case "rename": return await runChatRenameCommand(ctx); case "delete": case "rm": return await runChatDeleteCommand(ctx); default: throw new ChatCliError(`Unknown chat subcommand: ${subcommand}`); } } async function runChatListCommand(ctx: ChatCliContext): Promise { const titleQuery = getFlag(ctx.args, "--query"); const limit = parseLimit(getFlag(ctx.args, "--limit")); const includeArchived = hasFlag(ctx.args, "--include-archived"); const includeAutomated = hasFlag(ctx.args, "--include-automated"); out( ctx, await callQuery(ctx, api.listPastChats, { titleQuery, limit, includeArchived, includeAutomated, }), ); } async function runChatSearchCommand(ctx: ChatCliContext): Promise { const includeCurrent = hasFlag(ctx.args, "--include-current"); const includeAutomated = hasFlag(ctx.args, "--include-automated"); const limit = parseLimit(getFlag(ctx.args, "--limit")); const queryParts: string[] = []; while (ctx.args.length > 0) { const next = ctx.args.shift(); if (next === undefined) break; queryParts.push(next); } const query = queryParts.join(" ").trim(); if (!query) { throw new ChatCliError( 'Usage: dench chat search "" [--limit N] [--include-current] [--include-automated]', ); } out( ctx, await ctx.convex.action(api.searchPastChats, { ...commonArgs(ctx), query, limit, includeAutomated, // The server defaults excludeThreadId to undefined; the agent path // sets it explicitly. CLI users are not "in" a thread, so we pass // undefined unless --include-current is intentionally set (no-op). ...(includeCurrent ? {} : {}), }), ); } async function runChatReadCommand(ctx: ChatCliContext): Promise { const threadId = shift(ctx.args, "thread id"); const limit = parseLimit(getFlag(ctx.args, "--limit")); out( ctx, await callQuery(ctx, api.readPastChat, { threadId, limit, }), ); } async function readStdin(): Promise { if (process.stdin.isTTY) { throw new ChatCliError( "--from-stdin requires JSON piped on stdin (got TTY).", ); } const chunks: Buffer[] = []; for await (const chunk of process.stdin as AsyncIterable) { chunks.push(chunk); } return Buffer.concat(chunks).toString("utf8"); } async function runChatRenameCommand(ctx: ChatCliContext): Promise { const fromStdin = hasFlag(ctx.args, "--from-stdin"); let items: Array<{ threadId: string; title: string }>; if (fromStdin) { const raw = (await readStdin()).trim(); if (!raw) { throw new ChatCliError( 'Empty --from-stdin payload; expected {"items":[...]}.', ); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { throw new ChatCliError( `Invalid JSON on stdin: ${ error instanceof Error ? error.message : String(error) }`, ); } if ( !parsed || typeof parsed !== "object" || !Array.isArray((parsed as { items?: unknown }).items) ) { throw new ChatCliError( 'Stdin JSON must be {"items":[{"threadId":"...","title":"..."}]}.', ); } const rawItems = (parsed as { items: unknown[] }).items; items = rawItems.map((entry, index) => { if ( !entry || typeof entry !== "object" || typeof (entry as { threadId?: unknown }).threadId !== "string" || typeof (entry as { title?: unknown }).title !== "string" ) { throw new ChatCliError( `items[${index}] must be { threadId: string, title: string }.`, ); } const item = entry as { threadId: string; title: string }; if (!item.threadId.trim()) { throw new ChatCliError(`items[${index}].threadId is empty.`); } if (!item.title.trim()) { throw new ChatCliError(`items[${index}].title is empty.`); } return { threadId: item.threadId.trim(), title: item.title }; }); } else { const threadId = shift(ctx.args, "thread id").trim(); const titleParts: string[] = []; while (ctx.args.length > 0) { const next = ctx.args.shift(); if (next === undefined) break; if (next.startsWith("--")) { ctx.args.unshift(next); break; } titleParts.push(next); } const title = titleParts.join(" ").trim(); if (!title) { throw new ChatCliError( "Usage: dench chat rename ", ); } items = [{ threadId, title }]; } if (items.length === 0) { throw new ChatCliError("Nothing to rename."); } if (items.length > 50) { throw new ChatCliError( `Too many items (${items.length}); the server caps each call at 50.`, ); } out(ctx, await callMutation(ctx, api.renameThreads, { items })); } async function promptYesNo(question: string): Promise { if (!process.stdin.isTTY) return false; process.stdout.write(`${question} [y/N] `); return new Promise((resolve) => { const onData = (chunk: Buffer) => { const answer = chunk.toString("utf8").trim().toLowerCase(); cleanup(); resolve(answer === "y" || answer === "yes"); }; const cleanup = () => { process.stdin.off("data", onData); try { process.stdin.pause(); } catch { // ignore: stdin may already be closed. } }; process.stdin.resume(); process.stdin.once("data", onData); }); } async function runChatDeleteCommand(ctx: ChatCliContext): Promise { const idsFlag = getFlag(ctx.args, "--ids"); const skipConfirm = hasFlag(ctx.args, "--yes") || hasFlag(ctx.args, "-y"); const includeAutomated = hasFlag(ctx.args, "--include-automated"); const collected: string[] = []; if (idsFlag) { for (const part of idsFlag.split(",")) { const trimmed = part.trim(); if (trimmed) collected.push(trimmed); } } while (ctx.args.length > 0) { const next = ctx.args.shift(); if (next === undefined) break; if (next.startsWith("--")) { ctx.args.unshift(next); break; } if (next.trim()) collected.push(next.trim()); } const seen = new Set(); const threadIds = collected.filter((id) => { if (seen.has(id)) return false; seen.add(id); return true; }); if (threadIds.length === 0) { throw new ChatCliError( "Usage: dench chat delete [...] (or --ids id1,id2,...)", ); } // Server caps each call at 25 ids. Each accepted id is scheduled for // background deletion (fire-and-forget), so the cap exists to bound // the per-call validation work, not the deletion volume itself. if (threadIds.length > 25) { throw new ChatCliError( `Too many thread ids (${threadIds.length}); the server caps each call at 25. Run multiple invocations.`, ); } if (!skipConfirm) { if (!process.stdin.isTTY) { throw new ChatCliError( "Refusing to delete without --yes in non-interactive mode (no TTY).", ); } const automatedSuffix = includeAutomated ? " (INCLUDING AUTOMATED ROUTINES — automated thread protection bypassed)" : ""; const ok = await promptYesNo( `PERMANENTLY queue delete of ${threadIds.length} chat${ threadIds.length === 1 ? "" : "s" } and all their messages${automatedSuffix}?`, ); if (!ok) { console.error("Aborted."); return; } } out( ctx, await callMutation(ctx, api.deleteThreads, { threadIds, includeAutomated, }), ); } /** * `dench chat tree [] [--limit N] [--json]` — print the * parent-child chat thread hierarchy rooted at the given thread (or * roots = top-level chats if no id is passed). * * Uses `listPastChats` (which already includes nested threads in its * results — `topLevelOnly` is a `listThreads`-only knob) and groups * client-side by `parentThreadId`. With `--json`, prints the raw flat * array; otherwise prints an indented tree with status + a short * summary excerpt for each row. */ async function runChatTreeCommand(ctx: ChatCliContext): Promise { // Strip flag values from positionals so e.g. `tree --limit 30` doesn't // misinterpret "30" as the rootThreadId. We treat "--key value" as a // pair iff value doesn't itself start with "--". const positionals: string[] = []; for (let i = 0; i < ctx.args.length; i++) { const arg = ctx.args[i]; if (arg.startsWith("--")) { const next = ctx.args[i + 1]; if (next !== undefined && !next.startsWith("--")) { i += 1; // skip the flag value } continue; } positionals.push(arg); } const rootThreadId = positionals[0]?.trim() || undefined; const limit = parseLimit(getFlag(ctx.args, "--limit")) ?? 200; const flat = (await callQuery(ctx, api.listPastChats, { limit, includeArchived: false, })) as Array<{ threadId: string; title: string; status: string; parentThreadId: string | null; nestingDepth: number | null; summary: string | null; lastMessageAt: number; createdAt: number; activeRunId: string | null; }>; if (ctx.jsonOutput) { out(ctx, flat); return; } // Group by parent. Top-level rows = either no parent, or parent // outside the loaded set (so we don't silently swallow children // whose parent fell off the limit window). const ids = new Set(flat.map((t) => t.threadId)); const childrenByParent = new Map(); const roots: typeof flat = []; for (const thread of flat) { if (thread.parentThreadId && ids.has(thread.parentThreadId)) { let list = childrenByParent.get(thread.parentThreadId); if (!list) { list = []; childrenByParent.set(thread.parentThreadId, list); } list.push(thread); continue; } if (rootThreadId) { if (thread.threadId === rootThreadId) roots.push(thread); } else { roots.push(thread); } } // When the user passed an explicit `rootThreadId` but it didn't // appear in the loaded set (e.g. it sits beyond `--limit`), pull // it on its own so we still surface it as the single root. if (rootThreadId && roots.length === 0) { const explicit = flat.find((t) => t.threadId === rootThreadId); if (explicit) roots.push(explicit); } // Sort children oldest-first within each parent (spawn order), // roots newest-first (most recent root at the top). for (const list of childrenByParent.values()) { list.sort( (a, b) => (a.createdAt || a.lastMessageAt) - (b.createdAt || b.lastMessageAt), ); } roots.sort((a, b) => b.lastMessageAt - a.lastMessageAt); if (roots.length === 0) { if (rootThreadId) { console.error( `No thread found with id ${rootThreadId} (or it's outside the limit ` + `${limit} window — increase --limit).`, ); } else { console.error("No chat threads in this workspace yet."); } return; } const stripe = (s: string, n: number) => s.length > n ? `${s.slice(0, n - 1)}…` : s; const print = (thread: (typeof flat)[number], depth: number): void => { const indent = " ".repeat(depth); const branch = depth > 0 ? "└─ " : ""; const status = thread.status === "streaming" ? "▸ running" : thread.status === "error" ? "✗ error" : thread.summary ? "✓ done" : "·"; process.stdout.write( `${indent}${branch}[${status}] ${stripe(thread.title || "Untitled", 60)} ` + `(${thread.threadId})\n`, ); if (thread.summary) { process.stdout.write( `${indent}${depth > 0 ? " " : ""}${stripe(thread.summary.replace(/\s+/g, " "), 80)}\n`, ); } const direct = childrenByParent.get(thread.threadId) ?? []; for (const child of direct) print(child, depth + 1); }; for (const root of roots) print(root, 0); }