code mode initial commit

This commit is contained in:
Arjun 2026-06-10 23:11:37 +05:30
parent fcbcc137ca
commit b2176435bd
39 changed files with 3919 additions and 94 deletions

View file

@ -1181,6 +1181,8 @@ export async function* streamAgent({
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
let codeMode: 'claude' | 'codex' | null = null;
let codeCwd: string | null = null;
let codePolicy: 'ask' | 'auto-approve-reads' | 'yolo' | null = null;
let middlePaneContext:
| { kind: 'note'; path: string; content: string }
| { kind: 'browser'; url: string; title: string }
@ -1280,6 +1282,8 @@ export async function* streamAgent({
abortRegistry,
publish: (event) => bus.publish(event),
codeMode,
codeCwd,
codePolicy,
});
}
} catch (error) {
@ -1339,6 +1343,8 @@ export async function* streamAgent({
// Code mode is per-message: latest message decides whether the assistant
// should route coding work through the code-with-agents skill / chosen agent.
codeMode = msg.codeMode ?? null;
codeCwd = msg.codeCwd ?? null;
codePolicy = msg.codePolicy ?? null;
if (msg.voiceOutput) {
voiceOutput = msg.voiceOutput;
}
@ -1436,7 +1442,7 @@ The chip is the single source of truth for which agent runs:
**How to run coding work call the \`code_agent_run\` tool** with:
- \`agent\`: \`${codeMode}\` (always — match the chip).
- \`cwd\`: the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once).
- \`cwd\`: ${codeCwd ? `\`${codeCwd}\` (always — this coding session is pinned to that directory; never use another path)` : `the absolute project/working directory (resolve it per the code-with-agents skill — a path the user named, the "# User Work Directory" block, or ask once)`}.
- \`prompt\`: a clear, self-contained coding instruction.
The tool runs the agent on-device and streams its tool calls, file diffs, and plan into the chat; any action needing approval surfaces as an inline permission card, so you do NOT pre-confirm with an in-chat "reply yes". This chat keeps ONE persistent agent session, so follow-up coding requests automatically resume with full context just call \`code_agent_run\` again. Do NOT shell out to \`acpx\` or \`executeCommand\` for coding, and do NOT fall back to your own file tools.

View file

@ -1,6 +1,6 @@
import { AsyncLocalStorage } from 'node:async_hooks';
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'knowledge_sync';
export type UseCase = 'copilot_chat' | 'live_note_agent' | 'background_task_agent' | 'meeting_note' | 'knowledge_sync' | 'code_session';
export interface UseCaseContext {
useCase: UseCase;

View file

@ -824,16 +824,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
// chip set) — otherwise it can anchor on the thread's earlier agent and ignore a
// chip change. Honor the chip so switching it deterministically switches agents.
const effectiveAgent = ctx.codeMode ?? agent;
// Code-section sessions pin the working directory — never trust the model's
// cwd argument over the session's.
const effectiveCwd = ctx.codeCwd ?? cwd;
const manager = container.resolve<CodeModeManager>('codeModeManager');
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
// Approval policy from settings; default to asking the user.
// Approval policy: the session's (Code section) wins, else global settings,
// else default to asking the user.
let policy: ApprovalPolicy = 'ask';
try {
const cfg = await container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo').getConfig();
if (cfg.approvalPolicy) policy = cfg.approvalPolicy;
} catch {
// fall back to 'ask'
if (ctx.codePolicy) {
policy = ctx.codePolicy;
} else {
try {
const cfg = await container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo').getConfig();
if (cfg.approvalPolicy) policy = cfg.approvalPolicy;
} catch {
// fall back to 'ask'
}
}
// On stop, unblock any pending approval card so the broker stops waiting for
@ -850,7 +858,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
const result = await manager.runPrompt({
runId: ctx.runId,
agent: effectiveAgent,
cwd,
cwd: effectiveCwd,
prompt,
policy,
signal: ctx.signal,

View file

@ -18,6 +18,11 @@ export interface ToolContext {
// it is the authoritative coding agent — code_agent_run uses it rather than the
// agent the model guessed, so switching the chip deterministically switches agents.
codeMode?: 'claude' | 'codex' | null;
// Set for Code-section sessions in Rowboat mode: the session's working directory
// and approval policy. code_agent_run honors these over the model's cwd argument
// and the global approval policy.
codeCwd?: string | null;
codePolicy?: 'ask' | 'auto-approve-reads' | 'yolo' | null;
}
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {

View file

@ -9,6 +9,7 @@ export type MiddlePaneContext =
| { kind: 'browser'; url: string; title: string };
export type CodeMode = 'claude' | 'codex';
export type CodePolicy = 'ask' | 'auto-approve-reads' | 'yolo';
type EnqueuedMessage = {
messageId: string;
@ -17,11 +18,16 @@ type EnqueuedMessage = {
voiceOutput?: VoiceOutputMode;
searchEnabled?: boolean;
codeMode?: CodeMode;
// Code-section sessions pin the coding agent's working directory and
// approval policy for the turn (code_agent_run honors these over its
// model-provided arguments / the global policy).
codeCwd?: string;
codePolicy?: CodePolicy;
middlePaneContext?: MiddlePaneContext;
};
export interface IMessageQueue {
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string>;
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode, codeCwd?: string, codePolicy?: CodePolicy): Promise<string>;
dequeue(runId: string): Promise<EnqueuedMessage | null>;
}
@ -37,7 +43,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator;
}
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string> {
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode, codeCwd?: string, codePolicy?: CodePolicy): Promise<string> {
if (!this.store[runId]) {
this.store[runId] = [];
}
@ -49,6 +55,8 @@ export class InMemoryMessageQueue implements IMessageQueue {
voiceOutput,
searchEnabled,
codeMode,
codeCwd,
codePolicy,
middlePaneContext,
});
return id;

View file

@ -15,6 +15,13 @@ export interface RunPromptArgs {
onEvent: (event: CodeRunEvent) => void;
/** Aborts the turn on stop; the manager cancels then force-kills the adapter. */
signal?: AbortSignal;
/**
* Drop the conversation replay that session/load streams on a cold resume.
* Direct sessions persist their own history (run JSONL) and render from it,
* so replaying through onEvent would duplicate every prior turn. When set,
* events only flow to onEvent once the session is open, right before prompt.
*/
suppressReplay?: boolean;
}
interface ActiveRun {
@ -51,7 +58,7 @@ export class CodeModeManager {
private readonly runs = new Map<string, ActiveRun>();
async runPrompt(args: RunPromptArgs): Promise<RunPromptResult> {
const { runId, agent, cwd, prompt, policy, ask, onEvent, signal } = args;
const { runId, agent, cwd, prompt, policy, ask, onEvent, signal, suppressReplay } = args;
const broker = new PermissionBroker({
policy,
@ -59,7 +66,7 @@ export class CodeModeManager {
onResolved: (a, decision, auto) => onEvent({ type: 'permission', ask: a, decision, auto }),
});
const run = await this.ensureRun(runId, agent, cwd, broker, onEvent);
const run = await this.ensureRun(runId, agent, cwd, broker, onEvent, suppressReplay ?? false);
run.inflight++;
let graceTimer: ReturnType<typeof setTimeout> | undefined;
@ -148,6 +155,7 @@ export class CodeModeManager {
cwd: string,
broker: PermissionBroker,
onEvent: (event: CodeRunEvent) => void,
suppressReplay: boolean,
): Promise<ActiveRun> {
const existing = this.runs.get(runId);
if (existing && existing.agent === agent && existing.cwd === cwd) {
@ -157,10 +165,19 @@ export class CodeModeManager {
}
if (existing) this.dispose(runId); // agent/cwd changed — start over
const client = new AcpClient({ agent, cwd, broker, onEvent });
// With suppressReplay, the client starts with a muted event sink so a
// session/load replay of the prior conversation goes nowhere; the real
// sink is installed once the session is open (below).
const client = new AcpClient({
agent,
cwd,
broker,
onEvent: suppressReplay ? () => {} : onEvent,
});
await client.start();
const sessionId = await this.openSession(runId, agent, cwd, client);
if (suppressReplay) client.setHandlers(broker, onEvent);
const run: ActiveRun = { client, sessionId, agent, cwd, inflight: 0 };
this.runs.set(runId, run);
return run;

View file

@ -0,0 +1,240 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import fs from 'fs/promises';
import path from 'path';
import type { GitRepoInfo, GitStatusFile, GitFileState } from '@x/shared/dist/code-sessions.js';
const execFileAsync = promisify(execFile);
// Plain shell-outs to the system git. isomorphic-git (already in core) doesn't
// support worktrees, and these calls are simple enough that wrapping the CLI is
// both lighter and more faithful to what the user's own git would do.
const MAX_BUFFER = 32 * 1024 * 1024;
// Diff/file payloads above this are not worth shipping to the renderer.
const MAX_TEXT_BYTES = 1024 * 1024;
async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: MAX_BUFFER });
return stdout;
}
let gitAvailable: Promise<boolean> | null = null;
export function isGitAvailable(): Promise<boolean> {
if (!gitAvailable) {
gitAvailable = execFileAsync('git', ['--version'], { timeout: 5000 })
.then(() => true)
.catch(() => false);
}
return gitAvailable;
}
export async function repoInfo(cwd: string): Promise<GitRepoInfo> {
const none: GitRepoInfo = { isGitRepo: false, branch: null, hasCommits: false, dirtyCount: 0 };
if (!await isGitAvailable()) return none;
try {
const inside = (await git(cwd, ['rev-parse', '--is-inside-work-tree'])).trim();
if (inside !== 'true') return none;
} catch {
return none;
}
let branch: string | null = null;
try {
branch = (await git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || null;
} catch {
// unborn branch (no commits) — symbolic-ref still knows the name
try {
const ref = (await git(cwd, ['symbolic-ref', 'HEAD'])).trim();
branch = ref.replace(/^refs\/heads\//, '') || null;
} catch {
branch = null;
}
}
let hasCommits = false;
try {
await git(cwd, ['rev-parse', '--verify', 'HEAD']);
hasCommits = true;
} catch {
hasCommits = false;
}
let dirtyCount = 0;
try {
const out = await git(cwd, ['status', '--porcelain=v1', '-z']);
dirtyCount = out.split('\0').filter((l) => l.trim() !== '').length;
} catch {
dirtyCount = 0;
}
return { isGitRepo: true, branch, hasCommits, dirtyCount };
}
function stateFromPorcelain(xy: string): GitFileState {
if (xy === '??') return 'untracked';
if (xy.includes('R')) return 'renamed';
if (xy.includes('A')) return 'added';
if (xy.includes('D')) return 'deleted';
return 'modified';
}
// Working-tree changes vs HEAD with insertion/deletion counts. Untracked files
// get their line count from disk (capped) since numstat doesn't cover them.
export async function status(cwd: string): Promise<GitStatusFile[]> {
const out = await git(cwd, ['status', '--porcelain=v1', '-z']);
const entries: Array<{ path: string; state: GitFileState }> = [];
// -z format: "XY path\0" and for renames "XY newPath\0oldPath\0"
const parts = out.split('\0');
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part || part.length < 4) continue;
const xy = part.slice(0, 2);
const filePath = part.slice(3);
const state = stateFromPorcelain(xy);
if (state === 'renamed') i++; // skip the old path that follows
entries.push({ path: filePath, state });
}
const counts = new Map<string, { insertions: number | null; deletions: number | null }>();
try {
const numstat = await git(cwd, ['diff', 'HEAD', '--numstat', '-z']);
// -z numstat rows: "ins\tdel\tpath\0" (renames: "ins\tdel\0old\0new\0")
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) counts.set(filePath, { insertions, deletions });
}
} catch {
// no HEAD yet (no commits) — leave counts empty
}
const result: GitStatusFile[] = [];
for (const entry of entries) {
let insertions: number | null = null;
let deletions: number | null = null;
const counted = counts.get(entry.path);
if (counted) {
insertions = counted.insertions;
deletions = counted.deletions;
} else if (entry.state === 'untracked') {
try {
const full = path.join(cwd, entry.path);
const stat = await fs.stat(full);
if (stat.isFile() && stat.size <= MAX_TEXT_BYTES) {
const content = await fs.readFile(full, 'utf8');
if (!content.includes('\0')) {
insertions = content.length === 0
? 0
: content.split('\n').length - (content.endsWith('\n') ? 1 : 0);
deletions = 0;
}
}
} catch {
// unreadable — leave counts null
}
}
result.push({ path: entry.path, state: entry.state, insertions, deletions });
}
return result;
}
export interface FileDiff {
oldText: string;
newText: string;
isBinary: boolean;
tooLarge: boolean;
}
export async function fileDiff(cwd: string, relPath: string): Promise<FileDiff> {
let oldText = '';
try {
oldText = await git(cwd, ['show', `HEAD:${relPath}`]);
} catch {
// untracked / newly added / no commits — diff against empty
oldText = '';
}
let newText = '';
try {
const full = path.join(cwd, relPath);
const stat = await fs.stat(full);
if (stat.size > MAX_TEXT_BYTES) {
return { oldText: '', newText: '', isBinary: false, tooLarge: true };
}
newText = await fs.readFile(full, 'utf8');
} catch {
// deleted from working tree
newText = '';
}
if (oldText.length > MAX_TEXT_BYTES) {
return { oldText: '', newText: '', isBinary: false, tooLarge: true };
}
if (oldText.includes('\0') || newText.includes('\0')) {
return { oldText: '', newText: '', isBinary: true, tooLarge: false };
}
return { oldText, newText, isBinary: false, tooLarge: false };
}
export async function worktreeAdd(repoPath: string, worktreePath: string, branch: string): Promise<void> {
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
await git(repoPath, ['worktree', 'add', '-b', branch, worktreePath, 'HEAD']);
}
export async function worktreeRemove(
repoPath: string,
worktreePath: string,
opts: { force?: boolean; deleteBranch?: string } = {},
): Promise<void> {
try {
const args = ['worktree', 'remove'];
if (opts.force) args.push('--force');
args.push(worktreePath);
await git(repoPath, args);
} catch {
// The worktree dir may have been deleted by hand — prune the registration.
await git(repoPath, ['worktree', 'prune']).catch(() => {});
}
if (opts.deleteBranch) {
await git(repoPath, ['branch', '-D', opts.deleteBranch]).catch(() => {});
}
}
export interface MergeBackResult {
ok: boolean;
conflict?: boolean;
message: string;
}
// Merge the session branch into whatever the original checkout currently has
// checked out. Refuses on a dirty checkout; aborts cleanly on conflicts.
export async function mergeBack(repoPath: string, branch: string): Promise<MergeBackResult> {
const info = await repoInfo(repoPath);
if (!info.isGitRepo) {
return { ok: false, message: 'The project folder is not a git repository.' };
}
if (info.dirtyCount > 0) {
return {
ok: false,
message: `The repository at ${repoPath} has ${info.dirtyCount} uncommitted change(s). Commit or stash them, then merge again — or merge manually with: git merge ${branch}`,
};
}
try {
await git(repoPath, ['merge', '--no-edit', branch]);
return { ok: true, message: `Merged ${branch} into ${info.branch ?? 'the current branch'}.` };
} catch (e) {
await git(repoPath, ['merge', '--abort']).catch(() => {});
const detail = e instanceof Error ? e.message : String(e);
return {
ok: false,
conflict: true,
message: `Merge of ${branch} hit conflicts and was aborted. Resolve manually with: git merge ${branch}\n\n${detail.slice(0, 600)}`,
};
}
}

View file

@ -0,0 +1,83 @@
import fs from 'fs/promises';
import path from 'path';
// Contained file browsing for the Code section. Session cwds are arbitrary
// user directories (outside the Rowboat workspace), so every access resolves
// against the session root and is validated to stay inside it — realpath on
// the containing directory defeats both `..` traversal and symlink escapes.
const MAX_FILE_BYTES = 1024 * 1024;
async function resolveContained(root: string, relPath: string): Promise<string> {
if (path.isAbsolute(relPath)) {
throw new Error('Absolute paths are not allowed');
}
const realRoot = await fs.realpath(root);
const resolved = path.resolve(realRoot, relPath);
// Realpath the parent so symlinked ancestors can't escape...
const realParent = await fs.realpath(path.dirname(resolved)).catch(() => null);
if (realParent === null) {
throw new Error(`No such directory: ${relPath}`);
}
// ...and the target itself, so the final component being a symlink
// (e.g. a link to /etc) can't either. A missing target keeps its own path.
const joined = path.join(realParent, path.basename(resolved));
const realTarget = await fs.realpath(joined).catch(() => joined);
if (realTarget !== realRoot && !realTarget.startsWith(realRoot + path.sep)) {
throw new Error('Path escapes the session directory');
}
return realTarget;
}
export interface ProjectDirEntry {
name: string;
kind: 'file' | 'dir';
size?: number;
}
// One level at a time — the tree lazily expands, so node_modules costs nothing
// until the user opens it. `.git` is always hidden.
export async function readProjectDir(root: string, relPath: string): Promise<ProjectDirEntry[]> {
const target = await resolveContained(root, relPath || '.');
const dirents = await fs.readdir(target, { withFileTypes: true });
const entries: ProjectDirEntry[] = [];
for (const d of dirents) {
if (d.name === '.git') continue;
if (d.isDirectory()) {
entries.push({ name: d.name, kind: 'dir' });
} else if (d.isFile()) {
let size: number | undefined;
try {
size = (await fs.stat(path.join(target, d.name))).size;
} catch {
size = undefined;
}
entries.push({ name: d.name, kind: 'file', size });
}
// symlinks and other entry kinds are skipped
}
entries.sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1));
return entries;
}
export interface ProjectFileContent {
content: string;
isBinary: boolean;
tooLarge: boolean;
}
export async function readProjectFile(root: string, relPath: string): Promise<ProjectFileContent> {
const target = await resolveContained(root, relPath);
const stat = await fs.stat(target);
if (!stat.isFile()) {
throw new Error(`Not a file: ${relPath}`);
}
if (stat.size > MAX_FILE_BYTES) {
return { content: '', isBinary: false, tooLarge: true };
}
const buf = await fs.readFile(target);
if (buf.includes(0)) {
return { content: '', isBinary: true, tooLarge: false };
}
return { content: buf.toString('utf8'), isBinary: false, tooLarge: false };
}

View file

@ -0,0 +1,69 @@
import fs from 'fs/promises';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import z from 'zod';
import { CodeProject } from '@x/shared/dist/code-sessions.js';
const ProjectsFile = z.object({
projects: z.array(CodeProject),
});
export interface ICodeProjectsRepo {
list(): Promise<CodeProject[]>;
get(projectId: string): Promise<CodeProject | null>;
add(dirPath: string): Promise<CodeProject>;
remove(projectId: string): Promise<void>;
}
// Registered project directories for the Code section. One small JSON file —
// same pattern as the other config repos.
export class FSCodeProjectsRepo implements ICodeProjectsRepo {
private readonly configPath = path.join(WorkDir, 'config', 'code-projects.json');
private async read(): Promise<CodeProject[]> {
try {
const raw = await fs.readFile(this.configPath, 'utf8');
return ProjectsFile.parse(JSON.parse(raw)).projects;
} catch {
return [];
}
}
private async write(projects: CodeProject[]): Promise<void> {
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
await fs.writeFile(this.configPath, JSON.stringify({ projects }, null, 2));
}
async list(): Promise<CodeProject[]> {
return this.read();
}
async get(projectId: string): Promise<CodeProject | null> {
const projects = await this.read();
return projects.find((p) => p.id === projectId) ?? null;
}
async add(dirPath: string): Promise<CodeProject> {
const resolved = path.resolve(dirPath);
const stat = await fs.stat(resolved);
if (!stat.isDirectory()) {
throw new Error(`Not a directory: ${resolved}`);
}
const projects = await this.read();
const existing = projects.find((p) => p.path === resolved);
if (existing) return existing;
const project: CodeProject = {
id: `proj-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
path: resolved,
name: path.basename(resolved),
addedAt: new Date().toISOString(),
};
await this.write([...projects, project]);
return project;
}
async remove(projectId: string): Promise<void> {
const projects = await this.read();
await this.write(projects.filter((p) => p.id !== projectId));
}
}

View file

@ -0,0 +1,63 @@
import fs from 'fs/promises';
import path from 'path';
import { WorkDir } from '../../config/config.js';
import { CodeSession } from '@x/shared/dist/code-sessions.js';
// Mutable metadata for Code-section sessions, one JSON file per session
// (keyed by the session/run id). The immutable conversation itself lives in
// the run JSONL; the ACP resume state lives in code-mode/sessions/.
const META_DIR = path.join(WorkDir, 'code-mode', 'sessions-meta');
function metaFile(sessionId: string): string {
return path.join(META_DIR, `${sessionId}.json`);
}
export interface ICodeSessionsRepo {
list(): Promise<CodeSession[]>;
get(sessionId: string): Promise<CodeSession | null>;
save(session: CodeSession): Promise<void>;
remove(sessionId: string): Promise<void>;
}
export class FSCodeSessionsRepo implements ICodeSessionsRepo {
async list(): Promise<CodeSession[]> {
let names: string[] = [];
try {
names = (await fs.readdir(META_DIR)).filter((n) => n.endsWith('.json'));
} catch {
return [];
}
const sessions: CodeSession[] = [];
for (const name of names) {
try {
const raw = await fs.readFile(path.join(META_DIR, name), 'utf8');
sessions.push(CodeSession.parse(JSON.parse(raw)));
} catch {
// skip malformed files
}
}
// Newest activity first; session ids are time-sortable as a tiebreaker.
sessions.sort((a, b) =>
(b.lastActivityAt ?? b.createdAt).localeCompare(a.lastActivityAt ?? a.createdAt));
return sessions;
}
async get(sessionId: string): Promise<CodeSession | null> {
try {
const raw = await fs.readFile(metaFile(sessionId), 'utf8');
return CodeSession.parse(JSON.parse(raw));
} catch {
return null;
}
}
async save(session: CodeSession): Promise<void> {
const validated = CodeSession.parse(session);
await fs.mkdir(META_DIR, { recursive: true });
await fs.writeFile(metaFile(validated.id), JSON.stringify(validated, null, 2));
}
async remove(sessionId: string): Promise<void> {
await fs.rm(metaFile(sessionId), { force: true }).catch(() => {});
}
}

View file

@ -0,0 +1,335 @@
import path from 'path';
import z from 'zod';
import { WorkDir } from '../../config/config.js';
import type { CodeSession, CodeSessionMode } from '@x/shared/dist/code-sessions.js';
import type { CodingAgent, ApprovalPolicy } from '@x/shared/dist/code-mode.js';
import { RunEvent, MessageEvent } from '@x/shared/dist/runs.js';
import type { IRunsRepo } from '../../runs/repo.js';
import type { IRunsLock } from '../../runs/lock.js';
import type { IBus } from '../../application/lib/bus.js';
import type { IMonotonicallyIncreasingIdGenerator } from '../../application/lib/id-gen.js';
import type { IAbortRegistry } from '../../runs/abort-registry.js';
import type { CodeModeManager } from '../acp/manager.js';
import type { CodePermissionRegistry } from '../acp/permission-registry.js';
import type { ICodeSessionsRepo } from './repo.js';
import type { ICodeProjectsRepo } from '../projects/repo.js';
import { clearStoredSession } from '../acp/session-store.js';
import * as gitService from '../git/service.js';
export interface CreateSessionArgs {
projectId: string;
title?: string;
agent: CodingAgent;
mode: CodeSessionMode;
policy: ApprovalPolicy;
isolation: 'in-repo' | 'worktree';
}
export interface SendMessageResult {
accepted: boolean;
error?: string;
}
function worktreeRoot(projectId: string, sessionId: string): string {
return path.join(WorkDir, 'code-mode', 'worktrees', projectId, sessionId);
}
// Drives Code-section sessions. A session is a run (same id) whose JSONL holds
// both modes' history: Rowboat turns are written by the agent runtime; direct
// turns are written here. The direct path talks straight to the ACP engine —
// no copilot LLM in between — but mirrors the runtime's lifecycle contract
// (runs lock, abort registry, processing-start/end, run-stopped) so the rest
// of the app (stop IPC, status tracking, event forwarding) needs no special
// casing.
export class CodeSessionService {
private readonly runsRepo: IRunsRepo;
private readonly runsLock: IRunsLock;
private readonly bus: IBus;
private readonly idGenerator: IMonotonicallyIncreasingIdGenerator;
private readonly abortRegistry: IAbortRegistry;
private readonly codeModeManager: CodeModeManager;
private readonly codePermissionRegistry: CodePermissionRegistry;
private readonly codeSessionsRepo: ICodeSessionsRepo;
private readonly codeProjectsRepo: ICodeProjectsRepo;
// Session ids with a direct prompt currently streaming (the runs lock also
// guards this, but we keep our own set to give a precise "busy" error).
private readonly inflight = new Set<string>();
constructor({
runsRepo,
runsLock,
bus,
idGenerator,
abortRegistry,
codeModeManager,
codePermissionRegistry,
codeSessionsRepo,
codeProjectsRepo,
}: {
runsRepo: IRunsRepo;
runsLock: IRunsLock;
bus: IBus;
idGenerator: IMonotonicallyIncreasingIdGenerator;
abortRegistry: IAbortRegistry;
codeModeManager: CodeModeManager;
codePermissionRegistry: CodePermissionRegistry;
codeSessionsRepo: ICodeSessionsRepo;
codeProjectsRepo: ICodeProjectsRepo;
}) {
this.runsRepo = runsRepo;
this.runsLock = runsLock;
this.bus = bus;
this.idGenerator = idGenerator;
this.abortRegistry = abortRegistry;
this.codeModeManager = codeModeManager;
this.codePermissionRegistry = codePermissionRegistry;
this.codeSessionsRepo = codeSessionsRepo;
this.codeProjectsRepo = codeProjectsRepo;
}
async create(args: CreateSessionArgs): Promise<CodeSession> {
const project = await this.codeProjectsRepo.get(args.projectId);
if (!project) throw new Error(`Unknown project: ${args.projectId}`);
// The session is a real run so Rowboat mode (agent runtime) works on it
// directly and the existing runs plumbing (fetch/events/stop) applies.
const { createRun } = await import('../../runs/runs.js');
const run = await createRun({ agentId: 'copilot', useCase: 'code_session' });
const sessionId = run.id;
let cwd = project.path;
let worktree: CodeSession['worktree'];
if (args.isolation === 'worktree') {
const info = await gitService.repoInfo(project.path);
if (!info.isGitRepo || !info.hasCommits) {
throw new Error('Worktree isolation needs a git repository with at least one commit.');
}
const branch = `rowboat/${sessionId}`;
const wtPath = worktreeRoot(project.id, sessionId);
await gitService.worktreeAdd(project.path, wtPath, branch);
worktree = { path: wtPath, branch, baseBranch: info.branch };
cwd = wtPath;
}
const session: CodeSession = {
id: sessionId,
projectId: project.id,
title: args.title?.trim() || `${project.name} session`,
agent: args.agent,
mode: args.mode,
policy: args.policy,
cwd,
...(worktree ? { worktree } : {}),
createdAt: new Date().toISOString(),
};
await this.codeSessionsRepo.save(session);
return session;
}
async update(sessionId: string, patch: Partial<Pick<CodeSession, 'title' | 'mode' | 'policy' | 'agent'>>): Promise<CodeSession> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session) throw new Error(`Unknown session: ${sessionId}`);
const updated: CodeSession = { ...session, ...patch };
await this.codeSessionsRepo.save(updated);
return updated;
}
isBusy(sessionId: string): boolean {
return this.inflight.has(sessionId);
}
// Direct drive: send the user's text straight to the session's ACP agent.
// Returns once the turn fully settles (the renderer streams via runs:events).
async sendMessage(sessionId: string, text: string): Promise<SendMessageResult> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session) return { accepted: false, error: `Unknown session: ${sessionId}` };
if (this.inflight.has(sessionId)) {
return { accepted: false, error: 'The agent is still working on the previous message.' };
}
// The runs lock is shared with the agent runtime, so a Rowboat-mode turn
// in flight blocks direct sends (and vice versa) — the run JSONL never
// interleaves two writers.
if (!await this.runsLock.lock(sessionId)) {
return { accepted: false, error: 'The session is busy with a Rowboat-driven turn.' };
}
this.inflight.add(sessionId);
const signal = this.abortRegistry.createForRun(sessionId);
const turnId = await this.idGenerator.next();
const toolCallId = `direct-${turnId}`;
const appendAndPublish = async (event: z.infer<typeof RunEvent>) => {
await this.runsRepo.appendEvents(sessionId, [event]);
await this.bus.publish(event);
};
try {
await this.bus.publish({ runId: sessionId, type: 'run-processing-start', subflow: [] });
const userEvent: z.infer<typeof MessageEvent> = {
runId: sessionId,
type: 'message',
messageId: await this.idGenerator.next(),
message: { role: 'user', content: text },
subflow: [],
ts: new Date().toISOString(),
};
await appendAndPublish(userEvent);
await this.touch(session);
// Stream events live; persist the structural ones (tool calls, plan,
// resolved permissions). Streaming `message` chunks are NOT persisted —
// the agent's full text lands as one assistant MessageEvent below, which
// is also what lets a later Rowboat-mode turn see this conversation.
let finalText = '';
const persistQueue: Array<z.infer<typeof RunEvent>> = [];
const onAbort = () => this.codePermissionRegistry.cancelRun(sessionId);
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
let stopReason = 'cancelled';
try {
const result = await this.codeModeManager.runPrompt({
runId: sessionId,
agent: session.agent,
cwd: session.cwd,
prompt: text,
policy: session.policy,
signal,
suppressReplay: true,
onEvent: (event) => {
if (event.type === 'message' && event.role === 'agent') finalText += event.text;
const streamEvent: z.infer<typeof RunEvent> = {
runId: sessionId,
type: 'code-run-event',
toolCallId,
event,
subflow: [],
};
void this.bus.publish(streamEvent);
if (event.type === 'tool_call' || event.type === 'tool_call_update'
|| event.type === 'plan' || event.type === 'permission') {
persistQueue.push({ ...streamEvent, ts: new Date().toISOString() });
}
},
ask: (permAsk) => this.codePermissionRegistry.request(sessionId, (requestId) => {
void this.bus.publish({
runId: sessionId,
type: 'code-run-permission-request',
toolCallId,
requestId,
ask: permAsk,
subflow: [],
});
}),
});
stopReason = result.stopReason;
} catch (error) {
if (!signal.aborted) {
const message = error instanceof Error ? (error.message || error.name) : String(error);
await appendAndPublish({ runId: sessionId, type: 'error', error: message, subflow: [] });
}
} finally {
signal.removeEventListener('abort', onAbort);
}
if (persistQueue.length > 0) {
await this.runsRepo.appendEvents(sessionId, persistQueue);
}
if (finalText.trim()) {
await appendAndPublish({
runId: sessionId,
type: 'message',
messageId: await this.idGenerator.next(),
message: { role: 'assistant', content: finalText },
subflow: [],
ts: new Date().toISOString(),
});
}
if (signal.aborted || stopReason === 'cancelled') {
await appendAndPublish({
runId: sessionId,
type: 'run-stopped',
reason: 'user-requested',
subflow: [],
});
}
await this.touch(session);
return { accepted: true };
} finally {
this.inflight.delete(sessionId);
this.abortRegistry.cleanup(sessionId);
await this.runsLock.release(sessionId);
await this.bus.publish({ runId: sessionId, type: 'run-processing-end', subflow: [] });
}
}
// Unblocks a stuck permission card immediately; the manager's signal handling
// (ACP cancel -> grace -> force-kill) actually unwinds the prompt.
async stop(sessionId: string): Promise<void> {
this.abortRegistry.abort(sessionId);
this.codePermissionRegistry.cancelRun(sessionId);
}
async mergeBack(sessionId: string): Promise<gitService.MergeBackResult> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session?.worktree) {
return { ok: false, message: 'This session has no isolated worktree to merge.' };
}
const project = await this.codeProjectsRepo.get(session.projectId);
if (!project) {
return { ok: false, message: 'The session\'s project is no longer registered.' };
}
const result = await gitService.mergeBack(project.path, session.worktree.branch);
if (result.ok) {
await this.codeSessionsRepo.save({
...session,
worktree: { ...session.worktree, mergedAt: new Date().toISOString() },
});
}
return result;
}
async cleanupWorktree(sessionId: string, deleteBranch: boolean): Promise<void> {
const session = await this.codeSessionsRepo.get(sessionId);
if (!session?.worktree || session.worktree.removedAt) return;
const project = await this.codeProjectsRepo.get(session.projectId);
// Drop any live agent connection on the worktree before deleting it.
this.codeModeManager.dispose(sessionId);
if (project) {
await gitService.worktreeRemove(project.path, session.worktree.path, {
force: true,
...(deleteBranch ? { deleteBranch: session.worktree.branch } : {}),
});
}
await this.codeSessionsRepo.save({
...session,
// The worktree is gone — fall back to working directly in the repo.
cwd: project?.path ?? session.cwd,
worktree: { ...session.worktree, removedAt: new Date().toISOString() },
});
}
async delete(sessionId: string, opts: { removeWorktree?: boolean; deleteBranch?: boolean } = {}): Promise<void> {
await this.stop(sessionId);
this.codeModeManager.dispose(sessionId);
const session = await this.codeSessionsRepo.get(sessionId);
if (opts.removeWorktree && session?.worktree && !session.worktree.removedAt) {
const project = await this.codeProjectsRepo.get(session.projectId);
if (project) {
await gitService.worktreeRemove(project.path, session.worktree.path, {
force: true,
...(opts.deleteBranch ? { deleteBranch: session.worktree.branch } : {}),
});
}
}
await clearStoredSession(sessionId);
await this.codeSessionsRepo.remove(sessionId);
await this.runsRepo.delete(sessionId).catch(() => {});
}
private async touch(session: CodeSession): Promise<void> {
const current = await this.codeSessionsRepo.get(session.id);
if (!current) return;
await this.codeSessionsRepo.save({ ...current, lastActivityAt: new Date().toISOString() });
}
}

View file

@ -0,0 +1,136 @@
import z from 'zod';
import { RunEvent } from '@x/shared/dist/runs.js';
import type { IBus } from '../../application/lib/bus.js';
import type { ICodeSessionsRepo } from './repo.js';
import type { INotificationService } from '../../application/notification/service.js';
import type { CodeSessionStatus, CodeSession } from '@x/shared/dist/code-sessions.js';
import container from '../../di/container.js';
export type StatusListener = (sessionId: string, status: CodeSessionStatus) => void;
// Authoritative live status for Code-section sessions, derived in the main
// process from the run event stream. Works for both modes uniformly because
// direct turns and Rowboat-mode code_agent_run turns publish the same event
// types on the bus. The renderer just renders what this pushes.
export class CodeSessionStatusTracker {
private readonly bus: IBus;
private readonly codeSessionsRepo: ICodeSessionsRepo;
private readonly statuses = new Map<string, CodeSessionStatus>();
private readonly busySince = new Map<string, number>();
private readonly listeners = new Set<StatusListener>();
private unsubscribe: (() => void) | null = null;
// Session ids known to be code sessions; refreshed lazily on unknown ids so
// sessions created after start() are picked up without explicit wiring.
private knownSessions = new Set<string>();
// Ids confirmed NOT to be sessions (regular chat runs). Safe to cache
// permanently: a session's meta file is written before its first turn, so
// an id that misses the refresh can never become a session later.
private readonly knownNonSessions = new Set<string>();
constructor({ bus, codeSessionsRepo }: { bus: IBus; codeSessionsRepo: ICodeSessionsRepo }) {
this.bus = bus;
this.codeSessionsRepo = codeSessionsRepo;
}
async start(): Promise<void> {
if (this.unsubscribe) return;
await this.refreshKnownSessions();
this.unsubscribe = await this.bus.subscribe('*', async (event) => {
await this.handle(event);
});
}
stop(): void {
this.unsubscribe?.();
this.unsubscribe = null;
}
onTransition(listener: StatusListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
getStatuses(): Record<string, CodeSessionStatus> {
return Object.fromEntries(this.statuses);
}
private async refreshKnownSessions(): Promise<void> {
const sessions = await this.codeSessionsRepo.list().catch(() => [] as CodeSession[]);
this.knownSessions = new Set(sessions.map((s) => s.id));
}
private async isCodeSession(runId: string): Promise<boolean> {
if (this.knownSessions.has(runId)) return true;
if (this.knownNonSessions.has(runId)) return false;
// Unknown id — maybe a session created since the last refresh.
await this.refreshKnownSessions();
if (this.knownSessions.has(runId)) return true;
this.knownNonSessions.add(runId);
return false;
}
private async handle(event: z.infer<typeof RunEvent>): Promise<void> {
const relevant = event.type === 'run-processing-start'
|| event.type === 'run-processing-end'
|| event.type === 'run-stopped'
|| event.type === 'error'
|| event.type === 'code-run-permission-request'
|| (event.type === 'code-run-event' && event.event.type === 'permission');
if (!relevant) return;
if (!await this.isCodeSession(event.runId)) return;
const previous = this.statuses.get(event.runId) ?? 'idle';
let next: CodeSessionStatus = previous;
switch (event.type) {
case 'run-processing-start':
next = 'working';
break;
case 'code-run-permission-request':
next = 'needs-you';
break;
case 'code-run-event':
// A permission resolution while the turn is still running.
if (previous === 'needs-you') next = 'working';
break;
case 'run-processing-end':
case 'run-stopped':
case 'error':
next = 'idle';
break;
}
if (next === previous) return;
if (previous === 'idle' && next !== 'idle') this.busySince.set(event.runId, Date.now());
this.statuses.set(event.runId, next);
for (const listener of this.listeners) listener(event.runId, next);
await this.notify(event.runId, previous, next);
if (next === 'idle') this.busySince.delete(event.runId);
}
private async notify(sessionId: string, previous: CodeSessionStatus, next: CodeSessionStatus): Promise<void> {
let notificationService: INotificationService;
try {
notificationService = container.resolve<INotificationService>('notificationService');
} catch {
return; // not registered (e.g. tests)
}
if (!notificationService.isSupported()) return;
const session = await this.codeSessionsRepo.get(sessionId);
const title = session?.title ?? 'Coding session';
if (next === 'needs-you') {
notificationService.notify({
title,
message: 'The coding agent needs your approval.',
});
} else if (next === 'idle' && previous === 'working') {
// Only worth interrupting for if the agent worked long enough that
// the user has plausibly moved on to something else.
const since = this.busySince.get(sessionId);
if (since !== undefined && Date.now() - since > 30_000) {
notificationService.notify({
title,
message: 'The coding agent finished its turn.',
});
}
}
}
}

View file

@ -18,6 +18,10 @@ import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-sche
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
import { CodeModeManager } from "../code-mode/acp/manager.js";
import { CodePermissionRegistry } from "../code-mode/acp/permission-registry.js";
import { FSCodeProjectsRepo, ICodeProjectsRepo } from "../code-mode/projects/repo.js";
import { FSCodeSessionsRepo, ICodeSessionsRepo } from "../code-mode/sessions/repo.js";
import { CodeSessionService } from "../code-mode/sessions/service.js";
import { CodeSessionStatusTracker } from "../code-mode/sessions/status-tracker.js";
import type { IBrowserControlService } from "../application/browser-control/service.js";
import type { INotificationService } from "../application/notification/service.js";
@ -51,6 +55,13 @@ container.register({
// session/load); the registry brokers mid-run approvals.
codeModeManager: asClass(CodeModeManager).singleton(),
codePermissionRegistry: asClass(CodePermissionRegistry).singleton(),
// Code section: project registry, session metadata, the direct-drive
// session service, and the live status tracker.
codeProjectsRepo: asClass<ICodeProjectsRepo>(FSCodeProjectsRepo).singleton(),
codeSessionsRepo: asClass<ICodeSessionsRepo>(FSCodeSessionsRepo).singleton(),
codeSessionService: asClass(CodeSessionService).singleton(),
codeSessionStatusTracker: asClass(CodeSessionStatusTracker).singleton(),
});
export default container;

View file

@ -307,6 +307,7 @@ export class FSRunsRepo implements IRunsRepo {
title: metadata.title,
createdAt: metadata.start.ts!,
agentId: metadata.start.agentName,
...(metadata.start.useCase ? { useCase: metadata.start.useCase } : {}),
});
}

View file

@ -40,9 +40,9 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
return run;
}
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex'): Promise<string> {
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex', codeCwd?: string, codePolicy?: 'ask' | 'auto-approve-reads' | 'yolo'): Promise<string> {
const queue = container.resolve<IMessageQueue>('messageQueue');
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode);
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode, codeCwd, codePolicy);
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
runtime.trigger(runId);
return id;

View file

@ -1,5 +1,6 @@
import chokidar, { type FSWatcher } from 'chokidar';
import fs from 'node:fs/promises';
import path from 'node:path';
import { ensureWorkspaceRoot, absToRelPosix } from './workspace.js';
import { WorkDir } from '../config/config.js';
import { WorkspaceChangeEvent } from 'packages/shared/dist/workspace.js';
@ -21,8 +22,15 @@ export async function createWorkspaceWatcher(
): Promise<FSWatcher> {
await ensureWorkspaceRoot();
// Code-section session worktrees are full repo checkouts (thousands of files,
// possibly node_modules) living under WorkDir — watching them would flood the
// event stream and burn file handles, and nothing in the app renders them
// from workspace events.
const codeModeDir = path.join(WorkDir, 'code-mode');
const watcher = chokidar.watch(WorkDir, {
ignoreInitial: true,
ignored: (watchedPath: string) =>
watchedPath === codeModeDir || watchedPath.startsWith(codeModeDir + path.sep),
awaitWriteFinish: {
stabilityThreshold: 150,
pollInterval: 50,

View file

@ -0,0 +1,71 @@
import z from "zod";
import { CodingAgent, ApprovalPolicy } from "./code-mode.js";
// Shared zod schemas for the Code section: registered projects and coding
// sessions. A coding session is backed by a run (session id == run id); the
// mutable metadata below lives in its own per-session file.
export const CodeProject = z.object({
id: z.string(),
path: z.string(),
name: z.string(),
addedAt: z.iso.datetime(),
});
export type CodeProject = z.infer<typeof CodeProject>;
// Git facts about a project path, used to gate worktree creation in the UI.
export const GitRepoInfo = z.object({
isGitRepo: z.boolean(),
branch: z.string().nullable(),
hasCommits: z.boolean(),
dirtyCount: z.number(),
});
export type GitRepoInfo = z.infer<typeof GitRepoInfo>;
// 'direct': the user's messages go straight to the ACP coding agent.
// 'rowboat': Rowboat's copilot LLM orchestrates the agent via code_agent_run.
export const CodeSessionMode = z.enum(["direct", "rowboat"]);
export type CodeSessionMode = z.infer<typeof CodeSessionMode>;
// Derived live in the main process from the run event stream; not persisted.
export const CodeSessionStatus = z.enum(["working", "needs-you", "idle"]);
export type CodeSessionStatus = z.infer<typeof CodeSessionStatus>;
export const CodeWorktree = z.object({
path: z.string(),
branch: z.string(),
// Branch the original checkout was on when the worktree was created;
// merge-back targets whatever the checkout is on at merge time, this is
// informational.
baseBranch: z.string().nullable(),
mergedAt: z.iso.datetime().optional(),
removedAt: z.iso.datetime().optional(),
});
export type CodeWorktree = z.infer<typeof CodeWorktree>;
export const CodeSession = z.object({
id: z.string(), // == runId
projectId: z.string(),
title: z.string(),
agent: CodingAgent,
mode: CodeSessionMode,
policy: ApprovalPolicy,
// Where the agent works: the project path, or the worktree path.
cwd: z.string(),
worktree: CodeWorktree.optional(),
createdAt: z.iso.datetime(),
lastActivityAt: z.iso.datetime().optional(),
});
export type CodeSession = z.infer<typeof CodeSession>;
export const GitFileState = z.enum(["modified", "added", "deleted", "untracked", "renamed"]);
export type GitFileState = z.infer<typeof GitFileState>;
export const GitStatusFile = z.object({
path: z.string(),
state: GitFileState,
// Null when git can't compute line counts (binary files).
insertions: z.number().nullable(),
deletions: z.number().nullable(),
});
export type GitStatusFile = z.infer<typeof GitStatusFile>;

View file

@ -17,4 +17,5 @@ export * as frontmatter from './frontmatter.js';
export * as bases from './bases.js';
export * as browserControl from './browser-control.js';
export * as billing from './billing.js';
export * as codeSessions from './code-sessions.js';
export { PrefixLogger };

View file

@ -19,7 +19,8 @@ import { ZListToolkitsResponse } from './composio.js';
import { BrowserStateSchema } from './browser-control.js';
import { BillingInfoSchema } from './billing.js';
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
import { PermissionDecision, ApprovalPolicy } from './code-mode.js';
import { PermissionDecision, ApprovalPolicy, CodingAgent } from './code-mode.js';
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile } from './code-sessions.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@ -231,6 +232,10 @@ const ipcSchemas = {
voiceOutput: z.enum(['summary', 'full']).optional(),
searchEnabled: z.boolean().optional(),
codeMode: z.enum(['claude', 'codex']).optional(),
// Code-section sessions pin the coding agent's working directory and
// approval policy for the whole turn (see code_agent_run overrides).
codeCwd: z.string().optional(),
codePolicy: ApprovalPolicy.optional(),
middlePaneContext: z.discriminatedUnion('kind', [
z.object({
kind: z.literal('note'),
@ -460,6 +465,169 @@ const ipcSchemas = {
codex: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
}),
},
// ==========================================================================
// Code section: project registry + coding sessions
// ==========================================================================
'codeProject:add': {
req: z.object({
path: z.string(),
}),
res: z.object({
project: CodeProject,
git: GitRepoInfo,
}),
},
'codeProject:remove': {
req: z.object({
projectId: z.string(),
}),
res: z.object({
success: z.literal(true),
}),
},
'codeProject:list': {
req: z.null(),
res: z.object({
projects: z.array(z.object({
project: CodeProject,
git: GitRepoInfo,
})),
}),
},
'codeSession:create': {
req: z.object({
projectId: z.string(),
title: z.string().optional(),
agent: CodingAgent,
mode: CodeSessionMode,
policy: ApprovalPolicy,
isolation: z.enum(['in-repo', 'worktree']),
}),
res: z.object({
session: CodeSession,
}),
},
'codeSession:list': {
req: z.null(),
res: z.object({
sessions: z.array(CodeSession),
statuses: z.record(z.string(), CodeSessionStatus),
}),
},
'codeSession:update': {
req: z.object({
sessionId: z.string(),
patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true }).partial(),
}),
res: z.object({
session: CodeSession,
}),
},
'codeSession:delete': {
req: z.object({
sessionId: z.string(),
removeWorktree: z.boolean().optional(),
deleteBranch: z.boolean().optional(),
}),
res: z.object({
success: z.literal(true),
}),
},
// Direct-drive: send the user's message straight to the session's ACP agent
// (no copilot LLM in between). Streams back over `runs:events`.
'codeSession:sendMessage': {
req: z.object({
sessionId: z.string(),
text: z.string().min(1),
}),
res: z.object({
accepted: z.boolean(),
error: z.string().optional(),
}),
},
'codeSession:stop': {
req: z.object({
sessionId: z.string(),
}),
res: z.object({
success: z.literal(true),
}),
},
'codeSession:gitStatus': {
req: z.object({
sessionId: z.string(),
}),
res: z.object({
isRepo: z.boolean(),
branch: z.string().nullable(),
hasCommits: z.boolean(),
files: z.array(GitStatusFile),
}),
},
'codeSession:fileDiff': {
req: z.object({
sessionId: z.string(),
path: z.string(),
}),
res: z.object({
oldText: z.string(),
newText: z.string(),
isBinary: z.boolean(),
tooLarge: z.boolean(),
}),
},
'codeSession:readdir': {
req: z.object({
sessionId: z.string(),
relPath: z.string(),
}),
res: z.object({
entries: z.array(z.object({
name: z.string(),
kind: z.enum(['file', 'dir']),
size: z.number().optional(),
})),
}),
},
'codeSession:readFile': {
req: z.object({
sessionId: z.string(),
relPath: z.string(),
}),
res: z.object({
content: z.string(),
isBinary: z.boolean(),
tooLarge: z.boolean(),
}),
},
'codeSession:mergeBack': {
req: z.object({
sessionId: z.string(),
}),
res: z.object({
ok: z.boolean(),
conflict: z.boolean().optional(),
message: z.string(),
}),
},
'codeSession:cleanupWorktree': {
req: z.object({
sessionId: z.string(),
deleteBranch: z.boolean(),
}),
res: z.object({
success: z.boolean(),
error: z.string().optional(),
}),
},
// main → renderer: live session status transitions from the status tracker.
'codeSession:status': {
req: z.object({
sessionId: z.string(),
status: CodeSessionStatus,
}),
res: z.null(),
},
'granola:setConfig': {
req: z.object({
enabled: z.boolean(),

View file

@ -31,6 +31,7 @@ export const StartEvent = BaseRunEvent.extend({
"background_task_agent",
"meeting_note",
"knowledge_sync",
"code_session",
]).optional(),
subUseCase: z.string().optional(),
});
@ -188,6 +189,7 @@ export const UseCase = z.enum([
"background_task_agent",
"meeting_note",
"knowledge_sync",
"code_session",
]);
export const Run = z.object({
@ -209,6 +211,7 @@ export const ListRunsResponse = z.object({
title: true,
createdAt: true,
agentId: true,
useCase: true,
})),
nextCursor: z.string().optional(),
});