Merge remote-tracking branch 'upstream/dev' into feat/byok-multiple-keys

This commit is contained in:
Prakhar Pandey 2026-06-22 12:03:40 +05:30
commit 30373765e7
43 changed files with 1706 additions and 390 deletions

View file

@ -41,6 +41,7 @@ import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js'
import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js';
import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js';
import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js';
import type { CodeModeManager } from '@x/core/dist/code-mode/acp/manager.js';
import * as codeGit from '@x/core/dist/code-mode/git/service.js';
import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js';
import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js';
@ -981,6 +982,10 @@ export function setupIpcHandlers() {
const service = container.resolve<CodeSessionService>('codeSessionService');
return { session: await service.update(args.sessionId, args.patch) };
},
'codeMode:listModelOptions': async (_event, args) => {
const manager = container.resolve<CodeModeManager>('codeModeManager');
return manager.listModelOptions(args.agent);
},
'codeSession:delete': async (_event, args) => {
const service = container.resolve<CodeSessionService>('codeSessionService');
disposeTerminal(args.sessionId);
@ -1015,12 +1020,22 @@ export function setupIpcHandlers() {
if (!info.isGitRepo) {
return { isRepo: false, branch: null, hasCommits: false, files: [] };
}
const files = await codeGit.status(session.cwd);
let files = await codeGit.status(session.cwd);
if (session.worktree && !session.worktree.removedAt && session.worktree.baseBranch) {
const branchFiles = await codeGit.changedSinceBase(session.cwd, session.worktree.baseBranch);
const byPath = new Map(branchFiles.map((file) => [file.path, file]));
for (const file of files) {
if (!byPath.has(file.path)) byPath.set(file.path, file);
}
files = [...byPath.values()];
}
return { isRepo: true, branch: info.branch, hasCommits: info.hasCommits, files };
},
'codeSession:fileDiff': async (_event, args) => {
const session = await requireCodeSession(args.sessionId);
return codeGit.fileDiff(session.cwd, args.path);
return codeGit.fileDiff(session.cwd, args.path, {
baseRef: session.worktree && !session.worktree.removedAt ? session.worktree.baseBranch : null,
});
},
'codeSession:readdir': async (_event, args) => {
const session = await requireCodeSession(args.sessionId);
@ -1615,6 +1630,7 @@ export function setupIpcHandlers() {
name: args.name,
instructions: args.instructions,
...(args.triggers ? { triggers: args.triggers } : {}),
...(args.projectId ? { projectId: args.projectId } : {}),
...(args.model ? { model: args.model } : {}),
...(args.provider ? { provider: args.provider } : {}),
});

View file

@ -345,11 +345,11 @@ export async function connectProvider(provider: string, credentials?: { clientId
signedInUserId = billing.userId;
analyticsIdentify(billing.userId, {
...(billing.userEmail ? { email: billing.userEmail } : {}),
plan: billing.subscriptionPlan,
plan: billing.subscriptionPlanId,
status: billing.subscriptionStatus,
});
analyticsCapture('user_signed_in', {
plan: billing.subscriptionPlan,
plan: billing.subscriptionPlanId,
status: billing.subscriptionStatus,
});
}

View file

@ -726,6 +726,26 @@
background: rgba(0, 0, 0, 0.32);
}
.gmail-compose-modal,
.gmail-compose-card {
--gm-bg-card: #ffffff;
--gm-bg-input: #f4f4f7;
--gm-bg-elevated: #ffffff;
--gm-bg-pill: #ffffff;
--gm-bg-pill-hover: #f4f4f7;
--gm-text: #27272a;
--gm-text-strong: #09090b;
--gm-text-muted: #71717a;
--gm-text-body: #3f3f46;
--gm-border: #e4e4e7;
--gm-border-strong: #d4d4d8;
--gm-accent: #7c3aed;
--gm-accent-hover: #6d28d9;
--gm-accent-fg: #ffffff;
--gm-icon-hover-bg: #f4f4f7;
--gm-placeholder: #a1a1aa;
}
.gmail-compose-modal {
display: flex;
flex-direction: column;
@ -927,12 +947,12 @@
width: max-content;
min-width: 280px;
max-width: min(440px, 100%);
background: var(--gm-bg-elevated, #1e1e1e);
background: var(--gm-bg-elevated, #ffffff);
border: 1px solid var(--gm-border);
border-radius: 10px;
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.18),
0 12px 32px rgba(0, 0, 0, 0.36);
0 1px 2px rgba(24, 24, 27, 0.08),
0 12px 32px rgba(24, 24, 27, 0.18);
max-height: 296px;
overflow-y: auto;
overscroll-behavior: contain;

View file

@ -30,7 +30,7 @@ import { BgTasksView } from '@/components/bg-tasks-view';
import { EmailView } from '@/components/email-view';
import { WorkspaceView } from '@/components/workspace-view';
import { CodingRunBlock } from '@/components/coding-run';
import { KnowledgeView } from '@/components/knowledge-view';
import { KnowledgeView, type KnowledgeViewMode } from '@/components/knowledge-view';
import { ChatHistoryView } from '@/components/chat-history-view';
import { HomeView } from '@/components/home-view';
import { MeetingsView } from '@/components/meetings-view';
@ -591,7 +591,7 @@ type ViewState =
| { type: 'live-notes' }
| { type: 'email' }
| { type: 'workspace'; path?: string }
| { type: 'knowledge-view'; folderPath?: string }
| { type: 'knowledge-view'; folderPath?: string; mode?: KnowledgeViewMode }
| { type: 'chat-history' }
| { type: 'home' }
| { type: 'code' }
@ -602,7 +602,7 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
if (a.type === 'file' && b.type === 'file') return a.path === b.path
if (a.type === 'task' && b.type === 'task') return a.name === b.name
if (a.type === 'workspace' && b.type === 'workspace') return (a.path ?? '') === (b.path ?? '')
if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '')
if (a.type === 'knowledge-view' && b.type === 'knowledge-view') return (a.folderPath ?? '') === (b.folderPath ?? '') && (a.mode ?? '') === (b.mode ?? '')
return true // both graph
}
@ -652,7 +652,12 @@ function parseDeepLink(input: string): ViewState | null {
}
case 'knowledge-view': {
const folderPath = params.get('folderPath')
return { type: 'knowledge-view', folderPath: folderPath ?? undefined }
const mode = params.get('mode')
return {
type: 'knowledge-view',
folderPath: folderPath ?? undefined,
mode: mode === 'graph' || mode === 'basis' || mode === 'files' ? mode : undefined,
}
}
case 'chat-history':
return { type: 'chat-history' }
@ -775,6 +780,7 @@ function App() {
const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false)
const [workspaceInitialPath, setWorkspaceInitialPath] = useState<string | null>(null)
const [isKnowledgeViewOpen, setIsKnowledgeViewOpen] = useState(false)
const [knowledgeViewMode, setKnowledgeViewMode] = useState<KnowledgeViewMode>('graph')
// Folder being browsed inside the knowledge view (null = root overview).
// Lives in ViewState so folder drill-down participates in back/forward history.
const [knowledgeViewFolderPath, setKnowledgeViewFolderPath] = useState<string | null>(null)
@ -1197,7 +1203,7 @@ function App() {
if (isBgTasksTabPath(tab.path)) return 'Background tasks'
if (isEmailTabPath(tab.path)) return 'Email'
if (isWorkspaceTabPath(tab.path)) return 'Workspace'
if (isKnowledgeViewTabPath(tab.path)) return 'Notes'
if (isKnowledgeViewTabPath(tab.path)) return 'Brain'
if (isChatHistoryTabPath(tab.path)) return 'Chat history'
if (isHomeTabPath(tab.path)) return 'Home'
if (isCodeTabPath(tab.path)) return 'Code'
@ -3627,14 +3633,14 @@ function App() {
if (isLiveNotesOpen) return { type: 'live-notes' }
if (isSuggestedTopicsOpen) return { type: 'suggested-topics' }
if (isWorkspaceOpen) return { type: 'workspace', path: workspaceInitialPath ?? undefined }
if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined }
if (isKnowledgeViewOpen) return { type: 'knowledge-view', folderPath: knowledgeViewFolderPath ?? undefined, mode: knowledgeViewMode }
if (isChatHistoryOpen) return { type: 'chat-history' }
if (isHomeOpen) return { type: 'home' }
if (isCodeOpen) return { type: 'code' }
if (selectedPath) return { type: 'file', path: selectedPath }
if (isGraphOpen) return { type: 'graph' }
return { type: 'chat', runId }
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId])
}, [selectedBackgroundTask, isEmailOpen, isMeetingsOpen, isLiveNotesOpen, isBgTasksOpen, isSuggestedTopicsOpen, selectedPath, isGraphOpen, isWorkspaceOpen, isKnowledgeViewOpen, knowledgeViewFolderPath, knowledgeViewMode, isChatHistoryOpen, isHomeOpen, isCodeOpen, workspaceInitialPath, runId])
const appendUnique = useCallback((stack: ViewState[], entry: ViewState) => {
const last = stack[stack.length - 1]
@ -3997,6 +4003,7 @@ function App() {
setIsEmailOpen(false)
setIsWorkspaceOpen(false)
setIsKnowledgeViewOpen(true)
setKnowledgeViewMode(view.mode ?? (view.folderPath ? 'files' : 'graph'))
setKnowledgeViewFolderPath(view.folderPath ?? null)
setIsChatHistoryOpen(false)
setIsHomeOpen(false)
@ -4223,10 +4230,9 @@ function App() {
setBaseConfigByPath((prev) => ({ ...prev, [path]: config }))
}, [])
const handleBaseSave = useCallback(async (name: string | null) => {
if (!selectedPath) return
const isDefault = selectedPath === BASES_DEFAULT_TAB_PATH
const config = baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG
const handleBaseSave = useCallback(async (path: string, name: string | null) => {
const isDefault = path === BASES_DEFAULT_TAB_PATH
const config = baseConfigByPath[path] ?? DEFAULT_BASE_CONFIG
if (isDefault && name) {
// Save as new base file
@ -4250,14 +4256,14 @@ function App() {
// Save in place
try {
await window.ipc.invoke('workspace:writeFile', {
path: selectedPath,
path,
data: JSON.stringify(config, null, 2),
})
} catch (err) {
console.error('Failed to save base:', err)
}
}
}, [selectedPath, baseConfigByPath, loadDirectory, navigateToView])
}, [baseConfigByPath, loadDirectory, navigateToView])
// External search set by app-navigation tool (passed to BasesView)
const [externalBaseSearch, setExternalBaseSearch] = useState<string | undefined>(undefined)
@ -5121,8 +5127,10 @@ function App() {
},
}), [knowledgeFiles, recentWikiFiles, openWikiLink, ensureWikiFile])
const isBrainGraphOpen = isKnowledgeViewOpen && knowledgeViewMode === 'graph'
useEffect(() => {
if (!isGraphOpen) return
if (!isGraphOpen && !isBrainGraphOpen) return
let cancelled = false
const buildGraph = async () => {
@ -5237,7 +5245,7 @@ function App() {
return () => {
cancelled = true
}
}, [isGraphOpen, knowledgeFilePaths])
}, [isGraphOpen, isBrainGraphOpen, knowledgeFilePaths])
const renderConversationItem = (
item: ConversationItem,
@ -5760,12 +5768,44 @@ function App() {
revealInFileManager: knowledgeActions.revealInFileManager,
onOpenInNewTab: knowledgeActions.onOpenInNewTab,
}}
mode={knowledgeViewMode}
onModeChange={setKnowledgeViewMode}
graphContent={(
<GraphView
nodes={graphData.nodes}
edges={graphData.edges}
isLoading={false}
error={graphStatus === 'error' ? (graphError ?? 'Failed to build graph') : null}
onSelectNode={(path) => {
navigateToFile(path)
}}
/>
)}
basisContent={(
<BasesView
tree={tree}
onSelectNote={(path) => navigateToFile(path)}
config={baseConfigByPath[BASES_DEFAULT_TAB_PATH] ?? DEFAULT_BASE_CONFIG}
onConfigChange={(cfg) => handleBaseConfigChange(BASES_DEFAULT_TAB_PATH, cfg)}
isDefaultBase
onSave={(name) => void handleBaseSave(BASES_DEFAULT_TAB_PATH, name)}
externalSearch={externalBaseSearch}
onExternalSearchConsumed={() => setExternalBaseSearch(undefined)}
actions={{
rename: knowledgeActions.rename,
remove: knowledgeActions.remove,
copyPath: knowledgeActions.copyPath,
revealInFileManager: knowledgeActions.revealInFileManager,
}}
/>
)}
folderPath={knowledgeViewFolderPath}
onNavigateFolder={(path) => { void navigateToView({ type: 'knowledge-view', folderPath: path ?? undefined }) }}
onNavigateFolder={(path) => {
setKnowledgeViewMode('files')
void navigateToView({ type: 'knowledge-view', folderPath: path ?? undefined, mode: 'files' })
}}
onOpenNote={(path) => navigateToFile(path)}
onOpenGraph={() => knowledgeActions.openGraph()}
onOpenSearch={() => { setSearchDefaultScope('knowledge'); setIsSearchOpen(true) }}
onOpenBases={() => knowledgeActions.openBases()}
onVoiceNoteCreated={handleVoiceNoteCreated}
/>
</div>
@ -5796,7 +5836,7 @@ function App() {
config={baseConfigByPath[selectedPath] ?? DEFAULT_BASE_CONFIG}
onConfigChange={(cfg) => handleBaseConfigChange(selectedPath, cfg)}
isDefaultBase={selectedPath === BASES_DEFAULT_TAB_PATH}
onSave={(name) => void handleBaseSave(name)}
onSave={(name) => void handleBaseSave(selectedPath, name)}
externalSearch={externalBaseSearch}
onExternalSearchConsumed={() => setExternalBaseSearch(undefined)}
actions={{

View file

@ -4,6 +4,7 @@ import {
ListChecks, Play, Square, Loader2, Trash2, Plus, X, AlertCircle,
Repeat, Clock, Zap, ChevronLeft, ChevronDown, ChevronRight,
Pencil, Check, PanelRightClose, PanelRightOpen, Sparkles,
Code2, FolderOpen, LayoutTemplate,
} from 'lucide-react'
import type { z } from 'zod'
import type { BackgroundTask, BackgroundTaskSummary, Triggers } from '@x/shared/dist/background-task.js'
@ -271,7 +272,16 @@ function TriggersEditor({
// New Task dialog
// ---------------------------------------------------------------------------
type DialogMode = 'describe' | 'manual'
type DialogMode = 'describe' | 'manual' | 'templates' | 'coding'
// Prefills for the "Coding from meetings" preset.
const CODING_PRESET = {
name: 'Implement coding items from meetings',
instructions: `After a meeting's notes are ready, scan them for coding action items (bugs to fix, features to build, concrete changes requested) for me or my team.
Conservatively implement the clearly-scoped, self-contained ones in the configured repo using the launch-code-task tool group related items into one session, split unrelated ones. Note ambiguous, large/architectural, or other-repo items as "needs review" instead of coding them. If nothing is actionable, do nothing.`,
eventMatchCriteria: `A meeting's notes or transcript just became available (engineering standup, planning, sprint, or technical discussion) that may contain coding action items, bugs to fix, or features to build.`,
}
function NewTaskDialog({
open,
@ -295,6 +305,9 @@ function NewTaskDialog({
const [name, setName] = useState('')
const [instructions, setInstructions] = useState('')
const [triggers, setTriggers] = useState<Triggers | undefined>(undefined)
const [projectId, setProjectId] = useState<string | undefined>(undefined)
const [projectName, setProjectName] = useState<string | undefined>(undefined)
const [addingProject, setAddingProject] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
@ -304,11 +317,64 @@ function NewTaskDialog({
setName('')
setInstructions('')
setTriggers(undefined)
setProjectId(undefined)
setProjectName(undefined)
}
}, [open, copilotEnabled])
// Switch into the coding preset: prefill name/instructions/trigger once.
const enterCodingMode = () => {
setMode('coding')
setName(CODING_PRESET.name)
setInstructions(CODING_PRESET.instructions)
setTriggers({ eventMatchCriteria: CODING_PRESET.eventMatchCriteria })
}
const pickRepo = async () => {
setAddingProject(true)
try {
const res = await window.ipc.invoke('dialog:openDirectory', { title: 'Choose the repository for this task' })
const dir = res.path
if (!dir) return
const added = await window.ipc.invoke('codeProject:add', { path: dir })
if (!added.git?.isGitRepo) {
toast('That folder is not a git repository — coding tasks need one.', 'error')
return
}
setProjectId(added.project.id)
setProjectName(added.project.name)
} catch (err) {
toast(err instanceof Error ? err.message : String(err), 'error')
} finally {
setAddingProject(false)
}
}
const canSubmitDescribe = description.trim().length > 0 && !submitting
const canSubmitManual = name.trim().length > 0 && instructions.trim().length > 0 && !submitting
const canSubmitCoding = name.trim().length > 0 && instructions.trim().length > 0 && !!projectId && !submitting
const submitCoding = async () => {
if (!canSubmitCoding) return
setSubmitting(true)
try {
const result = await window.ipc.invoke('bg-task:create', {
name: name.trim(),
instructions: instructions.trim(),
...(triggers ? { triggers } : {}),
...(projectId ? { projectId } : {}),
})
if (result.success && result.slug) {
onCreated(result.slug)
} else {
toast(result.error ?? 'Failed to create task', 'error')
}
} catch (err) {
toast(err instanceof Error ? err.message : String(err), 'error')
} finally {
setSubmitting(false)
}
}
const submitDescribe = () => {
if (!canSubmitDescribe || !onCreateWithCopilot) return
@ -359,7 +425,116 @@ function NewTaskDialog({
</button>
</div>
{mode === 'describe' ? (
{(mode === 'describe' || mode === 'manual') && (
<button
type="button"
onClick={() => setMode('templates')}
className="mb-4 flex w-full items-center justify-between gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-left text-[12px] hover:border-solid hover:bg-accent"
>
<span className="flex items-center gap-2">
<LayoutTemplate className="size-4 shrink-0 text-muted-foreground" />
<span className="font-medium">View available templates</span>
</span>
<ChevronRight className="size-4 text-muted-foreground" />
</button>
)}
{mode === 'templates' ? (
<>
<div className="space-y-2">
{[
{
id: 'coding-from-meetings',
title: 'Coding from meetings',
description: "When a meeting's notes are ready, scan them for coding action items and auto-implement them in a repo — each on its own isolated branch, with a summary.",
icon: Code2,
onSelect: enterCodingMode,
},
].map(preset => (
<button
key={preset.id}
type="button"
onClick={preset.onSelect}
className="flex w-full items-start gap-2.5 rounded-md border bg-muted/40 px-3 py-2.5 text-left hover:border-foreground/30 hover:bg-accent"
>
<preset.icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block text-[12.5px] font-medium">{preset.title}</span>
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">{preset.description}</span>
</span>
</button>
))}
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setMode(copilotEnabled ? 'describe' : 'manual')}
className="text-[11px] text-muted-foreground hover:text-foreground"
>
Back
</button>
<Button variant="outline" size="sm" onClick={onClose}>Cancel</Button>
</div>
</>
) : mode === 'coding' ? (
<>
<div className="space-y-4">
<div>
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Repository</label>
{projectName ? (
<div className="flex items-center justify-between rounded-md border bg-muted/40 px-3 py-2">
<span className="flex items-center gap-2 text-[13px]">
<FolderOpen className="size-4 text-muted-foreground" />
<span className="font-medium">{projectName}</span>
</span>
<button type="button" onClick={pickRepo} className="text-[11px] text-muted-foreground hover:text-foreground" disabled={addingProject}>Change</button>
</div>
) : (
<Button variant="outline" size="sm" onClick={pickRepo} disabled={addingProject}>
{addingProject ? <Loader2 className="mr-1 size-3 animate-spin" /> : <FolderOpen className="mr-1 size-3" />}
Choose a git repository
</Button>
)}
<p className="mt-1 text-[11px] text-muted-foreground">
Code changes run full-auto in an isolated git worktree your working checkout is never touched.
</p>
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Name</label>
<Input value={name} onChange={e => setName(e.target.value)} />
</div>
<div>
<label className="mb-1 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Instructions</label>
<Textarea value={instructions} onChange={e => setInstructions(e.target.value)} rows={6} className="text-[12.5px] leading-relaxed" />
</div>
<div>
<label className="mb-2 block text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Triggers</label>
<TriggersEditor value={triggers} onChange={setTriggers} />
<p className="mt-2 text-[11px] text-muted-foreground">
Prefilled to fire when a meeting's notes become available. Adjust if you want.
</p>
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<button
type="button"
onClick={() => setMode(copilotEnabled ? 'describe' : 'manual')}
className="text-[11px] text-muted-foreground hover:text-foreground"
>
Back
</button>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>Cancel</Button>
<Button size="sm" onClick={submitCoding} disabled={!canSubmitCoding}>
{submitting && <Loader2 className="mr-1 size-3 animate-spin" />}
Create
</Button>
</div>
</div>
</>
) : mode === 'describe' ? (
<>
<Textarea
value={description}

View file

@ -2,6 +2,7 @@ import { ArrowUpRight, Bot, Mail, MessageSquare, Sparkles, Telescope } from 'luc
import { cn } from '@/lib/utils'
import { formatRelativeTime } from '@/lib/relative-time'
import { ToolConnectionsCard } from '@/components/tool-connections-card'
export interface ChatEmptyStateRun {
id: string
@ -101,6 +102,8 @@ export function ChatEmptyState({
))}
</div>
</div>
<ToolConnectionsCard />
</div>
)
}

View file

@ -0,0 +1,28 @@
import type { CodingAgent } from '@x/shared/src/code-mode.js'
import type { CodeAgentModelOptions, CodeAgentOption } from '@x/shared/src/code-sessions.js'
// Model + effort choices for a coding agent, discovered live from the engine
// (the same list `/model` shows) via the main process, which caches per agent.
// We memoize the in-flight/resolved promise per agent here too so reopening the
// picker doesn't re-hit IPC. A failed lookup resolves to empty lists so the UI
// just falls back to the engine default.
const EMPTY: CodeAgentModelOptions = { models: [], efforts: [] }
const cache = new Map<CodingAgent, Promise<CodeAgentModelOptions>>()
export function fetchCodeAgentOptions(agent: CodingAgent): Promise<CodeAgentModelOptions> {
let pending = cache.get(agent)
if (!pending) {
pending = window.ipc.invoke('codeMode:listModelOptions', { agent }).catch(() => EMPTY)
cache.set(agent, pending)
}
return pending
}
// Always offer a Default fallback even before options load (or if discovery fails).
export function withDefault(options: CodeAgentOption[]): CodeAgentOption[] {
return options.some((o) => o.value === 'default') ? options : [{ value: 'default', label: 'Default' }, ...options]
}
export function optionLabel(options: CodeAgentOption[], value: string | undefined): string {
return options.find((o) => o.value === (value ?? 'default'))?.label ?? value ?? 'Default'
}

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react'
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
import type { CodeSession, CodeSessionStatus, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
import { fetchCodeAgentOptions, withDefault, optionLabel } from './code-agent-options'
import type { ApprovalPolicy } from '@x/shared/src/code-mode.js'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
@ -31,6 +32,16 @@ const TERMINAL_HEIGHT_STORAGE_KEY = 'x:code-terminal-height'
const TERMINAL_MIN_HEIGHT = 120
const TERMINAL_MAX_HEIGHT = 600
// Remember which session was open so leaving the Code section (which unmounts
// this view) and coming back restores the selection — and with it the chat
// output in the right pane — instead of dropping back to the empty state.
const SELECTED_SESSION_STORAGE_KEY = 'x:code-selected-session'
function readStoredSelectedSessionId(): string | null {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(SELECTED_SESSION_STORAGE_KEY) || null
}
function readStoredTerminalHeight(): number {
if (typeof window === 'undefined') return 240
const raw = Number(window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY))
@ -44,6 +55,11 @@ const POLICY_LABEL: Record<ApprovalPolicy, string> = {
'auto-approve-reads': 'Auto-approve reads',
yolo: 'Auto-approve everything',
}
const POLICY_HEADER_LABEL: Record<ApprovalPolicy, string> = {
ask: 'Ask',
'auto-approve-reads': 'Auto reads',
yolo: 'Auto all',
}
export interface ActiveCodeSession {
session: CodeSession
@ -65,7 +81,7 @@ export function CodeView({
onDiffOpened?: () => void
}) {
const { projects, sessions, statusOf, refresh } = useCodeSessions()
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(readStoredSelectedSessionId)
const [newSessionProjectId, setNewSessionProjectId] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<CodeSession | null>(null)
const [terminalOpen, setTerminalOpen] = useState(false)
@ -76,6 +92,11 @@ export function CodeView({
window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight))
}, [terminalHeight])
useEffect(() => {
if (selectedSessionId) window.localStorage.setItem(SELECTED_SESSION_STORAGE_KEY, selectedSessionId)
else window.localStorage.removeItem(SELECTED_SESSION_STORAGE_KEY)
}, [selectedSessionId])
const handleTerminalDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault()
dragStateRef.current = { startY: e.clientY, startHeight: terminalHeight }
@ -99,6 +120,17 @@ export function CodeView({
const selectedStatus = selectedSession ? statusOf(selectedSession.id) : 'idle'
const newSessionProject = projects.find((p) => p.project.id === newSessionProjectId) ?? null
// Live model/effort choices for the selected session's agent, for the header
// pickers. Discovered from the engine and cached, so this is cheap to re-run.
const [modelOpts, setModelOpts] = useState<CodeAgentModelOptions>({ models: [], efforts: [] })
const selectedAgent = selectedSession?.agent
useEffect(() => {
if (!selectedAgent) { setModelOpts({ models: [], efforts: [] }); return }
let cancelled = false
void fetchCodeAgentOptions(selectedAgent).then((opts) => { if (!cancelled) setModelOpts(opts) })
return () => { cancelled = true }
}, [selectedAgent])
// Tell App which session (and status) owns the right-hand chat pane.
useEffect(() => {
onSessionSelected?.(selectedSession ? { session: selectedSession, status: selectedStatus } : null)
@ -147,7 +179,7 @@ export function CodeView({
}
}, [refresh, selectedSessionId])
const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex' }) => {
const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex'; agentModel?: string; agentEffort?: string }) => {
if (!selectedSessionId) return
try {
await window.ipc.invoke('codeSession:update', { sessionId: selectedSessionId, patch })
@ -180,45 +212,97 @@ export function CodeView({
<div className="flex min-w-0 flex-1 flex-col">
{selectedSession ? (
<>
<div className="flex items-center gap-3 border-b px-4 py-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start gap-x-3 gap-y-2 border-b px-4 py-2.5">
<div className="min-w-64 flex-[1_1_360px]">
<div className="truncate text-sm font-medium">{selectedSession.title}</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{AGENT_LABEL[selectedSession.agent]}</span>
<span>·</span>
<span className="truncate font-mono" title={selectedSession.cwd}>{selectedSession.cwd}</span>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<span className="shrink-0 whitespace-nowrap">{AGENT_LABEL[selectedSession.agent]}</span>
<span className="shrink-0 text-muted-foreground/50">·</span>
<span className="min-w-0 max-w-full flex-1 truncate font-mono" title={selectedSession.cwd}>{selectedSession.cwd}</span>
{selectedSession.worktree && !selectedSession.worktree.removedAt && (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-muted px-1.5 py-0.5">
<span className="flex min-w-0 max-w-72 shrink items-center gap-1 rounded-full bg-muted px-1.5 py-0.5">
<GitBranch className="size-3" />
{selectedSession.worktree.branch}
<span className="truncate">{selectedSession.worktree.branch}</span>
</span>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-muted-foreground">
{POLICY_LABEL[selectedSession.policy]}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((policy) => (
<DropdownMenuItem key={policy} onClick={() => void handleUpdateSession({ policy })}>
{POLICY_LABEL[policy]}
{selectedSession.policy === policy && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Bot className="size-3.5" />
Rowboat drives
<Switch
checked={selectedSession.mode === 'rowboat'}
disabled={busy}
onCheckedChange={(checked) => void handleUpdateSession({ mode: checked ? 'rowboat' : 'direct' })}
/>
</label>
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
title="Coding agent model"
>
<span className="whitespace-nowrap">{optionLabel(modelOpts.models, selectedSession.agentModel)}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
{withDefault(modelOpts.models).map((m) => (
<DropdownMenuItem key={m.value} onClick={() => void handleUpdateSession({ agentModel: m.value })}>
{m.label}
{(selectedSession.agentModel ?? 'default') === m.value && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{modelOpts.efforts.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
title="Reasoning effort"
>
<span className="whitespace-nowrap">{optionLabel(modelOpts.efforts, selectedSession.agentEffort)}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{withDefault(modelOpts.efforts).map((e) => (
<DropdownMenuItem key={e.value} onClick={() => void handleUpdateSession({ agentEffort: e.value })}>
{e.label}
{(selectedSession.agentEffort ?? 'default') === e.value && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
title={POLICY_LABEL[selectedSession.policy]}
>
<span className="whitespace-nowrap">{POLICY_HEADER_LABEL[selectedSession.policy]}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(Object.keys(POLICY_LABEL) as ApprovalPolicy[]).map((policy) => (
<DropdownMenuItem key={policy} onClick={() => void handleUpdateSession({ policy })}>
{POLICY_LABEL[policy]}
{selectedSession.policy === policy && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Bot className="size-3.5" />
<span className="whitespace-nowrap">Rowboat drives</span>
<Switch
checked={selectedSession.mode === 'rowboat'}
disabled={busy}
onCheckedChange={(checked) => void handleUpdateSession({ mode: checked ? 'rowboat' : 'direct' })}
/>
</label>
</div>
</div>
<div className="min-h-0 flex-1">
<WorkspacePane

View file

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
import type { CodeSession, CodeSessionMode } from '@x/shared/src/code-sessions.js'
import type { CodeSession, CodeSessionMode, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
import { fetchCodeAgentOptions, withDefault } from './code-agent-options'
import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
@ -86,6 +87,11 @@ export function NewSessionDialog({
const [modelOptions, setModelOptions] = useState<ModelOption[]>([])
// 'default' = let the backend use the configured default model.
const [modelKey, setModelKey] = useState('default')
// The coding agent's own model + reasoning effort. 'default' leaves the
// engine default. Choices are discovered live per agent (see effect below).
const [agentModel, setAgentModel] = useState('default')
const [agentEffort, setAgentEffort] = useState('default')
const [modelOpts, setModelOpts] = useState<CodeAgentModelOptions>({ models: [], efforts: [] })
const git = projectRow?.git
const worktreeAvailable = !!git?.isGitRepo && !!git?.hasCommits
@ -97,6 +103,8 @@ export function NewSessionDialog({
setIsolation('in-repo')
setMode('rowboat')
setModelKey('default')
setAgentModel('default')
setAgentEffort('default')
void loadModelOptions().then(setModelOptions)
void window.ipc.invoke('codeMode:checkAgentStatus', null).then((status) => {
setAgentStatus(status)
@ -108,6 +116,18 @@ export function NewSessionDialog({
})
}, [open])
// Model/effort choices are per-agent (and the saved value from one agent is
// meaningless for the other), so reset to defaults and (re)load the live list
// whenever the agent changes.
useEffect(() => {
setAgentModel('default')
setAgentEffort('default')
setModelOpts({ models: [], efforts: [] })
let cancelled = false
void fetchCodeAgentOptions(agent).then((opts) => { if (!cancelled) setModelOpts(opts) })
return () => { cancelled = true }
}, [agent])
const agentReady = (a: CodingAgent): boolean => {
if (!agentStatus) return true
const s = agentStatus[a]
@ -129,6 +149,8 @@ export function NewSessionDialog({
policy,
isolation,
...(picked ? { model: picked.model, provider: picked.provider } : {}),
...(agentModel !== 'default' ? { agentModel } : {}),
...(modelOpts.efforts.length > 0 && agentEffort !== 'default' ? { agentEffort } : {}),
})
onOpenChange(false)
onCreated(res.session)
@ -278,6 +300,41 @@ export function NewSessionDialog({
</p>
</div>
{/* The coding agent's own model + reasoning effort, discovered live
from the engine and applied to the ACP session each turn (so they
stay editable from the session header later). Effort is a separate
axis only for Claude; Codex folds it into the model id. */}
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Model</label>
<Select value={agentModel} onValueChange={setAgentModel}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{withDefault(modelOpts.models).map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{modelOpts.efforts.length > 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium">Effort</label>
<Select value={agentEffort} onValueChange={setAgentEffort}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{withDefault(modelOpts.efforts).map((e) => (
<SelectItem key={e.value} value={e.value}>{e.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* The model only powers Rowboat's own turns; the coding agent uses its
own configured model, so hide this entirely for direct sessions. */}
{mode === 'rowboat' && modelOptions.length > 0 && (

View file

@ -906,7 +906,7 @@ const AI_GENERATE_SYSTEM =
'<the email body as plain text>\n' +
'Do not use markdown. Do not add any commentary, labels, or surrounding quotes. ' +
'When recipient names are provided, address them naturally (e.g. "Hi <first name>,"). ' +
'When the sender\'s name is provided, sign off with it; otherwise omit the sign-off name ' +
'When the sender\'s first name is provided, sign off with that first name only; otherwise omit the sign-off name ' +
'(never write a placeholder like "[Your Name]").'
const AI_REWRITE_SYSTEM =
@ -931,8 +931,13 @@ function parseGeneratedEmail(text: string): { subject: string | null; body: stri
return { subject: null, body: text }
}
// Guarantee the sender's name signs off the email. If the model already ended
// with the name (e.g. "Best,\nHarsh"), leave it; otherwise append it.
function firstNameFromDisplayName(name: string): string {
const trimmed = name.trim().replace(/^["']|["']$/g, '')
return trimmed.split(/\s+/)[0] || ''
}
// Guarantee the sender's first name signs off the email. If the model already
// ended with the name (e.g. "Best,\nHarsh"), leave it; otherwise append it.
function ensureSignature(body: string, name: string): string {
const signer = name.trim()
if (!signer) return body
@ -977,7 +982,7 @@ const ComposeBox = memo(function ComposeBox({
const [showCc, setShowCc] = useState<boolean>(initialRecipients.cc.length > 0)
const [showBcc, setShowBcc] = useState<boolean>(false)
const [subject, setSubject] = useState<string>(() => (thread ? composeSubject(mode, thread.subject) : ''))
const modeLabel = isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply all' : 'Reply'
const modeLabel = isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : 'Reply'
const initialContent = useMemo(() => {
if (!thread) return ''
@ -1050,6 +1055,7 @@ const ComposeBox = memo(function ComposeBox({
// The signed-in account's display name, used to sign off AI-generated emails.
const [selfName, setSelfName] = useState<string>('')
const selfFirstName = useMemo(() => firstNameFromDisplayName(selfName), [selfName])
useEffect(() => {
if (!isNew) return
let cancelled = false
@ -1104,7 +1110,7 @@ const ComposeBox = memo(function ComposeBox({
})
.filter(Boolean)
if (recipientNames.length) ctx.push(`Recipient(s): ${recipientNames.join(', ')}`)
if (selfName) ctx.push(`Sender's name (sign off as this): ${selfName}`)
if (selfFirstName) ctx.push(`Sender's first name (sign off as this): ${selfFirstName}`)
if (subject.trim()) ctx.push(`Desired subject hint: ${subject.trim()}`)
if (current) ctx.push(`Existing draft (revise or build on it):\n${current}`)
prompt = `${ctx.length ? ctx.join('\n') + '\n\n' : ''}Instruction: ${instruction.trim()}`
@ -1130,8 +1136,8 @@ const ComposeBox = memo(function ComposeBox({
if (aiMode === 'generate') {
const { subject: generatedSubject, body } = parseGeneratedEmail(res.text)
if (generatedSubject) setSubject(generatedSubject)
// Always sign off with the account name, even if the model omitted it.
const signed = ensureSignature(body, selfName)
// Always sign off with the account first name, even if the model omitted it.
const signed = ensureSignature(body, selfFirstName)
editor.chain().focus().selectAll().insertContent(plainTextToHtml(signed)).run()
setHasGenerated(true)
} else {
@ -1495,10 +1501,17 @@ function ThreadDetail({
return () => { cancelled = true }
}, [])
const canReplyAll = useMemo(() => {
const { to, cc } = buildRecipients('replyAll', thread, selfEmail)
return cc.length > 0 || to.length > 1
}, [thread, selfEmail])
const replyAllRecipients = useMemo(
() => buildRecipients('replyAll', thread, selfEmail),
[thread, selfEmail],
)
const canReplyAll = replyAllRecipients.cc.length > 0 || replyAllRecipients.to.length > 1
const replyAllButton = canReplyAll ? (
<button type="button" onClick={() => setComposeMode('replyAll')}>
<ReplyAll size={16} />
Reply All
</button>
) : null
const toggleExpand = useCallback((index: number) => {
setExpandedIndices((prev) => {
@ -1568,16 +1581,11 @@ function ThreadDetail({
</div>
<div className="gmail-thread-actions">
{replyAllButton}
<button type="button" onClick={() => setComposeMode('reply')}>
<Reply size={16} />
Reply
</button>
{canReplyAll && (
<button type="button" onClick={() => setComposeMode('replyAll')}>
<ReplyAll size={16} />
Reply all
</button>
)}
<button type="button" onClick={() => setComposeMode('forward')}>
<Forward size={16} />
Forward

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ArrowRight, Bot, Calendar, Clock, ExternalLink, FileText, Mail, MessageSquare, Mic, Plug, Plus, Video } from 'lucide-react'
import { ArrowRight, Bot, Calendar, Clock, ExternalLink, FileText, Mail, MessageSquare, Mic, Plus, Video } from 'lucide-react'
import { extractConferenceLink } from '@/lib/calendar-event'
import { SettingsDialog } from '@/components/settings-dialog'
import { ToolConnectionsCard } from '@/components/tool-connections-card'
interface TreeNode {
path: string
@ -65,7 +65,6 @@ type SlackFeedMessage = {
ts: string
url?: string
}
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
function greeting(): string {
const h = new Date().getHours()
@ -187,54 +186,6 @@ function triggerMeetingCapture(event: CalEvent, openConference: boolean) {
}
const CARD = 'rounded-xl border border-border bg-card p-4'
const TOOLKIT_PREVIEW_LIMIT = 8
let cachedToolkitPreviews: ToolkitPreview[] | null = null
let cachedToolkitLogosLoaded = false
function ToolkitPreviewIcon({
toolkit,
onInvalid,
}: {
toolkit: ToolkitPreview
onInvalid: (slug: string) => void
}) {
const [loaded, setLoaded] = useState(false)
if (!loaded) {
return (
<img
src={toolkit.logo}
alt=""
className="hidden"
onLoad={(event) => {
const img = event.currentTarget
if (img.naturalWidth > 1 && img.naturalHeight > 1) {
setLoaded(true)
} else {
onInvalid(toolkit.slug)
}
}}
onError={() => onInvalid(toolkit.slug)}
/>
)
}
return (
<div
title={`${toolkit.name}: ${toolkit.description}`}
aria-label={toolkit.name}
className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60"
>
<img
src={toolkit.logo}
alt=""
className="size-5 shrink-0 object-contain"
onError={() => onInvalid(toolkit.slug)}
/>
</div>
)
}
export function HomeView({
tree,
@ -255,9 +206,6 @@ export function HomeView({
const [slackMessages, setSlackMessages] = useState<SlackFeedMessage[]>([])
const [slackError, setSlackError] = useState<string | null>(null)
const [slackErrorKind, setSlackErrorKind] = useState<string | null>(null)
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
const loadEvents = useCallback(async () => {
try {
@ -313,40 +261,7 @@ export function HomeView({
}
}, [])
const loadConnectorLogos = useCallback(async () => {
if (cachedToolkitLogosLoaded) return
try {
const configured = await window.ipc.invoke('composio:is-configured', null)
if (!configured.configured) return
const toolkits = await window.ipc.invoke('composio:list-toolkits', {})
const previews = toolkits.items
.filter((toolkit) => Boolean(toolkit.meta.logo))
.slice(0, TOOLKIT_PREVIEW_LIMIT)
.map((toolkit) => ({
slug: toolkit.slug,
logo: toolkit.meta.logo,
name: toolkit.name,
description: toolkit.meta.description,
}))
cachedToolkitPreviews = previews
setToolkitPreviews(previews)
} catch {
cachedToolkitPreviews = []
} finally {
cachedToolkitLogosLoaded = true
setToolkitLogosLoaded(true)
}
}, [])
const removeToolkitPreview = useCallback((slug: string) => {
setToolkitPreviews((prev) => {
const next = prev.filter((toolkit) => toolkit.slug !== slug)
cachedToolkitPreviews = next
return next
})
}, [])
useEffect(() => { void loadEvents(); void loadEmails(); void loadSlackMessages(); void loadConnectorLogos() }, [loadEvents, loadEmails, loadSlackMessages, loadConnectorLogos])
useEffect(() => { void loadEvents(); void loadEmails(); void loadSlackMessages() }, [loadEvents, loadEmails, loadSlackMessages])
// Upcoming (not-yet-ended) events, soonest first.
const upcoming = useMemo(() => {
@ -624,41 +539,7 @@ export function HomeView({
)}
{/* Tool connections */}
<div className={CARD}>
<div className="flex items-start gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
<Plug className="size-[14px]" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13.5px] leading-snug">
<span className="font-medium">Connect your tools.</span>
<span className="text-muted-foreground"> Bring context from the apps you already use.</span>
</div>
<div className="mt-3 flex min-h-5 flex-wrap items-center gap-1.5">
{toolkitLogosLoaded && toolkitPreviews.map((toolkit) => (
<ToolkitPreviewIcon
key={toolkit.slug}
toolkit={toolkit}
onInvalid={removeToolkitPreview}
/>
))}
<button
type="button"
onClick={() => setConnectionsSettingsOpen(true)}
className="ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 text-[12px] font-medium text-primary hover:underline"
>
Connections
<ArrowRight className="size-3" />
</button>
</div>
</div>
</div>
</div>
<SettingsDialog
defaultTab="connections"
open={connectionsSettingsOpen}
onOpenChange={setConnectionsSettingsOpen}
/>
<ToolConnectionsCard />
{/* Open chat CTA */}
{onOpenChat && (

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
ArrowLeft,
ChevronRight,
@ -46,17 +46,21 @@ export type KnowledgeViewActions = {
onOpenInNewTab?: (path: string) => void
}
export type KnowledgeViewMode = 'graph' | 'basis' | 'files'
type KnowledgeViewProps = {
tree: TreeNode[]
actions: KnowledgeViewActions
mode: KnowledgeViewMode
onModeChange: (mode: KnowledgeViewMode) => void
graphContent: ReactNode
basisContent: ReactNode
// Folder currently being browsed (null = root overview). Controlled by the
// app so drill-down participates in the global back/forward history.
folderPath: string | null
onNavigateFolder: (path: string | null) => void
onOpenNote: (path: string) => void
onOpenGraph: () => void
onOpenSearch: () => void
onOpenBases: () => void
onVoiceNoteCreated?: (path: string) => void
}
@ -145,12 +149,14 @@ function displayName(node: TreeNode): string {
export function KnowledgeView({
tree,
actions,
mode,
onModeChange,
graphContent,
basisContent,
folderPath,
onNavigateFolder,
onOpenNote,
onOpenGraph,
onOpenSearch,
onOpenBases,
onVoiceNoteCreated,
}: KnowledgeViewProps) {
const [renameTarget, setRenameTarget] = useState<string | null>(null)
@ -184,27 +190,46 @@ export function KnowledgeView({
<div className="flex h-full flex-col overflow-hidden">
<div className="shrink-0 flex items-start justify-between gap-4 border-b border-border px-8 py-6">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">Notes</h1>
<h1 className="text-2xl font-bold tracking-tight">Brain</h1>
<p className="mt-1 text-sm text-muted-foreground">
{totalNotes} {totalNotes === 1 ? 'note' : 'notes'} across {folders.length}{' '}
{folders.length === 1 ? 'folder' : 'folders'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="inline-flex overflow-hidden rounded-lg border border-border bg-background">
<ViewModeButton
icon={Network}
label="Graph"
active={mode === 'graph'}
onClick={() => onModeChange('graph')}
/>
<ViewModeButton
icon={Table2}
label="Base"
active={mode === 'basis'}
onClick={() => onModeChange('basis')}
/>
<ViewModeButton
icon={FileText}
label="Files"
active={mode === 'files'}
onClick={() => onModeChange('files')}
/>
</div>
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
<SecondaryButton icon={SearchIcon} label="Search" onClick={onOpenSearch} />
<SecondaryButton icon={Network} label="Graph" onClick={onOpenGraph} />
<button
type="button"
onClick={() => actions.createNote(currentFolder?.path)}
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
<FilePlus className="size-4" />
<span>New note</span>
</button>
</div>
</div>
{mode === 'graph' ? (
<div className="flex-1 min-h-0 overflow-hidden">
{graphContent}
</div>
) : mode === 'basis' ? (
<div className="flex-1 min-h-0 overflow-hidden">
{basisContent}
</div>
) : (
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-3xl px-8 py-6">
{currentFolder ? (
@ -267,11 +292,12 @@ export function KnowledgeView({
<QuickActions
actions={actions}
currentFolder={currentFolder}
onOpenBases={onOpenBases}
onOpenSearch={onOpenSearch}
onFolderCreated={setRenameTarget}
/>
</div>
</div>
)}
</div>
)
}
@ -279,12 +305,12 @@ export function KnowledgeView({
function QuickActions({
actions,
currentFolder,
onOpenBases,
onOpenSearch,
onFolderCreated,
}: {
actions: KnowledgeViewActions
currentFolder: TreeNode | null
onOpenBases: () => void
onOpenSearch: () => void
onFolderCreated: (path: string) => void
}) {
// Inside a folder these target that folder; at the root they target knowledge/.
@ -294,6 +320,7 @@ function QuickActions({
<SectionHeader label="Quick actions" />
<div className="flex flex-wrap gap-2">
<QuickAction icon={FilePlus} label="New note" onClick={() => actions.createNote(parent)} />
<QuickAction icon={SearchIcon} label="Search" onClick={onOpenSearch} />
<QuickAction
icon={FolderPlus}
label="New folder"
@ -304,7 +331,6 @@ function QuickActions({
} catch { /* ignore */ }
}}
/>
<QuickAction icon={Table2} label="Open as base" onClick={onOpenBases} />
<QuickAction
icon={FolderOpen}
label={`Reveal in ${getFileManagerName()}`}
@ -315,20 +341,26 @@ function QuickActions({
)
}
function SecondaryButton({
function ViewModeButton({
icon: Icon,
label,
active,
onClick,
}: {
icon: typeof SearchIcon
label: string
active: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
aria-pressed={active}
className={cn(
'inline-flex items-center gap-1.5 px-3 py-1.5 text-sm transition-colors',
active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
)}
>
<Icon className="size-4" />
<span>{label}</span>
@ -532,7 +564,7 @@ function FolderDetail({
onClick={() => onNavigate(null)}
className="rounded-md px-1.5 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Notes
Brain
</button>
{crumbs.map((c, i) => (
<span key={c.path} className="flex min-w-0 items-center gap-1.5">

View file

@ -17,17 +17,12 @@ import {
import { Separator } from "@/components/ui/separator"
import { useBilling } from "@/hooks/useBilling"
import { toast } from "sonner"
import type { BillingUsageBucket } from "@x/shared/dist/billing.js"
import { getBillingPlanData, type BillingUsageBucket } from "@x/shared/dist/billing.js"
interface AccountSettingsProps {
dialogOpen: boolean
}
function formatPlanName(plan: string | null | undefined) {
if (!plan) return 'No Plan'
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} Plan`
}
function CreditUsageBar({ label, bucket, helper }: {
label: string
bucket: BillingUsageBucket
@ -62,7 +57,8 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
const [connecting, setConnecting] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null)
const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected)
const hasPaidSubscription = billing?.subscriptionPlan === 'starter' || billing?.subscriptionPlan === 'pro'
const currentPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
const hasPaidSubscription = currentPlan?.category === 'starter' || currentPlan?.category === 'pro'
const checkConnection = useCallback(async () => {
try {
@ -197,7 +193,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium capitalize">
{formatPlanName(billing.subscriptionPlan)}
{currentPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
</p>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
@ -209,12 +205,12 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {
})() : billing.subscriptionStatus ? (
<p className="text-xs text-muted-foreground capitalize">{billing.subscriptionStatus}</p>
) : null}
{!billing.subscriptionPlan && (
{!billing.subscriptionPlanId && (
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
)}
</div>
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
{!billing.subscriptionPlanId ? 'Subscribe' : currentPlan?.category === 'free' ? 'Upgrade' : 'Change plan'}
</Button>
</div>
<div className="space-y-3 border-t pt-3">

View file

@ -62,6 +62,7 @@ import { SettingsDialog } from "@/components/settings-dialog"
import { extractConferenceLink } from "@/lib/calendar-event"
import { useBilling } from "@/hooks/useBilling"
import { toast } from "@/lib/toast"
import { getBillingPlanData } from "@x/shared/dist/billing.js"
import { ServiceEvent } from "@x/shared/src/service-events.js"
import z from "zod"
@ -91,11 +92,6 @@ type KnowledgeActions = {
onOpenInNewTab?: (path: string) => void
}
function formatBillingPlanName(plan: string | null | undefined) {
if (!plan) return 'No plan'
return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} plan`
}
function formatAgo(ms: number): string {
const diffMs = Math.max(0, Date.now() - ms)
const min = Math.floor(diffMs / 60000)
@ -437,6 +433,7 @@ export function SidebarContentPanel({
const [loggingIn, setLoggingIn] = useState(false)
const [appUrl, setAppUrl] = useState<string | null>(null)
const { billing } = useBilling(isRowboatConnected)
const currentBillingPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null
// Nav previews: unread important emails + next upcoming meetings (top 2 each).
const [unreadEmailCount, setUnreadEmailCount] = useState(0)
@ -819,7 +816,7 @@ export function SidebarContentPanel({
>
<FileText className="size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">Knowledge</span>
<span className="truncate">Brain</span>
{knowledgeUpdatedLabel && (
<span className="truncate text-[11px] text-muted-foreground">{knowledgeUpdatedLabel}</span>
)}
@ -921,7 +918,7 @@ export function SidebarContentPanel({
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
<div className="min-w-0">
<span className="text-xs font-medium capitalize text-sidebar-foreground">
{formatBillingPlanName(billing.subscriptionPlan)}
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
</span>
{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => {
const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
@ -936,7 +933,7 @@ export function SidebarContentPanel({
onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}
className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20"
>
{!billing.subscriptionPlan || billing.subscriptionPlan === 'free' || billing.subscriptionPlan === 'starter' ? 'Upgrade' : 'Manage'}
{!billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'}
</button>
</div>
</div>

View file

@ -0,0 +1,138 @@
import { useCallback, useEffect, useState } from 'react'
import { ArrowRight, Plug } from 'lucide-react'
import { SettingsDialog } from '@/components/settings-dialog'
import { cn } from '@/lib/utils'
type ToolkitPreview = { slug: string; logo: string; name: string; description: string }
const TOOLKIT_PREVIEW_LIMIT = 8
let cachedToolkitPreviews: ToolkitPreview[] | null = null
let cachedToolkitLogosLoaded = false
function ToolkitPreviewIcon({
toolkit,
onInvalid,
}: {
toolkit: ToolkitPreview
onInvalid: (slug: string) => void
}) {
const [loaded, setLoaded] = useState(false)
if (!loaded) {
return (
<img
src={toolkit.logo}
alt=""
className="hidden"
onLoad={(event) => {
const img = event.currentTarget
if (img.naturalWidth > 1 && img.naturalHeight > 1) {
setLoaded(true)
} else {
onInvalid(toolkit.slug)
}
}}
onError={() => onInvalid(toolkit.slug)}
/>
)
}
return (
<div
title={`${toolkit.name}: ${toolkit.description}`}
aria-label={toolkit.name}
className="flex size-6 shrink-0 items-center justify-center rounded-md border border-border bg-muted/60"
>
<img
src={toolkit.logo}
alt=""
className="size-5 shrink-0 object-contain"
onError={() => onInvalid(toolkit.slug)}
/>
</div>
)
}
export function ToolConnectionsCard({ className }: { className?: string }) {
const [toolkitPreviews, setToolkitPreviews] = useState<ToolkitPreview[]>(cachedToolkitPreviews ?? [])
const [toolkitLogosLoaded, setToolkitLogosLoaded] = useState(cachedToolkitLogosLoaded)
const [connectionsSettingsOpen, setConnectionsSettingsOpen] = useState(false)
const loadConnectorLogos = useCallback(async () => {
if (cachedToolkitLogosLoaded) return
try {
const configured = await window.ipc.invoke('composio:is-configured', null)
if (!configured.configured) return
const toolkits = await window.ipc.invoke('composio:list-toolkits', {})
const previews = toolkits.items
.filter((toolkit) => Boolean(toolkit.meta.logo))
.slice(0, TOOLKIT_PREVIEW_LIMIT)
.map((toolkit) => ({
slug: toolkit.slug,
logo: toolkit.meta.logo,
name: toolkit.name,
description: toolkit.meta.description,
}))
cachedToolkitPreviews = previews
setToolkitPreviews(previews)
} catch {
cachedToolkitPreviews = []
} finally {
cachedToolkitLogosLoaded = true
setToolkitLogosLoaded(true)
}
}, [])
const removeToolkitPreview = useCallback((slug: string) => {
setToolkitPreviews((prev) => {
const next = prev.filter((toolkit) => toolkit.slug !== slug)
cachedToolkitPreviews = next
return next
})
}, [])
useEffect(() => {
void loadConnectorLogos()
}, [loadConnectorLogos])
return (
<>
<div className={cn('rounded-xl border border-border bg-card p-4', className)}>
<div className="flex items-start gap-3">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
<Plug className="size-[14px]" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13.5px] leading-snug">
<span className="text-muted-foreground">Bring context from and take action in the apps you already use.</span>
</div>
<div className="mt-3 flex min-h-5 flex-wrap items-center gap-1.5">
{toolkitLogosLoaded && toolkitPreviews.map((toolkit) => (
<ToolkitPreviewIcon
key={toolkit.slug}
toolkit={toolkit}
onInvalid={removeToolkitPreview}
/>
))}
<button
type="button"
onClick={() => setConnectionsSettingsOpen(true)}
className="ml-1 flex h-5 shrink-0 items-center gap-1 rounded-md px-1 text-[12px] font-medium text-primary hover:underline"
>
Connections
<ArrowRight className="size-3" />
</button>
</div>
</div>
</div>
</div>
<SettingsDialog
defaultTab="connections"
open={connectionsSettingsOpen}
onOpenChange={setConnectionsSettingsOpen}
/>
</>
)
}

View file

@ -14,7 +14,7 @@ export async function identifyIfSignedIn(): Promise<void> {
if (!billing.userId) return;
identify(billing.userId, {
...(billing.userEmail ? { email: billing.userEmail } : {}),
plan: billing.subscriptionPlan,
plan: billing.subscriptionPlanId,
status: billing.subscriptionStatus,
});
} catch (err) {

View file

@ -158,6 +158,8 @@ Unlike other AI assistants that start cold every session, you have access to a l
When a user asks you to prep them for a call with someone, you already know every prior decision, concerns they've raised, and commitments on both sides - because memory has been accumulating across every email and call, not reconstructed on demand.
## The Knowledge Graph
The knowledge graph is the user's **Brain**. If the user says "my brain", "the brain", "look into your brain", "check my brain", "Brain", or similar, they mean the knowledge graph stored in \`knowledge/\`. Treat "Brain" and "knowledge graph" as the same thing.
The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into these categories:
- **Notes/** - Default location for user-authored notes. Create new notes here unless the user specifies a different folder.
- **People/** - Notes on individuals, tracking relationships, decisions, and commitments

View file

@ -202,6 +202,7 @@ Subject: Re: {original_subject}
**Drafting Guidelines:**
- Draft ONE email - do not offer multiple versions or options unless explicitly asked
- Be concise and professional
- If you include a sign-off name, use only the user's first name, never their full name
- For scheduling: propose specific times based on calendar availability
- For inquiries: answer directly or indicate what info is needed
- Reference any relevant context from memory naturally - show you remember past interactions

View file

@ -1,5 +1,6 @@
import { z, ZodType } from "zod";
import * as path from "path";
import * as os from "os";
import * as fs from "fs/promises";
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
import { agentSlackShimEnv } from "../../slack/agent-slack-exec.js";
@ -20,6 +21,8 @@ import type { CodeModeManager } from "../../code-mode/acp/manager.js";
import type { CodePermissionRegistry } from "../../code-mode/acp/permission-registry.js";
import { ICodeModeConfigRepo } from "../../code-mode/repo.js";
import type { ApprovalPolicy } from "@x/shared/dist/code-mode.js";
import type { ICodeProjectsRepo } from "../../code-mode/projects/repo.js";
import * as gitService from "../../code-mode/git/service.js";
// Inputs for the bg-task builtin tools. Reuse the canonical schema field
// descriptions; only `triggers` gets a tighter contextual override (the
@ -32,6 +35,9 @@ const CreateBackgroundTaskInput = BackgroundTaskSchema.pick({
provider: true,
}).extend({
triggers: TriggersSchema.optional().describe('All three sub-fields (cronExpr, windows, eventMatchCriteria) are independently optional — mix freely. No triggers at all = manual-only (user clicks Run).'),
projectDir: z.string().optional().describe(
"Set this ONLY when the user wants the task to WRITE CODE. An absolute path (or ~/…) to a LOCAL GIT REPOSITORY with at least one commit. It turns this into a *coding task*: each run scans the trigger source for actionable items and implements them autonomously in isolated git worktrees off this repo — never touching the user's checkout. Extract the directory from the user's request (e.g. 'use ~/Work/space/test as the work directory'). Omit for ordinary output/action tasks.",
),
});
const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
@ -44,7 +50,43 @@ const PatchBackgroundTaskInput = BackgroundTaskSchema.pick({
}).partial().extend({
slug: z.string().describe('The slug of the task to update (the folder name under bg-tasks/).'),
triggers: TriggersSchema.optional().describe('Replace the triggers object. To remove all triggers (make manual-only) pass an empty object.'),
projectDir: z.string().optional().describe("Point an existing task at a code repo (or change which one) to make it a coding task. Absolute path or ~/… to a local git repository with at least one commit. Same rules as on create."),
});
// Turn a user-supplied directory into a registered code project id. Reuses the
// same idempotent registry the Code-section picker writes to (add() validates the
// dir exists & is a directory, and dedupes by resolved path). Returns a soft
// `warning` — not an error — when the repo isn't yet worktree-ready, so the task
// still gets created and the copilot can tell the user what to fix.
function expandHome(p: string): string {
const t = p.trim();
if (t === '~') return os.homedir();
if (t.startsWith('~/') || t.startsWith(`~${path.sep}`)) return path.join(os.homedir(), t.slice(2));
return t;
}
async function resolveCodeProject(dirPath: string): Promise<
{ ok: true; projectId: string; path: string; warning?: string } | { ok: false; error: string }
> {
const abs = path.resolve(expandHome(dirPath));
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
let project: Awaited<ReturnType<ICodeProjectsRepo['add']>>;
try {
project = await projectsRepo.add(abs);
} catch (err) {
return { ok: false, error: `Could not use '${dirPath}' as a code directory: ${err instanceof Error ? err.message : String(err)}` };
}
// Worktree isolation needs a real git repo with at least one commit
// (codeSessionService.create throws otherwise). Surface it now as a soft
// warning rather than letting the next run fail silently.
let warning: string | undefined;
try {
const info = await gitService.repoInfo(project.path);
if (!info.isGitRepo) warning = `${project.path} is not a git repository yet — run \`git init\` and make a commit, or the coding sessions will fail.`;
else if (!info.hasCommits) warning = `${project.path} has no commits yet — make an initial commit, or the coding sessions will fail.`;
} catch { /* best effort — worktree creation will surface it later */ }
return { ok: true, projectId: project.id, path: project.path, ...(warning ? { warning } : {}) };
}
import { ensureLoaded as ensureBrowserSkillsLoaded, readSkillContent as readBrowserSkillContent, refreshFromRemote as refreshBrowserSkills } from "../browser-skills/index.js";
import type { ToolContext } from "./exec-tool.js";
import { generateText } from "ai";
@ -1490,15 +1532,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
inputSchema: CreateBackgroundTaskInput,
execute: async (input: z.infer<typeof CreateBackgroundTaskInput>) => {
try {
let projectId: string | undefined;
let warning: string | undefined;
if (input.projectDir) {
const r = await resolveCodeProject(input.projectDir);
if (!r.ok) return { success: false, error: r.error };
projectId = r.projectId;
warning = r.warning;
}
const { createTask } = await import("../../background-tasks/fileops.js");
const result = await createTask({
name: input.name,
instructions: input.instructions,
...(input.triggers ? { triggers: input.triggers } : {}),
...(projectId ? { projectId } : {}),
...(input.model ? { model: input.model } : {}),
...(input.provider ? { provider: input.provider } : {}),
});
return { success: true, slug: result.slug };
return { success: true, slug: result.slug, ...(warning ? { warning } : {}) };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
@ -1511,9 +1562,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => {
try {
const { patchTask } = await import("../../background-tasks/fileops.js");
const { slug, ...partial } = input;
const { slug, projectDir, ...partial } = input;
let warning: string | undefined;
if (projectDir) {
const r = await resolveCodeProject(projectDir);
if (!r.ok) return { success: false, error: r.error };
(partial as { projectId?: string }).projectId = r.projectId;
warning = r.warning;
}
const result = await patchTask(slug, partial);
return { success: true, task: result };
return { success: true, task: result, ...(warning ? { warning } : {}) };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
@ -1549,6 +1607,35 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
},
},
'launch-code-task': {
description: "Launch an autonomous coding session that implements a unit of work in the bg-task's pinned code repo. ONLY usable from a coding background task (one with a configured code project). The session runs full-auto in its own isolated git worktree/branch — it never touches the user's checkout — and runs asynchronously: this returns as soon as the session is created, so you can launch several (one per group of related items) in the same run. The tool writes and later updates a row under a `## Code Sessions` section in the task's index.md — do NOT edit that section yourself. Write an excellent, fully self-contained `prompt`: the coding agent has no other context and no human to ask. Group related items into one call; split unrelated items into separate calls.",
inputSchema: z.object({
taskSlug: z.string().describe("The slug of THIS background task (it's in your run message, e.g. 'implement-meeting-items'). Used to find the pinned repo and to update index.md."),
meeting: z.string().min(1).describe("The name/title of the meeting these items came from (e.g. 'Eng Sync — 2026-06-18'). Sessions are grouped under this heading in index.md so the user can see which meeting each change came from."),
title: z.string().min(1).max(120).describe("Short human title for this unit of work — one line in index.md (e.g. 'Add retry to upload client')."),
items: z.string().min(1).describe("Brief description of the action item(s) this session implements, for the summary row (e.g. 'Fix flaky upload + add retry; raised in standup')."),
prompt: z.string().min(1).describe("The full, self-contained coding instruction. Include the concrete goal, relevant context from the meeting, any files/areas to look at, and what 'done' means. The agent runs autonomously with no human — be specific and complete."),
context: z.string().optional().describe("Optional extra context, e.g. the relevant excerpt from the meeting."),
}),
execute: async (input: { taskSlug: string; meeting: string; title: string; items: string; prompt: string; context?: string }, ctx?: ToolContext) => {
try {
const { launchCodeTask } = await import("../../background-tasks/code-sessions.js");
const result = await launchCodeTask({
taskSlug: input.taskSlug,
meeting: input.meeting,
title: input.title,
items: input.items,
prompt: input.prompt,
...(input.context ? { context: input.context } : {}),
...(ctx?.runId ? { runId: ctx.runId } : {}),
});
return result;
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
},
},
'notify-user': {
description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.",
inputSchema: z.object({

View file

@ -47,6 +47,12 @@ On every run: perform the action using the appropriate tool (Slack, email, web-f
If your instructions imply BOTH ("summarize and email it"), do both per run.
CODE MODE implement code via isolated sessions.
Only available when the run message contains a **"# Coding task"** block (the task is pinned to a code repository). In that case:
- Detect actionable coding items from the source (e.g. the meeting notes named in the trigger), conservatively. Only implement clearly-scoped, self-contained items. Ambiguous, large/architectural, or other-repo items list them in \`index.md\` as "needs review"; do not code them.
- Group related items, then call \`launch-code-task\` once per group (\`taskSlug\` is your own slug). It runs full-auto in an isolated worktree and **owns the \`## Code Sessions\` section of \`index.md\`** — never edit those rows yourself. Write a complete, self-contained \`prompt\`: the coding agent has no other context and no human to ask.
- If nothing is actionable, launch nothing and say so in your summary.
# Triggers
The run message tells you which trigger fired and how to interpret it:
@ -76,11 +82,21 @@ The workspace lives at \`${WorkDir}\`.
`;
export function buildBackgroundTaskAgent(): z.infer<typeof Agent> {
// A running bg-task must not manage bg-tasks: re-running itself risks a
// recursive cascade, and patch/create can clobber its own task.yaml (a weak
// model has done exactly this, dropping the pinned projectId). It implements
// code via `launch-code-task`, not by editing task specs.
const EXCLUDED = new Set([
'executeCommand', // headless: no interactive approval
'code_agent_run', // headless: needs interactive permission UI
'run-background-task-agent',
'create-background-task',
'patch-background-task',
]);
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
// code_agent_run requires an interactive UI for permission approvals — skip it
// here (headless) so it can't hang on an approval no one can answer.
if (name === 'executeCommand' || name === 'code_agent_run') continue;
if (EXCLUDED.has(name)) continue;
tools[name] = { type: 'builtin', name };
}

View file

@ -0,0 +1,333 @@
import fs from 'fs/promises';
import { PrefixLogger } from '@x/shared/dist/prefix-logger.js';
import type { GitStatusFile } from '@x/shared/dist/code-sessions.js';
import container from '../di/container.js';
import type { CodeSessionService } from '../code-mode/sessions/service.js';
import type { ICodeProjectsRepo } from '../code-mode/projects/repo.js';
import * as gitService from '../code-mode/git/service.js';
import { extractAgentResponse } from '../agents/utils.js';
import { withFileLock } from '../knowledge/file-lock.js';
import { fetchTask, taskIndexPath } from './fileops.js';
const log = new PrefixLogger('BgTask:Code');
// A code session that hangs (engine wedged, never settles) shouldn't pin a
// "running…" row forever. After this long we finalize from whatever the
// worktree shows and tell the user to check the session.
const MAX_WATCH_MS = 90 * 60 * 1000;
// A single bg-task run must not spawn an unbounded fleet of code sessions — a
// weak model has called this 11+ times in one run. Cap per agent run.
const MAX_LAUNCHES_PER_RUN = 5;
const launchesPerRun = new Map<string, number>();
export interface LaunchCodeTaskArgs {
/** The bg-task slug — used to find the pinned projectId and to write index.md. */
taskSlug: string;
/** The meeting these items came from — sessions are grouped under it in index.md. */
meeting: string;
/** Short human title for this unit of work (one row in index.md). */
title: string;
/** Short description of the item(s) being implemented (for the row). */
items: string;
/** The detailed, task-specific coding instruction written by the agent. */
prompt: string;
/** Optional extra context (e.g. the relevant meeting excerpt). */
context?: string;
/** The bg-task agent's runId — used to cap launches per run. */
runId?: string;
}
export interface LaunchCodeTaskResult {
success: boolean;
sessionId?: string;
branch?: string;
worktreePath?: string;
error?: string;
}
// Wrap the agent-authored task body in a robust autonomous-coding scaffold so
// every launch gets a strong, self-contained first message regardless of how
// the agent phrased its part. The session runs full-auto (yolo) with no human.
function buildCodePrompt(args: { prompt: string; branch: string; context?: string }): string {
const { prompt, branch, context } = args;
return `You are an autonomous coding agent. There is NO human present to answer questions, approve steps, or review mid-way — make reasonable decisions and drive the task to a complete, working result on your own.
${context ? `## Context\n${context}\n\n` : ''}## Task
${prompt}
## Operating rules
- You are on an isolated branch/worktree (\`${branch}\`). Work only within this repository; your changes never touch the user's main checkout.
- Implement the task end-to-end. Do not stop half-way, leave TODOs/stubs, or defer work back to the user.
- Before you start, briefly explore the repo to match its existing conventions, structure, and style.
- After implementing, VERIFY: run the project's build / typecheck / lint and any directly relevant tests. Fix anything you break.
- Make small, logically-scoped git commits with clear messages as you go.
- Stay in scope don't refactor unrelated code or make sweeping changes the task didn't ask for.
- If the task is genuinely ambiguous or blocked (missing dependency, contradictory requirement), make the safest reasonable partial progress and clearly flag what's blocked in your final summary never guess in a way that could be destructive.
## When done
Finish your response with a section titled exactly \`## Summary\` as the LAST thing you write — nothing after it. Under it, put 25 short bullet points only: what you changed, which files/areas, how you verified it, and any follow-ups or blockers. No narration or preamble inside the summary (no "I then…", "Let me…") — just the facts. This section is shown to the user verbatim, so keep it clean and self-contained.`;
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// The code agent's final message is mostly streamed narration ("Let me view it
// in context…"). We instruct it to end with a `## Summary` section — extract just
// that. Fall back to the last paragraph if it didn't comply.
const SUMMARY_MAX_CHARS = 900;
function cleanSummary(text: string): string {
if (!text) return '';
let body: string;
const idx = text.toLowerCase().lastIndexOf('## summary');
if (idx >= 0) {
body = text.slice(idx + '## summary'.length).trim();
} else {
const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
body = paras.length ? paras[paras.length - 1] : text.trim();
}
// Drop empty lines and any leftover heading markers; keep bullet structure.
const lines = body.split('\n').map((l) => l.replace(/^#+\s*/, '').trimEnd()).filter((l) => l.trim() !== '');
let out = lines.join('\n').trim();
if (out.length > SUMMARY_MAX_CHARS) out = out.slice(0, SUMMARY_MAX_CHARS).trimEnd() + '…';
return out;
}
// Render a summary as a clean markdown blockquote, preserving its bullet lines.
function quoteSummary(summary: string): string[] {
const cleaned = cleanSummary(summary);
if (!cleaned) return [];
return ['', ...cleaned.split('\n').map((l) => (l.trim() ? `> ${l.trim()}` : '>'))];
}
const SECTION_HEADING = '## Code Sessions';
function startMarker(id: string): string { return `<!-- cs-start:${id} -->`; }
function endMarker(id: string): string { return `<!-- cs-end:${id} -->`; }
function meetingHeading(meeting: string): string {
return `### 📅 ${meeting}`;
}
function runningBlock(args: { sessionId: string; title: string; items: string; branch: string; worktreePath: string }): string {
const { sessionId, title, items, branch, worktreePath } = args;
return [
startMarker(sessionId),
`#### ⏳ ${title}`,
`- **Items:** ${items}`,
`- **Branch:** \`${branch}\``,
`- **Worktree:** \`${worktreePath}\``,
`- **Session:** \`${sessionId}\` _(running…)_`,
endMarker(sessionId),
].join('\n');
}
// Append a "running" block for a freshly launched session, grouped under its
// meeting's heading inside the Code Sessions section (creating section/heading as
// needed). Serialized via the index.md file lock so concurrent launches don't
// clobber each other.
async function appendRunningBlock(slug: string, meeting: string, block: string): Promise<void> {
const indexPath = taskIndexPath(slug);
await withFileLock(indexPath, async () => {
let content = '';
try {
content = await fs.readFile(indexPath, 'utf-8');
} catch {
content = '';
}
if (!content.includes(SECTION_HEADING)) {
const sep = content.endsWith('\n') || content === '' ? '' : '\n';
content += `${sep}\n${SECTION_HEADING}\n`;
}
const heading = meetingHeading(meeting);
const lines = content.split('\n');
const headingIdx = lines.findIndex((l) => l.trim() === heading);
if (headingIdx === -1) {
// New meeting group — append heading + block at the end.
if (!content.endsWith('\n')) content += '\n';
content += `\n${heading}\n\n${block}\n`;
} else {
// Existing meeting — insert this block right after the heading so
// sessions stay grouped (newest first within the group).
lines.splice(headingIdx + 1, 0, '', block);
content = lines.join('\n');
}
await fs.writeFile(indexPath, content, 'utf-8');
});
}
// Replace a session's block in place once its run settles.
async function finalizeBlock(slug: string, sessionId: string, block: string): Promise<void> {
const indexPath = taskIndexPath(slug);
await withFileLock(indexPath, async () => {
let content = '';
try {
content = await fs.readFile(indexPath, 'utf-8');
} catch {
return; // nothing to finalize against
}
const re = new RegExp(`${escapeRegExp(startMarker(sessionId))}[\\s\\S]*?${escapeRegExp(endMarker(sessionId))}`);
if (re.test(content)) {
content = content.replace(re, block);
} else {
// The running block went missing (manual edit?) — append the final one.
if (!content.endsWith('\n')) content += '\n';
content += `\n${block}\n`;
}
await fs.writeFile(indexPath, content, 'utf-8');
});
}
// Once the code turn settles, summarize from the worktree diff + the agent's
// final message and rewrite the row.
async function finalizeFromResult(
slug: string,
args: { sessionId: string; title: string; items: string; branch: string; worktreePath: string; baseBranch?: string; timedOut?: boolean; error?: string },
): Promise<void> {
const { sessionId, title, items, branch, worktreePath, baseBranch, timedOut, error } = args;
let summary = '';
try {
summary = (await extractAgentResponse(sessionId)) ?? '';
} catch { /* best effort */ }
// Count everything the session changed since it forked — including commits
// (the autonomous scaffold tells the agent to commit, so working-tree status
// alone would read as "no changes"). Fall back to working-tree status if we
// don't know the base.
let files: GitStatusFile[] = [];
try {
files = baseBranch
? await gitService.changedSinceBase(worktreePath, baseBranch)
: await gitService.status(worktreePath);
} catch { /* worktree may be gone */ }
const ins = files.reduce((a, f) => a + (f.insertions ?? 0), 0);
const del = files.reduce((a, f) => a + (f.deletions ?? 0), 0);
let heading: string;
let status: string;
if (error) {
heading = `#### ❌ ${title}`;
status = `Failed — ${error}`;
} else if (timedOut) {
heading = `#### ⌛ ${title}`;
status = `Timed out — open the session to check progress`;
} else if (files.length > 0) {
heading = `#### ✅ ${title}`;
status = `Implemented — ${files.length} file(s) changed (+${ins} / -${del})`;
} else {
heading = `#### ⚠️ ${title}`;
status = `No file changes — open the session for details`;
}
const fileLines = files.slice(0, 25).map((f) => ` - \`${f.path}\` (${f.state})`);
const more = files.length > 25 ? [` - …and ${files.length - 25} more`] : [];
const block = [
startMarker(sessionId),
heading,
`- **Items:** ${items}`,
`- **Branch:** \`${branch}\``,
`- **Session:** \`${sessionId}\``,
`- **Status:** ${status}`,
...(files.length > 0 ? ['- **Files:**', ...fileLines, ...more] : []),
...quoteSummary(summary),
endMarker(sessionId),
].join('\n');
await finalizeBlock(slug, sessionId, block);
}
/**
* Launch a coding session for a bg-task, asynchronously.
*
* Creates an isolated worktree session (yolo, direct, claude), fires the prompt
* without waiting, writes a "running" row into the task's index.md, and detaches
* a watcher that finalizes the row once the turn settles. Returns as soon as the
* session exists so the bg-task agent can launch more groups (or finish).
*/
export async function launchCodeTask(args: LaunchCodeTaskArgs): Promise<LaunchCodeTaskResult> {
const { taskSlug, meeting, title, items, prompt, context, runId } = args;
// Per-run launch cap — stop a runaway agent from spawning a session fleet.
if (runId) {
const used = launchesPerRun.get(runId) ?? 0;
if (used >= MAX_LAUNCHES_PER_RUN) {
return { success: false, error: `Launch cap reached (${MAX_LAUNCHES_PER_RUN} code sessions per run). Group remaining items instead of launching more.` };
}
launchesPerRun.set(runId, used + 1);
}
const task = await fetchTask(taskSlug);
if (!task) {
return { success: false, error: `Background task '${taskSlug}' not found.` };
}
if (!task.projectId) {
return { success: false, error: `Task '${taskSlug}' has no configured code project (repo). Set one to use launch-code-task.` };
}
const projectsRepo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
const project = await projectsRepo.get(task.projectId);
if (!project) {
return { success: false, error: `Configured code project '${task.projectId}' is no longer registered.` };
}
const codeSessionService = container.resolve<CodeSessionService>('codeSessionService');
let session;
try {
session = await codeSessionService.create({
projectId: project.id,
title,
agent: 'claude',
mode: 'direct',
policy: 'yolo',
isolation: 'worktree',
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: `Could not create code session: ${msg}` };
}
const branch = session.worktree?.branch ?? 'rowboat/' + session.id;
const baseBranch = session.worktree?.baseBranch ?? undefined;
const worktreePath = session.cwd;
await appendRunningBlock(taskSlug, meeting, runningBlock({
sessionId: session.id, title, items, branch, worktreePath,
}));
const wrapped = buildCodePrompt({ prompt, branch, ...(context ? { context } : {}) });
log.log(`${taskSlug} — launched session ${session.id} on ${branch}`);
// Detached: drive the turn to completion, then finalize the index.md row.
// `sendMessage` resolves when the turn settles (it awaits the engine and
// never rejects on engine errors), so we don't need a separate completion
// subscription — but we still cap it with a timeout so a wedged engine can't
// pin the row at "running" forever.
void (async () => {
let timedOut = false;
try {
await Promise.race([
codeSessionService.sendMessage(session.id, wrapped),
new Promise<void>((resolve) => setTimeout(() => { timedOut = true; resolve(); }, MAX_WATCH_MS)),
]);
} catch (err) {
log.log(`${taskSlug} — session ${session.id} errored: ${err instanceof Error ? err.message : String(err)}`);
}
try {
await finalizeFromResult(taskSlug, {
sessionId: session.id, title, items, branch, worktreePath, timedOut,
...(baseBranch ? { baseBranch } : {}),
});
} catch (err) {
log.log(`${taskSlug} — finalize failed for ${session.id}: ${err instanceof Error ? err.message : String(err)}`);
}
})();
return { success: true, sessionId: session.id, branch, worktreePath };
}

View file

@ -97,6 +97,7 @@ export interface CreateTaskInput {
name: string;
instructions: string;
triggers?: BackgroundTask['triggers'];
projectId?: string;
model?: string;
provider?: string;
}
@ -136,6 +137,7 @@ export async function createTask(input: CreateTaskInput): Promise<{ slug: string
instructions: input.instructions,
active: true,
...(input.triggers ? { triggers: input.triggers } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.model ? { model: input.model } : {}),
...(input.provider ? { provider: input.provider } : {}),
createdAt: new Date().toISOString(),
@ -194,6 +196,7 @@ export async function listTasks(opts: ListTasksOptions = {}): Promise<ListTasksR
instructions: task.instructions,
active: task.active,
...(task.triggers ? { triggers: task.triggers } : {}),
...(task.projectId ? { projectId: task.projectId } : {}),
createdAt: task.createdAt,
...(task.lastAttemptAt ? { lastAttemptAt: task.lastAttemptAt } : {}),
...(task.lastRunId ? { lastRunId: task.lastRunId } : {}),

View file

@ -31,11 +31,31 @@ const BG_TASK_EVENT_DECISION_DIRECTIVE = '**Decision:** Determine whether this e
const BG_TASK_MANUAL_PAREN = 'user-triggered — either the Run button in the Background Task detail view or the `run-background-task-agent` tool';
function buildCodeBlock(slug: string, project: { id: string; path: string; name: string }): string {
return `
# Coding task
This is a **coding task**. It is pinned to a code repository:
- **Project:** ${project.name}
- **Path:** \`${project.path}\`
Your job this run:
1. Read the relevant source (e.g. the meeting notes named in the trigger below) and identify **actionable coding items** bugs to fix, features to build, concrete changes requested.
2. Be **conservative**: only implement items that are clearly scoped and self-contained. Items that are ambiguous, large/architectural, or about a different repository do NOT code them. List them briefly in \`index.md\` as "needs review" instead.
3. **Group** related items together; keep unrelated items separate.
4. For each group, call the \`launch-code-task\` tool with \`taskSlug: "${slug}"\`, the \`meeting\` name/title these items came from (so sessions are grouped by meeting), a short \`title\`, the \`items\` summary, and a **detailed, fully self-contained \`prompt\`** describing exactly what to implement (the coding agent has no other context and no human to ask). Put the relevant meeting excerpt in \`context\`.
5. \`launch-code-task\` runs asynchronously in an isolated git worktree (full-auto) and manages a \`## Code Sessions\` section in \`index.md\` itself — **do not edit that section.** You may add a short note ABOVE it summarizing what you detected.
If there are no actionable coding items, launch nothing and say so in your final summary.`;
}
function buildMessage(
slug: string,
task: BackgroundTask,
trigger: BackgroundTaskTriggerType,
context?: string,
codeProject?: { id: string; path: string; name: string },
): string {
const now = new Date();
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
@ -50,7 +70,7 @@ function buildMessage(
**Instructions:**
${task.instructions}
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).`;
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).${codeProject ? buildCodeBlock(slug, codeProject) : ''}`;
return baseMessage + buildTriggerBlock({
trigger,
@ -103,6 +123,20 @@ export async function runBackgroundTask(
// `||` not `??`: an empty-string `task.model` (occasionally synthesized
// by an LLM call to create-background-task) should fall through to the
// default just like undefined does.
// Coding tasks carry a pinned code project — resolve it so the run
// message can tell the agent which repo to work in.
let codeProject: { id: string; path: string; name: string } | undefined;
if (task.projectId) {
try {
const { default: container } = await import('../di/container.js');
const projectsRepo = container.resolve<import('../code-mode/projects/repo.js').ICodeProjectsRepo>('codeProjectsRepo');
const project = await projectsRepo.get(task.projectId);
if (project) codeProject = { id: project.id, path: project.path, name: project.name };
} catch (err) {
log.log(`${slug} — could not resolve code project ${task.projectId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
const model = task.model || await getBackgroundTaskAgentModel();
const agentRun = await createRun({
agentId: 'background-task-agent',
@ -129,9 +163,16 @@ export async function runBackgroundTask(
// we leave `lastRunAt` / `lastRunSummary` / `lastRunError` untouched —
// the previous successful run stays visible in the UI even while this
// new run is in-flight or fails.
// `projectId` is runtime-owned config the agent must never lose. A weak
// model can clobber task.yaml mid-run (despite "never touch this"), which
// would silently disable coding on later runs — so we re-assert it on
// every patch to self-heal.
const heal = task.projectId ? { projectId: task.projectId } : {};
await patchTask(slug, {
lastAttemptAt: startedAt,
lastRunId: runId,
...heal,
});
backgroundTaskBus.publish({
@ -142,7 +183,7 @@ export async function runBackgroundTask(
});
try {
await createMessage(runId, buildMessage(slug, task, trigger, context));
await createMessage(runId, buildMessage(slug, task, trigger, context, codeProject));
await waitForRunCompletion(runId, { throwOnError: true });
const summary = await extractAgentResponse(runId);
@ -151,6 +192,7 @@ export async function runBackgroundTask(
lastRunAt: new Date().toISOString(),
lastRunSummary: summary ?? undefined,
lastRunError: undefined,
...heal,
});
log.log(`${slug} — done summary="${truncate(summary)}"`);
@ -171,7 +213,7 @@ export async function runBackgroundTask(
// state; the scheduler's backoff (lastAttemptAt + 5min) prevents
// retry-storming.
try {
await patchTask(slug, { lastRunError: msg });
await patchTask(slug, { lastRunError: msg, ...heal });
} catch {
// don't mask the original error
}

View file

@ -1,8 +1,10 @@
import { getAccessToken } from '../auth/tokens.js';
import { API_URL } from '../config/env.js';
import type { BillingInfo, BillingPlan } from '@x/shared/dist/billing.js';
import type { BillingInfo, BillingPlanId } from '@x/shared/dist/billing.js';
import { getRowboatConfig } from '../config/rowboat.js';
export async function getBillingInfo(): Promise<BillingInfo> {
const config = await getRowboatConfig();
const accessToken = await getAccessToken();
const response = await fetch(`${API_URL}/v1/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
@ -16,7 +18,7 @@ export async function getBillingInfo(): Promise<BillingInfo> {
email: string;
};
billing: {
plan: BillingPlan | null;
planId: BillingPlanId | null;
status: string | null;
trialExpiresAt: string | null;
usage: {
@ -37,9 +39,10 @@ export async function getBillingInfo(): Promise<BillingInfo> {
return {
userEmail: body.user.email ?? null,
userId: body.user.id ?? null,
subscriptionPlan: body.billing.plan,
subscriptionPlanId: body.billing.planId,
subscriptionStatus: body.billing.status,
trialExpiresAt: body.billing.trialExpiresAt ?? null,
catalog: config.billing,
monthly: body.billing.usage.monthly,
daily: body.billing.usage.daily,
};

View file

@ -37,6 +37,74 @@ const STARTUP_TIMEOUT_MS = Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS) >
? Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS)
: 60_000;
export interface CodeAgentOption { value: string; label: string }
export interface CodeAgentModelOptions { models: CodeAgentOption[]; efforts: CodeAgentOption[] }
// The agent advertises its model + effort choices on the session it opens (the
// same data that backs its `/model` picker), in one of two shapes:
// - `configOptions`: select options with id "model" / "effort" (Claude).
// - `models`: a SessionModelState { availableModels: [{ modelId, name }] }
// (Codex — which folds effort into the model id, so no separate effort).
// We read configOptions first and fall back to `models`, then prepend a
// synthetic "Default" so the user can always keep the engine default.
type RawSelectOption = { value?: unknown; name?: unknown; options?: Array<{ value?: unknown; name?: unknown }> };
type RawConfigOption = { id?: string; options?: RawSelectOption[] };
type RawModelState = { availableModels?: Array<{ modelId?: unknown; name?: unknown }> };
function withDefault(choices: CodeAgentOption[]): CodeAgentOption[] {
return choices.some((c) => c.value === 'default')
? choices
: [{ value: 'default', label: 'Default' }, ...choices];
}
function toChoices(option: RawConfigOption | undefined): CodeAgentOption[] {
const flat = (option?.options ?? []).flatMap((o) => (Array.isArray(o.options) ? o.options : [o]));
return flat
.filter((o): o is { value: string; name?: unknown } => typeof o.value === 'string')
.map((o) => ({ value: o.value, label: typeof o.name === 'string' && o.name ? o.name : o.value }));
}
function modelStateChoices(models: RawModelState | undefined): CodeAgentOption[] {
return (models?.availableModels ?? [])
.filter((m): m is { modelId: string; name?: unknown } => typeof m.modelId === 'string')
.map((m) => ({ value: m.modelId, label: typeof m.name === 'string' && m.name ? m.name : m.modelId }));
}
export function extractModelOptions(configOptions: unknown, models?: unknown): CodeAgentModelOptions {
const list = (Array.isArray(configOptions) ? configOptions : []) as RawConfigOption[];
const modelOpt = list.find((o) => o.id === 'model');
const effortOpt = list.find((o) => o.id === 'effort');
const modelChoices = toChoices(modelOpt);
return {
// configOptions is authoritative when present; otherwise fall back to the
// SessionModelState list (Codex reports models only there).
models: withDefault(modelChoices.length ? modelChoices : modelStateChoices(models as RawModelState)),
efforts: effortOpt ? withDefault(toChoices(effortOpt)) : [],
};
}
// Claude's `availableModels` exposes its top model only as "Default
// (recommended)" and omits an explicit "Opus" row (the interactive `/model`
// lists it, the ACP adapter dedupes it). Surface the canonical aliases
// explicitly for clarity — the adapter resolves "opus"/"sonnet"/"haiku" to the
// concrete model. Deduped against what the engine already returned, so in
// practice this only adds the missing "Opus" entry, placed right after Default.
const CLAUDE_ALIAS_ROWS: CodeAgentOption[] = [
{ value: 'opus', label: 'Opus' },
{ value: 'sonnet', label: 'Sonnet' },
{ value: 'haiku', label: 'Haiku' },
];
function withClaudeAliases(options: CodeAgentModelOptions): CodeAgentModelOptions {
const have = new Set(options.models.map((m) => m.value));
const extra = CLAUDE_ALIAS_ROWS.filter((r) => !have.has(r.value));
if (extra.length === 0) return options;
const at = options.models.findIndex((m) => m.value === 'default');
const models = [...options.models];
models.splice(at >= 0 ? at + 1 : 0, 0, ...extra);
return { ...options, models };
}
// Map a raw ACP session/update notification onto our small CodeRunEvent union.
function toEvent(update: SessionUpdate): CodeRunEvent {
switch (update.sessionUpdate) {
@ -180,6 +248,20 @@ export class AcpClient {
}
}
// Open a throwaway session purely to read the agent's advertised model +
// effort choices, then let the caller dispose this client. Used for the
// model picker before any real session exists.
async describeModelOptions(): Promise<CodeAgentModelOptions> {
try {
const res = await this.withStartupTimeout(this.conn().newSession({ cwd: this.cwd, mcpServers: [] }));
const r = res as { configOptions?: unknown; models?: unknown };
const options = extractModelOptions(r.configOptions, r.models);
return this.agent === 'claude' ? withClaudeAliases(options) : options;
} catch (e) {
throw this.enrich(e, 'describeModelOptions');
}
}
async loadSession(sessionId: string): Promise<void> {
try {
await this.withStartupTimeout(this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] }));
@ -188,6 +270,20 @@ export class AcpClient {
}
}
// Point the open session at a specific model. The adapter resolves aliases
// ("opus"/"sonnet"/…) to concrete ids. Throws if the model is unknown; the
// caller applies this best-effort so a bad value never blocks a turn.
async setModel(sessionId: string, modelId: string): Promise<void> {
await this.conn().unstable_setSessionModel({ sessionId, modelId });
}
// Set the reasoning-effort level via the agent's "effort" config option.
// The option only exists for models that support it, so this throws for
// others — again applied best-effort by the caller.
async setEffort(sessionId: string, value: string): Promise<void> {
await this.conn().setSessionConfigOption({ sessionId, configId: 'effort', value });
}
async prompt(sessionId: string, text: string): Promise<PromptResponse> {
try {
return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] });

View file

@ -1,5 +1,6 @@
import * as os from 'os';
import type { ApprovalPolicy, CodeRunEvent, CodingAgent, PermissionAsk, PermissionDecision, RunPromptResult } from './types.js';
import { AcpClient } from './client.js';
import { AcpClient, type CodeAgentModelOptions } from './client.js';
import { PermissionBroker } from './permission-broker.js';
import { readStoredSession, writeStoredSession, clearStoredSession } from './session-store.js';
@ -9,6 +10,11 @@ export interface RunPromptArgs {
cwd: string;
prompt: string;
policy: ApprovalPolicy;
/** Coding-agent model alias/id (e.g. "opus"); applied to the ACP session
* before the prompt. Omitted / "default" leaves the engine default. */
model?: string;
/** Reasoning-effort level (e.g. "high"); applied alongside the model. */
effort?: string;
/** Called when the policy needs the user to decide (the "ask" path). */
ask: (ask: PermissionAsk) => Promise<PermissionDecision>;
/** Stream sink for this prompt's run. */
@ -56,9 +62,32 @@ const CANCEL_GRACE_MS = 2_000;
// resumes the persisted session via session/load.
export class CodeModeManager {
private readonly runs = new Map<string, ActiveRun>();
// Per-agent model/effort choices, discovered once from the engine and reused
// (the list only changes when the provider ships new models, and the app can
// be restarted to pick those up). Avoids cold-starting an adapter per picker.
private readonly modelOptionsCache = new Map<CodingAgent, CodeAgentModelOptions>();
// Discover a coding agent's available models + effort levels straight from
// the engine (what its `/model` picker would show). Spawns a short-lived
// adapter, opens a throwaway session to read its advertised options, and
// tears it down. Cached per agent for the lifetime of the process.
async listModelOptions(agent: CodingAgent): Promise<CodeAgentModelOptions> {
const cached = this.modelOptionsCache.get(agent);
if (cached) return cached;
const broker = new PermissionBroker({ policy: 'yolo', ask: async () => 'reject' });
const client = new AcpClient({ agent, cwd: os.homedir(), broker, onEvent: () => {} });
try {
await client.start();
const options = await client.describeModelOptions();
this.modelOptionsCache.set(agent, options);
return options;
} finally {
client.dispose();
}
}
async runPrompt(args: RunPromptArgs): Promise<RunPromptResult> {
const { runId, agent, cwd, prompt, policy, ask, onEvent, signal, suppressReplay } = args;
const { runId, agent, cwd, prompt, policy, model, effort, ask, onEvent, signal, suppressReplay } = args;
const broker = new PermissionBroker({
policy,
@ -67,6 +96,10 @@ export class CodeModeManager {
});
const run = await this.ensureRun(runId, agent, cwd, broker, onEvent, suppressReplay ?? false);
// Re-apply the session's model + effort each turn (idempotent): a warm
// connection keeps the last selection, but a cold session/load resets it,
// and the user may have changed it from the header since the last turn.
await this.applyModelAndEffort(run, model, effort);
run.inflight++;
let graceTimer: ReturnType<typeof setTimeout> | undefined;
@ -109,6 +142,26 @@ export class CodeModeManager {
}
}
// Best-effort: a model the engine doesn't know, or an effort level a model
// doesn't support, must not abort the turn — we log and proceed with the
// engine default rather than surfacing a hard error to the user.
private async applyModelAndEffort(run: ActiveRun, model?: string, effort?: string): Promise<void> {
if (model && model !== 'default') {
try {
await run.client.setModel(run.sessionId, model);
} catch (e) {
console.warn(`[code-mode] could not set model "${model}": ${e instanceof Error ? e.message : String(e)}`);
}
}
if (effort && effort !== 'default') {
try {
await run.client.setEffort(run.sessionId, effort);
} catch (e) {
console.warn(`[code-mode] could not set effort "${effort}": ${e instanceof Error ? e.message : String(e)}`);
}
}
}
dispose(runId: string): void {
const run = this.runs.get(runId);
if (!run) return;

View file

@ -78,6 +78,14 @@ async function repoToplevel(cwd: string): Promise<string> {
}
}
async function mergeBase(cwd: string, baseRef: string): Promise<string> {
try {
return (await git(cwd, ['merge-base', baseRef, 'HEAD'])).trim() || baseRef;
} catch {
return baseRef;
}
}
function stateFromPorcelain(xy: string): GitFileState {
if (xy === '??') return 'untracked';
if (xy.includes('R')) return 'renamed';
@ -161,6 +169,62 @@ export async function status(cwd: string): Promise<GitStatusFile[]> {
return result;
}
// Everything this worktree's branch changed since it forked from `baseRef` —
// committed AND uncommitted. `status()` only sees the working tree (uncommitted),
// so it misses work an agent committed; this is what you want for a session
// summary. Counts come from numstat, states from name-status, merged by path.
export async function changedSinceBase(cwd: string, baseRef: string): Promise<GitStatusFile[]> {
const forkPoint = await mergeBase(cwd, baseRef);
const stateByPath = new Map<string, GitFileState>();
try {
const ns = await git(cwd, ['diff', '--name-status', '-z', forkPoint]);
const parts = ns.split('\0');
for (let i = 0; i < parts.length; i++) {
const code = parts[i];
if (!code) continue;
const letter = code[0];
if (letter === 'R' || letter === 'C') {
// rename/copy: "<code>\0<old>\0<new>"
const newPath = parts[i + 2];
i += 2;
if (newPath) stateByPath.set(newPath, 'renamed');
} else {
const p = parts[i + 1];
i += 1;
if (p) stateByPath.set(p, letter === 'A' ? 'added' : letter === 'D' ? 'deleted' : 'modified');
}
}
} catch {
// bad ref / no commits — leave states empty
}
const result: GitStatusFile[] = [];
try {
const numstat = await git(cwd, ['diff', '--numstat', '-z', forkPoint]);
const rows = numstat.split('\0');
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (!row) continue;
const m = row.match(/^(\d+|-)\t(\d+|-)\t?(.*)$/);
if (!m) continue;
const insertions = m[1] === '-' ? null : Number(m[1]);
const deletions = m[2] === '-' ? null : Number(m[2]);
let filePath = m[3];
if (!filePath) {
// rename form: old and new paths follow as separate tokens
i += 2;
filePath = rows[i] ?? '';
}
if (!filePath) continue;
result.push({ path: filePath, state: stateByPath.get(filePath) ?? 'modified', insertions, deletions });
}
} catch {
// bad ref / no commits — nothing to report
}
return result;
}
export interface FileDiff {
oldText: string;
newText: string;
@ -168,7 +232,7 @@ export interface FileDiff {
tooLarge: boolean;
}
export async function fileDiff(cwd: string, relPath: string): Promise<FileDiff> {
export async function fileDiff(cwd: string, relPath: string, opts: { baseRef?: string | null } = {}): Promise<FileDiff> {
// Paths from `status` are repo-root-relative; paths clicked in the chat
// timeline are cwd-relative. Resolve whichever interpretation points at a
// real file (deleted files fall back to the root interpretation, which is
@ -189,7 +253,8 @@ export async function fileDiff(cwd: string, relPath: string): Promise<FileDiff>
}
let oldText = '';
try {
oldText = await git(cwd, ['show', `HEAD:${gitPath}`]);
const oldRef = opts.baseRef ? await mergeBase(cwd, opts.baseRef) : 'HEAD';
oldText = await git(cwd, ['show', `${oldRef}:${gitPath}`]);
} catch {
// untracked / newly added / no commits — diff against empty
oldText = '';

View file

@ -27,6 +27,10 @@ export interface CreateSessionArgs {
// LLM for Rowboat-mode turns; unset falls through to the configured default.
model?: string;
provider?: string;
// The coding agent's own model + reasoning effort (ACP engine); unset leaves
// the engine default. Re-applied to the ACP session on every turn.
agentModel?: string;
agentEffort?: string;
}
export interface SendMessageResult {
@ -142,6 +146,8 @@ export class CodeSessionService {
policy: args.policy,
cwd,
...(worktree ? { worktree } : {}),
...(args.agentModel ? { agentModel: args.agentModel } : {}),
...(args.agentEffort ? { agentEffort: args.agentEffort } : {}),
createdAt: new Date().toISOString(),
};
await this.codeSessionsRepo.save(session);
@ -149,7 +155,7 @@ export class CodeSessionService {
return session;
}
async update(sessionId: string, patch: Partial<Pick<CodeSession, 'title' | 'mode' | 'policy' | 'agent'>>): Promise<CodeSession> {
async update(sessionId: string, patch: Partial<Pick<CodeSession, 'title' | 'mode' | 'policy' | 'agent' | 'agentModel' | 'agentEffort'>>): Promise<CodeSession> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session) throw new Error(`Unknown session: ${sessionId}`);
const updated: CodeSession = { ...session, ...patch };
@ -217,6 +223,8 @@ export class CodeSessionService {
cwd: session.cwd,
prompt: text,
policy: session.policy,
...(session.agentModel ? { model: session.agentModel } : {}),
...(session.agentEffort ? { effort: session.agentEffort } : {}),
signal,
suppressReplay: true,
onEvent: (event) => {

View file

@ -109,7 +109,7 @@ export interface Classification {
const ClassificationSchema = z.object({
importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'),
summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'),
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". If a sign-off name is included, use only the user\'s first name. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
});
const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user.
@ -139,6 +139,8 @@ Could you resend it with a bit more context so I can get back to you properly?
Best,
John
If you include the user's name in the sign-off, use only their first name, never their full name.
When an email-style guide is provided below, it takes precedence: follow it for greeting, tone, sign-off, length, and phrasing patterns (while keeping the line-break structure shown above). If no style guide is provided, default to a brief, warm, professional voice.
For scheduling-related threads (where the sender proposes meeting times, asks for the user's availability, or follows up on a meeting request), look at the user's upcoming calendar (provided below) and either:

View file

@ -0,0 +1,114 @@
// Local-part aliases that are almost always automated/role addresses you don't
// compose a fresh message to. Matched as a whole segment of the local part
// (segments split on . _ - +).
const AUTOMATED_LOCAL_PARTS = new Set([
'noreply', 'no-reply', 'donotreply', 'do-not-reply', 'reply',
'notifications', 'notification', 'notify',
'alerts', 'alert', 'updates', 'update',
'news', 'newsletter', 'newsletters',
'info', 'information', 'hello', 'hi', 'hey',
'welcome', 'onboarding', 'getstarted',
'team', 'marketing', 'promo', 'promos', 'promotions',
'offer', 'offers', 'deals', 'deal',
'accounts', 'account', 'billing', 'invoices', 'statements', 'statement',
'learn', 'learning', 'courses',
'mailer-daemon', 'mailerdaemon', 'postmaster', 'bounce', 'bounces',
'automated', 'auto', 'autoconfirm',
'support-bot', 'noticeboard', 'system',
'contact', 'connect',
'sender', 'broadcast', 'digest', 'campaign', 'campaigns',
'support', 'service', 'help', 'helpdesk', 'feedback',
'mailer', 'mailers', 'members', 'membership',
'careers', 'jobs', 'recruit', 'recruiting',
'tickets', 'orders', 'order', 'receipts', 'receipt',
'applications', 'apply', 'admissions',
'health', 'security', 'auth',
]);
// Subdomain labels that flag a bulk/marketing infrastructure domain.
const AUTOMATED_SUBDOMAIN_LABELS = new Set([
'mail', 'mailer', 'mailers', 'mailing', 'mailgun', 'sendgrid', 'mta',
'email', 'em', 'e', 'm',
'news', 'newsletter', 'newsletters',
'marketing', 'mkt', 'promo', 'promos', 'offers',
'event', 'events', 'ecomm', 'commerce',
'notifications', 'notification', 'notify', 'alerts', 'alert', 'updates',
'messaging', 'message', 'msg',
'noreply', 'donotreply',
'creators', 'partners', 'team',
'info', 'welcome', 'hi', 'hello',
'bounces', 'bounce',
'reply', 'user', 'usr', 'auto',
]);
// Specific bulk-mail provider domains (substring match on full domain).
const AUTOMATED_DOMAIN_KEYWORDS = [
'facebookmail', 'kajabimail', 'substack', 'mailgun', 'sendgrid',
'mcsv.net', 'mailchimp', 'mailerlite', 'createsend', 'cmail',
'amazonses', 'sparkpost', 'sendinblue', 'brevo',
'luma-mail', 'lumamail',
'umusic-online', 'icloud-mail',
];
function localSegments(local: string): string[] {
return local.toLowerCase().split(/[._\-+]/).filter(Boolean);
}
export function isAutomatedAddress(email: string): boolean {
if (!email) return true;
const at = email.indexOf('@');
if (at < 0) return true;
const local = email.slice(0, at).toLowerCase();
const domain = email.slice(at + 1).toLowerCase();
// Plus-aliased reply bots: `reply+abc123@...`
if (/^reply\+/i.test(local)) return true;
// Encoded VERP/list aliases, e.g. long-token-arjun=rowboat...@domain.
if (local.includes('=') && /^[a-z0-9]{16,}[-+].*=/.test(local)) return true;
const segs = localSegments(local);
for (const s of segs) {
if (AUTOMATED_LOCAL_PARTS.has(s)) return true;
}
if (/(no.?reply|do.?not.?reply|notifications?|news.?letter|mailer.?daemon|postmaster|automated|broadcast|statement)/i.test(local)) {
return true;
}
if (local.length >= 20 && /^[a-z0-9=._\-+]+$/.test(local) && /[0-9]/.test(local)) {
const digits = (local.match(/[0-9]/g) || []).length;
const letters = (local.match(/[a-z]/g) || []).length;
if (digits / local.length >= 0.2 || (digits >= 3 && letters >= 12 && !local.includes('.'))) return true;
}
const labels = domain.split('.');
if (labels.length >= 3) {
const subs = labels.slice(0, -2);
for (const label of subs) {
if (AUTOMATED_SUBDOMAIN_LABELS.has(label)) return true;
}
}
for (const kw of AUTOMATED_DOMAIN_KEYWORDS) {
if (domain.includes(kw)) return true;
}
if (/(^|\.)(mailers?|mailer|mailgun|sendgrid|mailchimp|mailerlite|bounces?|marketing|promo|notifications?|newsletter)(\.|$)/i.test(domain)) {
return true;
}
const sld = labels[labels.length - 1];
if (['email', 'mail', 'marketing', 'promo', 'news', 'newsletter', 'click', 'link'].includes(sld)) {
return true;
}
// Brand-identity addresses like `uber@uber.com`, `lenovo@lenovo.com` -
// local part equals the first label of the domain. Almost always a
// transactional/marketing sender.
if (labels.length >= 2 && local === labels[0]) {
return true;
}
return false;
}

View file

@ -4,6 +4,7 @@ import path from 'path';
import { WorkDir } from '../config/config.js';
import type { GmailThreadSnapshot } from './sync_gmail.js';
import { getAccountEmail } from './sync_gmail.js';
import { isAutomatedAddress } from './contact_filters.js';
const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
const INDEX_TTL_MS = 5 * 60 * 1000;
@ -62,125 +63,6 @@ function parseAddressList(header: string): Array<{ name: string; email: string }
return result;
}
// Local-part aliases that are almost always automated/role addresses you don't
// compose a fresh message to. Matched as a whole segment of the local part
// (segments split on . _ - +).
const AUTOMATED_LOCAL_PARTS = new Set([
'noreply', 'no-reply', 'donotreply', 'do-not-reply', 'reply',
'notifications', 'notification', 'notify',
'alerts', 'alert', 'updates', 'update',
'news', 'newsletter', 'newsletters',
'info', 'information', 'hello', 'hi', 'hey',
'welcome', 'onboarding', 'getstarted',
'team', 'marketing', 'promo', 'promos', 'promotions',
'offer', 'offers', 'deals', 'deal',
'accounts', 'account', 'billing', 'invoices', 'statements', 'statement',
'learn', 'learning', 'courses',
'mailer-daemon', 'mailerdaemon', 'postmaster', 'bounce', 'bounces',
'automated', 'auto', 'autoconfirm',
'support-bot', 'noticeboard', 'system',
'contact', 'connect',
'sender', 'broadcast', 'digest', 'campaign', 'campaigns',
'support', 'service', 'help', 'helpdesk', 'feedback',
'mailer', 'mailers', 'members', 'membership',
'careers', 'jobs', 'recruit', 'recruiting',
'tickets', 'orders', 'order', 'receipts', 'receipt',
'applications', 'apply', 'admissions',
'health', 'security', 'auth',
]);
// Subdomain labels that flag a bulk/marketing infrastructure domain.
const AUTOMATED_SUBDOMAIN_LABELS = new Set([
'mail', 'mailer', 'mailers', 'mailing', 'mailgun', 'sendgrid', 'mta',
'email', 'em', 'e', 'm',
'news', 'newsletter', 'newsletters',
'marketing', 'mkt', 'promo', 'promos', 'offers',
'event', 'events', 'ecomm', 'commerce',
'notifications', 'notification', 'notify', 'alerts', 'alert', 'updates',
'messaging', 'message', 'msg',
'noreply', 'donotreply',
'creators', 'partners', 'team',
'info', 'welcome', 'hi', 'hello',
'bounces', 'bounce',
'reply', 'user', 'usr', 'auto',
]);
// Specific bulk-mail provider domains (substring match on full domain).
const AUTOMATED_DOMAIN_KEYWORDS = [
'facebookmail', 'kajabimail', 'substack', 'mailgun', 'sendgrid',
'mcsv.net', 'mailchimp', 'mailerlite', 'createsend', 'cmail',
'amazonses', 'sparkpost', 'sendinblue', 'brevo',
'luma-mail', 'lumamail',
'umusic-online', 'icloud-mail',
];
function localSegments(local: string): string[] {
return local.toLowerCase().split(/[._\-+]/).filter(Boolean);
}
function isAutomatedAddress(email: string): boolean {
if (!email) return true;
const at = email.indexOf('@');
if (at < 0) return true;
const local = email.slice(0, at).toLowerCase();
const domain = email.slice(at + 1).toLowerCase();
// Plus-aliased reply bots: `reply+abc123@…`
if (/^reply\+/i.test(local)) return true;
// Whole-segment local-part matches.
const segs = localSegments(local);
for (const s of segs) {
if (AUTOMATED_LOCAL_PARTS.has(s)) return true;
}
// Some senders pack noise into the local part with no separators
// (e.g. `hdfcbanksmartstatement`). Catch the common ones.
if (/(no.?reply|do.?not.?reply|notifications?|news.?letter|mailer.?daemon|postmaster|automated|broadcast|statement)/i.test(local)) {
return true;
}
// Random-looking machine local parts: long, mostly hex/base32-ish.
if (local.length >= 20 && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(local) && /[0-9]/.test(local)) {
const digits = (local.match(/[0-9]/g) || []).length;
if (digits / local.length >= 0.25) return true;
}
// Subdomain-label check (everything except the registrable last two labels).
const labels = domain.split('.');
if (labels.length >= 3) {
const subs = labels.slice(0, -2);
for (const label of subs) {
if (AUTOMATED_SUBDOMAIN_LABELS.has(label)) return true;
}
}
// Provider keyword anywhere in the domain.
for (const kw of AUTOMATED_DOMAIN_KEYWORDS) {
if (domain.includes(kw)) return true;
}
// Domain itself contains tell-tale tokens.
if (/(^|\.)(mailers?|mailer|mailgun|sendgrid|mailchimp|mailerlite|bounces?|marketing|promo|notifications?|newsletter)(\.|$)/i.test(domain)) {
return true;
}
// Marketing-style TLD / second-level domain (e.g. bookmyshow.email,
// foo.marketing, bar.news). These domains exist almost exclusively for bulk.
const sld = labels[labels.length - 1];
if (['email', 'mail', 'marketing', 'promo', 'news', 'newsletter', 'click', 'link'].includes(sld)) {
return true;
}
// Brand-identity addresses like `uber@uber.com`, `lenovo@lenovo.com` —
// local part equals the first label of the domain. Almost always a
// transactional/marketing sender.
if (labels.length >= 2 && local === labels[0]) {
return true;
}
return false;
}
function ingestSnapshot(snapshot: GmailThreadSnapshot, selfEmail: string, map: Map<string, IndexEntry>): void {
if (!snapshot?.messages) return;
for (const msg of snapshot.messages) {

View file

@ -6,6 +6,7 @@ import { OAuth2Client } from 'google-auth-library';
import { WorkDir } from '../config/config.js';
import { GoogleClientFactory } from './google-client-factory.js';
import { getUserEmail } from './classify_thread.js';
import { isAutomatedAddress } from './contact_filters.js';
const STATE_FILE = path.join(WorkDir, 'contacts_sent.json');
const RECENCY_HALFLIFE_DAYS = 60;
@ -104,6 +105,7 @@ async function saveState(state: StoredState): Promise<void> {
function indexFromStored(state: StoredState): Map<string, IndexEntry> {
const map = new Map<string, IndexEntry>();
for (const e of state.entries) {
if (isAutomatedAddress(e.email)) continue;
map.set(e.email, {
name: e.name,
email: e.email,
@ -167,6 +169,7 @@ async function ingestMessage(
];
for (const { name, email } of recipients) {
if (!email || email === selfEmail) continue;
if (isAutomatedAddress(email)) continue;
let entry = map.get(email);
if (!entry) {
entry = { name, email, count: 0, lastSeenMs: 0, nameCounts: new Map() };
@ -374,6 +377,7 @@ export async function searchSentContacts(query: string, opts: SearchOpts = {}):
const matches: Array<{ entry: IndexEntry; tier: number; s: number }> = [];
for (const entry of cachedIndex.values()) {
if (excluded.has(entry.email)) continue;
if (isAutomatedAddress(entry.email)) continue;
const tier = matchTier(q, entry);
if (tier < 0) continue;
matches.push({ entry, tier, s: score(entry, nowMs) });

View file

@ -6,6 +6,7 @@ import container from '../../di/container.js';
import { IGranolaConfigRepo } from './repo.js';
import { serviceLogger } from '../../services/service_logger.js';
import { limitEventItems } from '../limit_event_items.js';
import { publishMeetingNotesReadyEvent } from '../meeting-events.js';
import {
GetDocumentsResponse,
SyncState,
@ -439,6 +440,14 @@ async function syncNotes(): Promise<void> {
} else {
console.log(`[Granola] Saved: ${filename}`);
newCount++;
// First-time write only — don't re-fire on later edits to the
// same note (Granola notes update live).
await publishMeetingNotesReadyEvent({
source: 'granola',
title: docTitle,
filePath,
when: docDate.toISOString(),
});
}
// Update state

View file

@ -166,7 +166,7 @@ If there are events, include them:
1. Use \`file-list\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`)
2. Use \`file-readText\` to read the email markdown files (e.g. \`gmail_sync/threadid123.md\`)
3. Check the frontmatter \`action\` field — emails with \`action: reply\` or \`action: respond\` need a response
4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example:
4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. If a draft includes a sign-off name, use only the user's first name, never their full name. Example:
\`\`\`
\\\`\\\`\\\`emails

View file

@ -0,0 +1,37 @@
import { createEvent } from '../events/producer.js';
// Emitted when a meeting note/transcript is first written to disk (Fireflies,
// Granola, …). This is the natural "the meeting is over and we have content"
// signal — unlike a calendar end-time, the notes actually exist now. Coding
// background tasks subscribe to it (via eventMatchCriteria) to scan freshly
// landed notes for actionable coding items.
//
// Fire ONCE per meeting (on first write), not on every re-sync/edit, so a note
// that keeps updating doesn't re-trigger downstream agents.
export async function publishMeetingNotesReadyEvent(args: {
source: string;
title: string;
filePath: string;
when?: string;
}): Promise<void> {
const { source, title, filePath, when } = args;
try {
await createEvent({
source,
type: 'meeting.notes_ready',
createdAt: new Date().toISOString(),
payload: [
`# Meeting notes ready`,
``,
`**Title:** ${title}`,
when ? `**When:** ${when}` : ``,
`**Source:** ${source}`,
`**Notes file:** \`${filePath}\``,
``,
`The full meeting notes/transcript are at the path above. They may contain coding action items (bugs to fix, features to build, changes requested). Read the file to decide whether to act.`,
].filter(Boolean).join('\n'),
});
} catch (err) {
console.error(`[${source}] Failed to publish meeting.notes_ready event:`, err);
}
}

View file

@ -4,6 +4,7 @@ import { WorkDir } from '../config/config.js';
import { FirefliesClientFactory } from './fireflies-client-factory.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
import { publishMeetingNotesReadyEvent } from './meeting-events.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'fireflies');
@ -583,6 +584,15 @@ async function syncMeetings() {
fs.writeFileSync(filePath, markdown);
console.log(`[Fireflies] Saved: ${filename}`);
// First-time write for this meeting (guarded by syncedIds above) —
// signal that fresh notes are available for downstream agents.
await publishMeetingNotesReadyEvent({
source: 'fireflies',
title: meetingData.title || 'untitled',
filePath,
...(meetingData.dateString ? { when: meetingData.dateString } : {}),
});
syncedIds.add(meetingId);
newCount++;
processedInBatch++;

View file

@ -4,7 +4,7 @@ import { IModelConfigRepo } from "./repo.js";
import { isSignedIn } from "../account/account.js";
import container from "../di/container.js";
const SIGNED_IN_DEFAULT_MODEL = "gpt-5.4";
const SIGNED_IN_DEFAULT_MODEL = "anthropic/claude-opus-4.7";
const SIGNED_IN_DEFAULT_PROVIDER = "rowboat";
const SIGNED_IN_KG_MODEL = "google/gemini-3.1-flash-lite";
const SIGNED_IN_LIVE_NOTE_AGENT_MODEL = "google/gemini-3.1-flash-lite";

View file

@ -166,6 +166,7 @@ Subject: Re: {original_subject}
**Drafting Guidelines:**
- Be concise and professional
- If you include a sign-off name, use only the user's first name, never their full name
- For scheduling: propose specific times based on calendar availability
- For inquiries: answer directly or indicate what info is needed
- Reference any relevant context from memory naturally

View file

@ -27,6 +27,10 @@ export type BackgroundTask = {
instructions: string;
active: boolean;
triggers?: Triggers;
// When set, this is a *coding* task: it implements code in the pinned code
// project (a registered repo) via the `launch-code-task` tool, each launch
// running in its own isolated worktree. Omit for ordinary OUTPUT/ACTION tasks.
projectId?: string;
model?: string;
provider?: string;
createdAt: string;
@ -48,6 +52,7 @@ export type BackgroundTaskSummary = {
instructions: string;
active: boolean;
triggers?: Triggers;
projectId?: string;
createdAt: string;
lastAttemptAt?: string;
lastRunId?: string;
@ -56,11 +61,14 @@ export type BackgroundTaskSummary = {
lastRunError?: string;
};
// NOTE: keep `BackgroundTaskSummary` (above) and `BackgroundTask` (top) in sync.
export const BackgroundTaskSchema = z.object({
name: z.string().min(1).describe('User-facing display name.'),
instructions: z.string().min(1).describe('A persistent instruction in the user\'s words — what should this task keep doing? E.g. "Summarize my unread emails every morning into a brief digest." The agent re-reads instructions on every run and decides whether to rewrite index.md (OUTPUT mode) or perform a side-effect and journal it (ACTION mode) based on the verbs.'),
active: z.boolean().default(true).describe('Set false to pause without deleting.'),
triggers: TriggersSchema.optional().describe('When the agent fires. Omit for manual-only.'),
projectId: z.string().optional().describe('When set, marks this as a coding task pinned to a registered code project (repo). The agent implements detected work via the launch-code-task tool, each launch in its own isolated worktree.'),
model: z.string().optional().describe('ADVANCED — leave unset. Per-task model override.'),
provider: z.string().optional().describe('ADVANCED — leave unset. Per-task provider name override.'),
createdAt: z.string().describe('ISO timestamp set once at create-time.'),
@ -77,6 +85,7 @@ export const BackgroundTaskSummarySchema = z.object({
instructions: z.string(),
active: z.boolean(),
triggers: TriggersSchema.optional(),
projectId: z.string().optional(),
createdAt: z.string(),
lastAttemptAt: z.string().optional(),
lastRunId: z.string().optional(),

View file

@ -1,7 +1,26 @@
import { z } from 'zod';
export const BillingPlanSchema = z.enum(['free', 'starter', 'pro']);
export type BillingPlan = z.infer<typeof BillingPlanSchema>;
export const BillingPlanCategorySchema = z.enum(['free', 'starter', 'pro']);
export type BillingPlanCategory = z.infer<typeof BillingPlanCategorySchema>;
export const BillingPlanIdSchema = z.string().min(1);
export type BillingPlanId = z.infer<typeof BillingPlanIdSchema>;
export const BillingCatalogPlanSchema = z.object({
id: BillingPlanIdSchema,
category: BillingPlanCategorySchema,
displayName: z.string(),
monthlyCredits: z.number(),
dailyCredits: z.number(),
monthlyPriceCents: z.number().nullable(),
archived: z.boolean().optional(),
});
export type BillingCatalogPlan = z.infer<typeof BillingCatalogPlanSchema>;
export const BillingCatalogSchema = z.object({
plans: z.array(BillingCatalogPlanSchema),
});
export type BillingCatalog = z.infer<typeof BillingCatalogSchema>;
export const BillingUsageBucketSchema = z.object({
sanctionedCredits: z.number(),
@ -13,12 +32,21 @@ export type BillingUsageBucket = z.infer<typeof BillingUsageBucketSchema>;
export const BillingInfoSchema = z.object({
userEmail: z.string().nullable(),
userId: z.string().nullable(),
subscriptionPlan: BillingPlanSchema.nullable(),
subscriptionPlanId: BillingPlanIdSchema.nullable(),
subscriptionStatus: z.string().nullable(),
trialExpiresAt: z.string().nullable(),
catalog: BillingCatalogSchema,
monthly: BillingUsageBucketSchema,
daily: BillingUsageBucketSchema.extend({
usageDay: z.string(),
}),
});
export type BillingInfo = z.infer<typeof BillingInfoSchema>;
export function getBillingPlanData(
catalog: BillingCatalog,
planId: string | null | undefined,
): BillingCatalogPlan | null {
if (!planId) return null;
return catalog.plans.find((plan) => plan.id === planId) ?? null;
}

View file

@ -53,11 +53,34 @@ export const CodeSession = z.object({
// Where the agent works: the project path, or the worktree path.
cwd: z.string(),
worktree: CodeWorktree.optional(),
// The coding agent's own model + reasoning effort (applied to the ACP engine,
// not the Rowboat-mode LLM). Values come from CODE_AGENT_MODELS /
// CODE_AGENT_EFFORTS; unset (or 'default') leaves the engine's own default.
agentModel: z.string().optional(),
agentEffort: z.string().optional(),
createdAt: z.iso.datetime(),
lastActivityAt: z.iso.datetime().optional(),
});
export type CodeSession = z.infer<typeof CodeSession>;
// Model + effort choices for the ACP coding agents are discovered live from the
// engine (the same list `/model` shows), not hardcoded — so they always reflect
// whatever the provider currently offers. See the `codeMode:listModelOptions`
// IPC and CodeModeManager.listModelOptions. 'default' is a synthetic sentinel
// meaning "don't override the engine default".
//
// Claude exposes model and effort as two independent options; Codex folds the
// reasoning effort into the model id ("gpt-5-codex[high]") and so reports no
// separate effort list. The UI renders whatever each agent advertises.
export const CodeAgentOption = z.object({ value: z.string(), label: z.string() });
export type CodeAgentOption = z.infer<typeof CodeAgentOption>;
export const CodeAgentModelOptions = z.object({
models: z.array(CodeAgentOption),
efforts: z.array(CodeAgentOption),
});
export type CodeAgentModelOptions = z.infer<typeof CodeAgentModelOptions>;
export const GitFileState = z.enum(["modified", "added", "deleted", "untracked", "renamed"]);
export type GitFileState = z.infer<typeof GitFileState>;

View file

@ -21,7 +21,7 @@ import { BillingInfoSchema } from './billing.js';
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
import { PermissionDecision, ApprovalPolicy, CodingAgent } from './code-mode.js';
import { NotificationSettingsSchema } from './notification-settings.js';
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile } from './code-sessions.js';
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile, CodeAgentModelOptions } from './code-sessions.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@ -611,6 +611,10 @@ const ipcSchemas = {
// chat, the model is fixed once the session's run exists.
model: z.string().optional(),
provider: z.string().optional(),
// The coding agent's own model + reasoning effort (ACP engine). Unlike the
// Rowboat model these are re-applied each turn, so they stay editable.
agentModel: z.string().optional(),
agentEffort: z.string().optional(),
}),
res: z.object({
session: CodeSession,
@ -626,12 +630,18 @@ const ipcSchemas = {
'codeSession:update': {
req: z.object({
sessionId: z.string(),
patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true }).partial(),
patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true, agentModel: true, agentEffort: true }).partial(),
}),
res: z.object({
session: CodeSession,
}),
},
// Live model + effort choices for a coding agent, discovered from the engine
// (cached per agent in the main process). Mirrors what `/model` would show.
'codeMode:listModelOptions': {
req: z.object({ agent: CodingAgent }),
res: CodeAgentModelOptions,
},
'codeSession:delete': {
req: z.object({
sessionId: z.string(),
@ -1309,6 +1319,7 @@ const ipcSchemas = {
name: z.string(),
instructions: z.string(),
triggers: TriggersSchema.optional(),
projectId: z.string().optional(),
model: z.string().optional(),
provider: z.string().optional(),
}),

View file

@ -1,7 +1,9 @@
import { z } from 'zod';
import { BillingCatalogSchema } from './billing.js';
export const RowboatApiConfig = z.object({
appUrl: z.string(),
websocketApiUrl: z.string(),
supabaseUrl: z.string(),
billing: BillingCatalogSchema,
});