feat: add git branch/worktree setup, message queueing, file explorer improvements, and server restart

This commit is contained in:
Mohamed Boudra
2025-11-22 23:20:42 +01:00
parent 9feec81047
commit 95bacf8fc7
40 changed files with 3759 additions and 644 deletions
@@ -207,10 +207,15 @@ class ClaudeAgentSession implements AgentSession {
private currentMode: PermissionMode;
private availableModes: AgentMode[] = DEFAULT_MODES;
private toolUseCache = new Map<string, ToolUseCacheEntry>();
private toolUseIndexToId = new Map<number, string>();
private toolUseInputBuffers = new Map<string, string>();
private pendingPermissions = new Map<string, PendingPermission>();
private eventQueue: Pushable<AgentStreamEvent> | null = null;
private persistedHistory: AgentTimelineItem[] = [];
private historyPending = false;
private turnCancelRequested = false;
private streamedAssistantTextThisTurn = false;
private streamedReasoningThisTurn = false;
constructor(
config: ClaudeAgentConfig,
@@ -266,6 +271,17 @@ class ClaudeAgentSession implements AgentSession {
const sdkMessage = this.toSdkUserMessage(prompt);
const queue = new Pushable<AgentStreamEvent>();
this.eventQueue = queue;
let finishedNaturally = false;
let cancelIssued = false;
const requestCancel = () => {
if (cancelIssued) {
return;
}
cancelIssued = true;
this.turnCancelRequested = true;
void this.interruptActiveTurn();
queue.end();
};
if (this.historyPending && this.persistedHistory.length > 0) {
for (const item of this.persistedHistory) {
queue.push({ type: "timeline", item, provider: "claude" });
@@ -279,10 +295,14 @@ class ClaudeAgentSession implements AgentSession {
for await (const event of queue) {
yield event;
if (event.type === "turn_completed" || event.type === "turn_failed") {
finishedNaturally = true;
break;
}
}
} finally {
if (!finishedNaturally && !cancelIssued) {
requestCancel();
}
if (this.eventQueue === queue) {
this.eventQueue = null;
}
@@ -501,6 +521,8 @@ class ClaudeAgentSession implements AgentSession {
}
private async forwardPromptEvents(message: SDKUserMessage, queue: Pushable<AgentStreamEvent>) {
this.streamedAssistantTextThisTurn = false;
this.streamedReasoningThisTurn = false;
try {
for await (const sdkEvent of this.processPrompt(message)) {
const events = this.translateMessageToEvents(sdkEvent);
@@ -509,16 +531,30 @@ class ClaudeAgentSession implements AgentSession {
}
}
} catch (error) {
queue.push({
type: "turn_failed",
provider: "claude",
error: error instanceof Error ? error.message : "Claude stream failed",
});
if (!this.turnCancelRequested) {
queue.push({
type: "turn_failed",
provider: "claude",
error: error instanceof Error ? error.message : "Claude stream failed",
});
}
} finally {
this.turnCancelRequested = false;
queue.end();
}
}
private async interruptActiveTurn(): Promise<void> {
if (!this.query || typeof this.query.interrupt !== "function") {
return;
}
try {
await this.query.interrupt();
} catch (error) {
console.warn("[ClaudeAgentSession] Failed to interrupt active turn:", error);
}
}
private translateMessageToEvents(message: SDKMessage): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [{ type: "provider_event", provider: "claude", raw: message }];
@@ -539,7 +575,10 @@ class ClaudeAgentSession implements AgentSession {
break;
}
case "assistant": {
const timelineItems = this.mapBlocksToTimeline(message.message.content);
const timelineItems = this.mapBlocksToTimeline(message.message.content, {
suppressAssistantText: this.streamedAssistantTextThisTurn,
suppressReasoning: this.streamedReasoningThisTurn,
});
for (const item of timelineItems) {
events.push({ type: "timeline", item, provider: "claude" });
}
@@ -811,14 +850,33 @@ class ClaudeAgentSession implements AgentSession {
}
private convertHistoryEntry(entry: any): AgentTimelineItem[] {
return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content));
return convertClaudeHistoryEntry(entry, (content) =>
this.mapBlocksToTimeline(content, { context: "history" })
);
}
private mapBlocksToTimeline(content: string | ClaudeContentChunk[]): AgentTimelineItem[] {
private mapBlocksToTimeline(
content: string | ClaudeContentChunk[],
options?: {
context?: "live" | "history";
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
}
): AgentTimelineItem[] {
const context = options?.context ?? "live";
const suppressAssistant = options?.suppressAssistantText ?? false;
const suppressReasoning = options?.suppressReasoning ?? false;
if (typeof content === "string") {
if (!content || content === "[Request interrupted by user for tool use]") {
return [];
}
if (context === "live") {
this.streamedAssistantTextThisTurn = true;
}
if (suppressAssistant) {
return [];
}
return [{ type: "assistant_message", text: content, raw: content }];
}
@@ -828,13 +886,23 @@ class ClaudeAgentSession implements AgentSession {
case "text":
case "text_delta":
if (block.text && block.text !== "[Request interrupted by user for tool use]") {
items.push({ type: "assistant_message", text: block.text, raw: block });
if (context === "live") {
this.streamedAssistantTextThisTurn = true;
}
if (!suppressAssistant) {
items.push({ type: "assistant_message", text: block.text, raw: block });
}
}
break;
case "thinking":
case "thinking_delta":
if (block.thinking) {
items.push({ type: "reasoning", text: block.thinking, raw: block });
if (context === "live") {
this.streamedReasoningThisTurn = true;
}
if (!suppressReasoning) {
items.push({ type: "reasoning", text: block.thinking, raw: block });
}
}
break;
case "tool_use":
@@ -915,6 +983,23 @@ class ClaudeAgentSession implements AgentSession {
}
private mapPartialEvent(event: SDKPartialAssistantMessage["event"]): AgentTimelineItem[] {
if (event.type === "content_block_start" && (event.content_block as ClaudeContentChunk | undefined)?.type === "tool_use") {
const block = event.content_block as ClaudeContentChunk;
if (typeof event.index === "number" && typeof block?.id === "string") {
this.toolUseIndexToId.set(event.index, block.id);
this.toolUseInputBuffers.delete(block.id);
}
} else if (event.type === "content_block_delta" && (event.delta as ClaudeContentChunk | undefined)?.type === "input_json_delta") {
this.handleToolInputDelta(event.index, (event.delta as { partial_json?: string })?.partial_json);
return [];
} else if (event.type === "content_block_stop" && typeof event.index === "number") {
const toolId = this.toolUseIndexToId.get(event.index);
if (toolId) {
this.toolUseIndexToId.delete(event.index);
this.toolUseInputBuffers.delete(toolId);
}
}
switch (event.type) {
case "content_block_start":
return this.mapBlocksToTimeline([event.content_block as ClaudeContentChunk]);
@@ -956,18 +1041,8 @@ class ClaudeAgentSession implements AgentSession {
if (block.type === "tool_use") {
const input = this.normalizeToolInput(block.input);
existing.input = input;
if (input) {
if (this.isCommandTool(existing.name, input)) {
existing.classification = "command";
existing.commandText = this.extractCommandText(input) ?? existing.commandText;
} else {
const files = this.extractFileChanges(input);
if (files?.length) {
existing.classification = "file_change";
existing.files = files;
}
}
this.applyToolInput(existing, input);
}
}
@@ -975,6 +1050,41 @@ class ClaudeAgentSession implements AgentSession {
return existing;
}
private handleToolInputDelta(index: number | undefined, partialJson: string | undefined): void {
if (typeof index !== "number" || typeof partialJson !== "string") {
return;
}
const toolId = this.toolUseIndexToId.get(index);
if (!toolId) {
return;
}
const buffer = (this.toolUseInputBuffers.get(toolId) ?? "") + partialJson;
this.toolUseInputBuffers.set(toolId, buffer);
let parsed: unknown;
try {
parsed = JSON.parse(buffer);
} catch {
return;
}
const entry = this.toolUseCache.get(toolId);
const normalized = this.normalizeToolInput(parsed);
if (!entry || !normalized) {
return;
}
this.applyToolInput(entry, normalized);
this.toolUseCache.set(toolId, entry);
this.pushToolCall({
server: entry.server,
tool: entry.name,
status: "pending",
callId: toolId,
displayName: this.buildToolDisplayName(entry),
kind: this.getToolKind(entry.classification),
input: normalized,
raw: { type: "tool_use", id: toolId, input: parsed },
});
}
private normalizeToolInput(input: unknown): Record<string, unknown> | null {
if (!input || typeof input !== "object") {
return null;
@@ -982,6 +1092,20 @@ class ClaudeAgentSession implements AgentSession {
return input as Record<string, unknown>;
}
private applyToolInput(entry: ToolUseCacheEntry, input: Record<string, unknown>): void {
entry.input = input;
if (this.isCommandTool(entry.name, input)) {
entry.classification = "command";
entry.commandText = this.extractCommandText(input) ?? entry.commandText;
} else {
const files = this.extractFileChanges(input);
if (files?.length) {
entry.classification = "file_change";
entry.files = files;
}
}
}
private isCommandTool(name: string, input: Record<string, unknown>): boolean {
const normalized = name.toLowerCase();
if (normalized.includes("bash") || normalized.includes("shell") || normalized.includes("terminal") || normalized.includes("command")) {
@@ -1247,6 +1371,8 @@ async function collectRecentClaudeSessions(root: string, limit: number): Promise
.slice(0, limit);
}
const CLAUDE_PERSISTED_TIMELINE_LIMIT = 20;
async function parseClaudeSessionDescriptor(
filePath: string,
mtime: Date
@@ -1261,6 +1387,7 @@ async function parseClaudeSessionDescriptor(
let sessionId: string | null = null;
let cwd: string | null = null;
let title: string | null = null;
const timeline: AgentTimelineItem[] = [];
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
@@ -1277,8 +1404,19 @@ async function parseClaudeSessionDescriptor(
if (!cwd && typeof entry.cwd === "string") {
cwd = entry.cwd;
}
if (!title && entry.type === "user" && entry.message) {
title = extractClaudeUserText(entry.message);
if (entry.type === "user" && entry.message) {
const text = extractClaudeUserText(entry.message);
if (text) {
if (!title) {
title = text;
}
timeline.push({ type: "user_message", text });
}
} else if (entry.type === "assistant" && entry.message) {
const text = extractClaudeUserText(entry.message);
if (text) {
timeline.push({ type: "assistant_message", text });
}
}
if (sessionId && cwd && title) {
break;
@@ -1306,6 +1444,7 @@ async function parseClaudeSessionDescriptor(
title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`,
lastActivityAt: mtime,
persistence,
timeline: timeline.slice(0, CLAUDE_PERSISTED_TIMELINE_LIMIT),
};
}