Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/libclaudebox/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const BASTION_SSH_KEY = join(homedir(), ".ssh", "build_instance_key");

// ── Interactive session config ──────────────────────────────────
export const CLAUDEBOX_HOST = process.env.CLAUDEBOX_HOST || "localhost:3000";
export const SESSION_PAGE_USER = process.env.CLAUDEBOX_SESSION_USER || "admin";
export const SESSION_PAGE_USER = process.env.CLAUDEBOX_SESSION_USER || "aztec";
export const SESSION_PAGE_PASS = process.env.CLAUDEBOX_SESSION_PASS || "";

// ── Log URL builder ─────────────────────────────────────────────
Expand Down
15 changes: 4 additions & 11 deletions packages/libclaudebox/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,6 @@ export class DockerService {
const profile = await loadProfile(profileDir);
const dockerConfig = profile.docker || {};
const containerImage = dockerConfig.image || DOCKER_IMAGE;
const mountRef = dockerConfig.mountReferenceRepo !== false; // default true

console.log(`[DOCKER] Starting session ${logId} (worktree=${worktreeId} profile=${profileDir})`);
console.log(`[DOCKER] Sidecar: ${sidecarName}`);
console.log(`[DOCKER] Claude: ${claudeName}`);
Expand Down Expand Up @@ -218,13 +216,11 @@ export class DockerService {
`${claudeProjectsDir}:${CONTAINER_HOME}/.claude/projects/-workspace:ro`,
`${CLAUDEBOX_CODE_DIR}:/opt/claudebox:ro`,
`${profileHostDir}:/opt/claudebox-profile:rw`,
`${BASTION_SSH_KEY}:${CONTAINER_HOME}/.ssh/build_instance_key:ro`,
`${BASTION_SSH_KEY}:/home/aztec-dev/.ssh/build_instance_key:ro`,
`${CLAUDEBOX_STATS_DIR}:/stats:rw`,
`${CLAUDEBOX_DIR}:${CONTAINER_HOME}/.claudebox:rw`,
];
if (mountRef) {
sidecarBinds.push(`${join(REPO_DIR, ".git")}:/reference-repo/.git:ro`);
}
sidecarBinds.push(`${join(REPO_DIR, ".git")}:/reference-repo/.git:ro`);

// Server URL for sidecar → server communication (internal port, not exposed to internet)
const serverUrl = `http://host.docker.internal:${INTERNAL_PORT}`;
Expand Down Expand Up @@ -301,7 +297,6 @@ export class DockerService {
"-v", `${join(CLAUDEBOX_CODE_DIR, "profiles", profileDir)}:/opt/claudebox-profile:rw`,
"-e", `CLAUDEBOX_MCP_URL=${mcpUrl}`,
"-e", `SESSION_UUID=${sessionUuid}`,
"-e", `AZTEC_MCP_SERVER=http://${sidecarName}:9801/creds`,
"-e", `CLAUDEBOX_SIDECAR_HOST=${sidecarName}`,
"-e", `CLAUDEBOX_SIDECAR_PORT=9801`,
"-e", `PARENT_LOG_ID=${logId}`,
Expand All @@ -318,10 +313,8 @@ export class DockerService {
if (dockerConfig.extraBinds) {
for (const b of dockerConfig.extraBinds) claudeArgs.push("-v", b);
}
// Mount reference repo for profiles that use local clone
if (mountRef) {
claudeArgs.push("-v", `${join(REPO_DIR, ".git")}:/reference-repo/.git:ro`);
}
// Mount reference repo for sparse pre-clone
claudeArgs.push("-v", `${join(REPO_DIR, ".git")}:/reference-repo/.git:ro`);

// Auto-detect resume
if (opts.worktreeId) {
Expand Down
99 changes: 75 additions & 24 deletions packages/libclaudebox/html/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,13 @@ function PromptCard({text, time, user, slackLink}){

// ── Artifact helpers ─────────────────────────────────────────────

// Deduplicate artifacts by URL (or full text if no URL), keeping last occurrence
function dedupArtifacts(artifacts){
var seen=new Map();
for(var a of artifacts){var t=slackToMd(a.text);var u=t.match(/https?:\\/\\/\\S+/);seen.set(u?u[0]:t,a);}
return[...seen.values()];
}

// Extract PR number from artifact text
function artifactPrNum(text){
var m=text.match(/#(\\d+)/);
Expand All @@ -534,7 +541,8 @@ function artifactPrNum(text){
// Build compact artifact chips for a run, marking PR updates
function ArtifactChips({artifacts, priorPrNums}){
if(!artifacts||!artifacts.length)return null;
return html\`<div class="artifact-chips">\${artifacts.map((a,i)=>{
var unique=dedupArtifacts(artifacts);
return html\`<div class="artifact-chips">\${unique.map((a,i)=>{
var text=slackToMd(a.text);
var prNum=artifactPrNum(text);
var isUpdate=(prNum&&priorPrNums&&priorPrNums.has(prNum))||/updated/i.test(text);
Expand All @@ -544,20 +552,33 @@ function ArtifactChips({artifacts, priorPrNums}){
}

// ── AgentSection — collapsible agent card ─────────────────────
function AgentSection({text, agentLogUrl, time, children}){
const [expanded,setExpanded]=useState(false);
function AgentSection({text, agentId, time, children}){
const PREVIEW=6;
const [expanded,setExpanded]=useState(()=>window.location.hash==="#agent-"+agentId);
const [showAll,setShowAll]=useState(false);
const total=children?children.length:0;
const visible=showAll||total<=PREVIEW?children:(children||[]).slice(0,PREVIEW);
const linked=linkify(slackToMd(text));
const agentInner=agentLogUrl
?'<a href="'+esc(agentLogUrl)+'" target="_blank" class="link" onclick="event.stopPropagation()">'+linked+'</a>'
:linked;
return html\`<div class=\${"act-agent-card"+(expanded?" expanded":"")}>
<div class="act-agent-header" onClick=\${()=>setExpanded(!expanded)}>
const ref=useRef(null);
useEffect(()=>{if(expanded&&ref.current&&window.location.hash==="#agent-"+agentId)ref.current.scrollIntoView({block:"start"});},[]);
function toggle(){
const next=!expanded;
setExpanded(next);
if(next){history.replaceState(null,"","#agent-"+agentId);}
else{history.replaceState(null,"",window.location.pathname+window.location.search);}
}
return html\`<div class=\${"act-agent-card"+(expanded?" expanded":"")} id=\${"agent-"+agentId} ref=\${ref}>
<div class="act-agent-header" onClick=\${toggle}>
<span class="act-agent-chevron">\u25B6</span>
<span class="act-icon" dangerouslySetInnerHTML=\${{__html:ICONS.agent}}></span>
<span dangerouslySetInnerHTML=\${{__html:agentInner}}></span>
<span dangerouslySetInnerHTML=\${{__html:linked}}></span>
\${total?html\`<span class="dim">(\${total})</span>\`:null}
<span class="act-ts">\${time}</span>
</div>
\${children&&children.length?html\`<div class="act-agent-body">\${children}</div>\`:null}
\${expanded&&visible&&visible.length?html\`<div class="act-agent-body">
\${visible}
\${!showAll&&total>PREVIEW?html\`<div class="act-row" style="cursor:pointer;color:#5FA7F1" onClick=\${(e)=>{e.stopPropagation();setShowAll(true);}}>show all \${total} entries</div>\`:null}
</div>\`:null}
</div>\`;
}

Expand All @@ -568,22 +589,23 @@ function renderActivityItems(activity){
var item=activity[i];
var entry=item.entry;
var key=item.id||("a"+i);
// Agent sections — collect subsequent tool calls until next non-tool entry
// Agent sections — collect subsequent tool/subagent entries
if(entry.type==="agent_start"){
var agentChildren=[];
var j=i+1;
while(j<activity.length){
var nxt=activity[j];
var nt=nxt.entry.type;
// Stop grouping at non-tool entries (but include tool_use/tool_result)
if(nt!=="tool_use"&&nt!=="tool_result")break;
agentChildren.push(nxt);
j++;
// Collect tool_use/tool_result (including subagent-tagged ones)
if(nt==="tool_use"||nt==="tool_result"||(nxt.entry.subagent&&(nt==="context"||nt==="status"))){
agentChildren.push(nxt);
j++;
}else break;
}
// Render grouped children
var grouped=renderToolGroups(agentChildren);
out.push(html\`<\${AgentSection} key=\${key} text=\${entry.text||""} agentLogUrl=\${item.agentLogUrl} time=\${entry.ts?timeAgo(entry.ts):""}>\${grouped}</\${AgentSection}>\`);
i=j-1; // skip grouped items
var agentId=key.replace(/[^a-z0-9-]/gi,"");
out.push(html\`<\${AgentSection} key=\${key} text=\${entry.text||""} agentId=\${agentId} time=\${entry.ts?timeAgo(entry.ts):""}>\${grouped}</\${AgentSection}>\`);
i=j-1;
continue;
}
if(entry.type==="tool_use"){
Expand Down Expand Up @@ -778,12 +800,10 @@ function Sidebar({open, status, exitCode, user, baseBranch, sessions, artifacts,
\${sessions.length&&sessions[0].started?html\`<div class="stat-row"><span class="dim">started</span> \${timeAgo(sessions[0].started)}</div>\`:null}
</div>
\${artifacts.length?html\`<div class="sidebar-section">
<div class="sidebar-label">Artifacts (\${artifacts.length})</div>
\${(()=>{var u=dedupArtifacts(artifacts);return html\`<div class="sidebar-label">Artifacts (\${u.length})</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
\${artifacts.map((a,i)=>{
return html\`<span key=\${i} dangerouslySetInnerHTML=\${{__html:compactArtifact(slackToMd(a.text))}}></span>\`;
})}
</div>
\${u.map((a,i)=>html\`<span key=\${i} dangerouslySetInnerHTML=\${{__html:compactArtifact(slackToMd(a.text))}}></span>\`)}
</div>\`;})()}
</div>\`:null}
\${sessions.length>1?html\`<div class="sidebar-section">
<div class="sidebar-label">Runs</div>
Expand Down Expand Up @@ -912,6 +932,17 @@ function WorkspacePage(){

const goBack=useCallback(()=>{setSelectedRun(null);},[]);

// Sync URL with selected run
useEffect(()=>{
const url=new URL(window.location);
if(selectedRun!=null&&runs[selectedRun]){
url.searchParams.set("run",runs[selectedRun].logId);
}else{
url.searchParams.delete("run");
}
if(url.href!==window.location.href)history.replaceState(null,"",url);
},[selectedRun,runs]);

// Auto-open detail when new session starts on a running workspace
const runsLen=runs.length;
useEffect(()=>{
Expand Down Expand Up @@ -1014,6 +1045,10 @@ function WorkspacePage(){
if(!newLogId)return;
var newSession={log_id:newLogId,status:"running",started:new Date().toISOString(),user:D.user,prompt:text,slack_channel:"",slack_message_ts:"",slack_thread_ts:""};
D.sessions.unshift(newSession);
// Clear activity/artifacts for the new run so previous run's entries don't bleed in
setActivityByRun(prev=>({...prev,[newLogId]:[]}));
setArtifactsByRun(prev=>({...prev,[newLogId]:[]}));
setLastReplyByRun(prev=>{const next={...prev};delete next[newLogId];return next;});
var newIdx;
setRuns(prev=>{
var total=prev.length+1;
Expand Down Expand Up @@ -1054,7 +1089,23 @@ function WorkspacePage(){
const wasRunning=statusRef.current==="running";
setStatus(d.status);
setExitCode(d.exit_code);
if(wasRunning&&d.status!=="running")setTimeout(()=>sendNextQueued(),100);
// Update last run's status so RunCard/RunDetail reflect it
setRuns(prev=>{if(!prev.length)return prev;const copy=[...prev];copy[copy.length-1]={...copy[copy.length-1],status:d.status,exitCode:d.exit_code};return copy;});
// Inject activity entry for terminal states
if(wasRunning&&d.status!=="running"){
setRuns(prev=>{
if(!prev.length)return prev;
const lastLogId=prev[prev.length-1].logId;
const entry={type:"status",text:"Session "+d.status+(d.exit_code!=null?" (exit "+d.exit_code+")":""),ts:new Date().toISOString()};
const mid=msgId(entry.text);
if(!seenRef.current.has(mid)){
seenRef.current.add(mid);
setActivityByRun(ap=>{const n={...ap};n[lastLogId]=[...(n[lastLogId]||[]),{entry,id:mid}];return n;});
}
return prev;
});
setTimeout(()=>sendNextQueued(),100);
}
}else if(d.type==="init"){
if(Array.isArray(d.activity)){
const results=[];
Expand Down
43 changes: 40 additions & 3 deletions packages/libclaudebox/http-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,14 +525,14 @@ const routes: Route[] = [
const sessions = worktreeId ? store.listByWorktree(worktreeId) : [{ ...session, _log_id: hash }];
const worktreeAlive = worktreeId ? store.isWorktreeAlive(worktreeId) : false;

// Extract last reply per session from activity (for collapsed card summaries)
// Extract all replies per session from activity (for run card summaries)
const lastReplies: Record<string, string> = {};
if (worktreeId) {
const activity = store.readActivity(worktreeId); // newest first
const sessionsOldest = [...sessions].reverse();
const repliesByRun: Record<string, string[]> = {};
for (const entry of activity) {
if (entry.type !== "response") continue;
// Find which session this entry belongs to by timestamp
let logId: string | undefined;
if ((entry as any).log_id) {
logId = (entry as any).log_id;
Expand All @@ -544,7 +544,14 @@ const routes: Route[] = [
}
}
}
if (logId && !lastReplies[logId]) lastReplies[logId] = entry.text;
if (logId) {
if (!repliesByRun[logId]) repliesByRun[logId] = [];
repliesByRun[logId].push(entry.text);
}
}
// Combine all replies (reversed to chronological order)
for (const [logId, replies] of Object.entries(repliesByRun)) {
lastReplies[logId] = replies.reverse().join("\n\n---\n\n");
}
}

Expand Down Expand Up @@ -1412,6 +1419,36 @@ const routes: Route[] = [
}
},
},

// ── Internal API: CI log read (sidecar → host, host has Redis/SSH) ──
{
method: "GET", pattern: /^\/api\/internal\/read-log$/, auth: "api", internal: true,
handler: async (req, res) => {
const url = new URL(req.url!, `http://${req.headers.host}`);
const key = url.searchParams.get("key") || "";
if (!key || !/^[a-zA-Z0-9._-]+$/.test(key)) { json(res, 400, { error: "invalid key" }); return; }

try {
const { spawnSync } = await import("child_process");
const { join } = await import("path");
const repoDir = process.env.CLAUDE_REPO_DIR || join(process.env.HOME || "", "repo");
const ciSh = join(repoDir, "ci.sh");
const result = spawnSync("bash", ["-c", `cd "${repoDir}" && "${ciSh}" dlog "${key}"`], {
encoding: "utf-8", timeout: 30_000, stdio: ["pipe", "pipe", "pipe"],
});
if (result.status !== 0) {
json(res, 502, { error: (result.stderr || "").trim().slice(0, 500) });
return;
}
// Strip ci.sh startup noise (Slack listener, PID lines)
let output = (result.stdout || "").replace(/^(?:Starting Slack listener\.\.\.\n|Started \(PID \d+\)\n)*/m, "");
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(output);
} catch (e: any) {
json(res, 500, { error: e.message });
}
},
},
];

// ── Server factory ──────────────────────────────────────────────
Expand Down
48 changes: 17 additions & 31 deletions packages/libclaudebox/mcp/git-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,12 @@ export interface PRToolConfig {

export function registerCloneRepo(server: McpServer, config: CloneToolConfig): void {
const desc = config.description ||
`Clone the repo into ${config.workspace}. Safe to call on resume — fetches new refs. Call FIRST before doing any work.`;
`Clone the repo into ${config.workspace}. MUST be your FIRST tool call — the workspace is empty until you clone. Do NOT run git, ls, Read, or any file operations before calling this. Safe to call on resume — fetches new refs.`;
const refHint = config.refHint || "'origin/next', 'abc123'";

const defaultRef = config.fallbackRef || "origin/main";
server.tool("clone_repo", desc,
{ ref: z.string().regex(/^[a-zA-Z0-9._\/@-]+$/).describe(`Branch, tag, or commit hash to check out (e.g. ${refHint})`) },
{ ref: z.string().regex(/^[a-zA-Z0-9._\/@-]+$/).default(defaultRef).describe(`Branch, tag, or commit hash to check out (default: ${defaultRef}). Examples: ${refHint}`) },
async ({ ref }) => {
if (ref.startsWith("-")) return { content: [{ type: "text", text: "Invalid ref: must not start with -" }], isError: true };
const targetDir = config.workspace;
Expand Down Expand Up @@ -274,57 +275,42 @@ export function registerLogTools(server: McpServer, config: { workspace: string
}

server.tool("read_log",
`Read a CI log by key/hash. Use this to view build logs, session logs, or any ci.aztec-labs.com log.

IMPORTANT: Use this tool instead of:
- Curling ci.aztec-labs.com directly (will fail — no CI_PASSWORD in container)
- Using CI_PASSWORD env var (not available)
- Running ci.sh dlog manually

Pass the log key (hex hash from the URL). Returns the log content.`,
`Read a CI log by key/hash. Use instead of curling ci.aztec-labs.com or using CI_PASSWORD.`,
{
key: z.string().regex(/^[a-zA-Z0-9._-]+$/).describe("Log key/hash (the hex string from a ci.aztec-labs.com URL)"),
tail: z.number().optional().describe("Only return the last N lines (useful for large logs)"),
key: z.string().regex(/^[a-zA-Z0-9._-]+$/).describe("Log key/hash from a ci.aztec-labs.com URL"),
tail: z.number().optional().describe("Only return the last N lines"),
head: z.number().optional().describe("Only return the first N lines"),
},
async ({ key, tail, head }) => {
const ci3 = findCi3();
if (!ci3) {
return { content: [{ type: "text", text: "ci3/ not found. Run clone_repo first to make log tools available." }], isError: true };
const serverUrl = process.env.CLAUDEBOX_SERVER_URL;
const serverToken = process.env.CLAUDEBOX_SERVER_TOKEN;
if (!serverUrl || !serverToken) {
return { content: [{ type: "text", text: "read_log: no server connection configured" }], isError: true };
}

try {
// ci.sh dlog sources Redis connection scripts and reads the key
const ciSh = join(config.workspace, "ci.sh");
const cmd = existsSync(ciSh) ? ciSh : join(ci3, "..", "ci.sh");
const result = spawnSync("bash", ["-c", `cd "${config.workspace}" && "${cmd}" dlog "${key}"`], {
encoding: "utf-8",
timeout: 30_000,
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env },
const resp = await fetch(`${serverUrl}/api/internal/read-log?key=${encodeURIComponent(key)}`, {
headers: { "Authorization": `Bearer ${serverToken}` },
});

if (result.status !== 0) {
const err = (result.stderr || "").trim();
return { content: [{ type: "text", text: `read_log failed (exit ${result.status}): ${err.slice(0, 500)}` }], isError: true };
if (!resp.ok) {
const err = await resp.text();
return { content: [{ type: "text", text: `read_log failed: ${err.slice(0, 500)}` }], isError: true };
}

let output = result.stdout || "";
let output = await resp.text();
if (!output.trim()) {
return { content: [{ type: "text", text: `Log '${key}' is empty or not found.` }], isError: true };
}

// Apply head/tail filtering
if (tail || head) {
const lines = output.split("\n");
if (tail) output = lines.slice(-tail).join("\n");
else if (head) output = lines.slice(0, head).join("\n");
}

// Truncate very large output
const MAX = 200_000;
if (output.length > MAX) {
output = output.slice(0, MAX) + `\n\n...(truncated at ${MAX} chars, use tail/head params to paginate)`;
output = output.slice(0, MAX) + `\n\n...(truncated at ${MAX} chars, use tail/head params)`;
}

return { content: [{ type: "text", text: output }] };
Expand Down
2 changes: 0 additions & 2 deletions packages/libclaudebox/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import type { RunMeta, ContainerSessionOpts } from "./types.ts";
export interface DockerConfig {
/** Docker image to use (overrides global default) */
image?: string;
/** Mount the local .git as reference repo (default: true) */
mountReferenceRepo?: boolean;
/** Extra bind mounts: ["host:container:mode", ...] */
extraBinds?: string[];
/** Extra env vars: ["KEY=value", ...] */
Expand Down
Loading