mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-03 20:41:07 +02:00
code mode initial commit
This commit is contained in:
parent
fcbcc137ca
commit
b2176435bd
39 changed files with 3919 additions and 94 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
240
apps/x/packages/core/src/code-mode/git/service.ts
Normal file
240
apps/x/packages/core/src/code-mode/git/service.ts
Normal 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)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
83
apps/x/packages/core/src/code-mode/projects/fs.ts
Normal file
83
apps/x/packages/core/src/code-mode/projects/fs.ts
Normal 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 };
|
||||
}
|
||||
69
apps/x/packages/core/src/code-mode/projects/repo.ts
Normal file
69
apps/x/packages/core/src/code-mode/projects/repo.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
63
apps/x/packages/core/src/code-mode/sessions/repo.ts
Normal file
63
apps/x/packages/core/src/code-mode/sessions/repo.ts
Normal 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(() => {});
|
||||
}
|
||||
}
|
||||
335
apps/x/packages/core/src/code-mode/sessions/service.ts
Normal file
335
apps/x/packages/core/src/code-mode/sessions/service.ts
Normal 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() });
|
||||
}
|
||||
}
|
||||
136
apps/x/packages/core/src/code-mode/sessions/status-tracker.ts
Normal file
136
apps/x/packages/core/src/code-mode/sessions/status-tracker.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
71
apps/x/packages/shared/src/code-sessions.ts
Normal file
71
apps/x/packages/shared/src/code-sessions.ts
Normal 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>;
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue