Compare commits
No commits in common. "a84671cfadaab6ec73f640b32cdaee053898bbcc" and "ec12310801f7ff96fe8b601eec248d15bdd5aa28" have entirely different histories.
a84671cfad
...
ec12310801
17 changed files with 134 additions and 1245 deletions
|
|
@ -1,10 +0,0 @@
|
||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"mcp__agor-launcher__agor-status",
|
|
||||||
"mcp__agor-launcher__agor-kill-stale",
|
|
||||||
"mcp__agor-launcher__agor-stop",
|
|
||||||
"mcp__agor-launcher__agor-start"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,8 +8,7 @@ import { execSync } from 'child_process';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { query, listSessions, type Query } from '@anthropic-ai/claude-agent-sdk';
|
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
|
|
||||||
|
|
||||||
const rl = createInterface({ input: stdin });
|
const rl = createInterface({ input: stdin });
|
||||||
|
|
||||||
|
|
@ -42,10 +41,7 @@ interface QueryMessage {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
maxTurns?: number;
|
maxTurns?: number;
|
||||||
maxBudgetUsd?: number;
|
maxBudgetUsd?: number;
|
||||||
/** @deprecated Use resumeMode='resume' + resumeSessionId instead. */
|
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */
|
|
||||||
resumeMode?: 'new' | 'continue' | 'resume';
|
|
||||||
permissionMode?: string;
|
permissionMode?: string;
|
||||||
settingSources?: string[];
|
settingSources?: string[];
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
|
@ -78,7 +74,7 @@ async function handleMessage(msg: Record<string, unknown>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleQuery(msg: QueryMessage) {
|
async function handleQuery(msg: QueryMessage) {
|
||||||
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, resumeMode, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg;
|
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg;
|
||||||
|
|
||||||
if (sessions.has(sessionId)) {
|
if (sessions.has(sessionId)) {
|
||||||
send({ type: 'error', sessionId, message: 'Session already running' });
|
send({ type: 'error', sessionId, message: 'Session already running' });
|
||||||
|
|
@ -116,25 +112,6 @@ async function handleQuery(msg: QueryMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build resume/continue options based on resumeMode
|
|
||||||
let resumeOpt: string | undefined;
|
|
||||||
let continueOpt: boolean | undefined;
|
|
||||||
if (resumeMode === 'continue') {
|
|
||||||
continueOpt = true;
|
|
||||||
log(`Session ${sessionId}: continuing most recent session`);
|
|
||||||
// Sanitize the most recent session file before SDK reads it
|
|
||||||
sanitizeSessionFiles(cwd || process.cwd());
|
|
||||||
} else if (resumeMode === 'resume' && resumeSessionId) {
|
|
||||||
resumeOpt = resumeSessionId;
|
|
||||||
log(`Session ${sessionId}: resuming SDK session ${resumeSessionId}`);
|
|
||||||
// Sanitize the specific session file
|
|
||||||
sanitizeSessionFile(cwd || process.cwd(), resumeSessionId);
|
|
||||||
} else if (resumeSessionId && !resumeMode) {
|
|
||||||
// Legacy: direct resumeSessionId without resumeMode
|
|
||||||
resumeOpt = resumeSessionId;
|
|
||||||
sanitizeSessionFile(cwd || process.cwd(), resumeSessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt,
|
prompt,
|
||||||
options: {
|
options: {
|
||||||
|
|
@ -144,11 +121,17 @@ async function handleQuery(msg: QueryMessage) {
|
||||||
env: cleanEnv,
|
env: cleanEnv,
|
||||||
maxTurns: maxTurns ?? undefined,
|
maxTurns: maxTurns ?? undefined,
|
||||||
maxBudgetUsd: maxBudgetUsd ?? undefined,
|
maxBudgetUsd: maxBudgetUsd ?? undefined,
|
||||||
resume: resumeOpt,
|
resume: resumeSessionId ?? undefined,
|
||||||
continue: continueOpt,
|
allowedTools: [
|
||||||
|
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
||||||
|
'WebSearch', 'WebFetch', 'TodoWrite', 'NotebookEdit',
|
||||||
|
],
|
||||||
permissionMode: (permissionMode ?? 'bypassPermissions') as 'bypassPermissions' | 'default',
|
permissionMode: (permissionMode ?? 'bypassPermissions') as 'bypassPermissions' | 'default',
|
||||||
allowDangerouslySkipPermissions: (permissionMode ?? 'bypassPermissions') === 'bypassPermissions',
|
allowDangerouslySkipPermissions: (permissionMode ?? 'bypassPermissions') === 'bypassPermissions',
|
||||||
...(systemPrompt ? { systemPrompt } : {}),
|
settingSources: settingSources ?? ['user', 'project'],
|
||||||
|
systemPrompt: systemPrompt
|
||||||
|
? systemPrompt
|
||||||
|
: { type: 'preset' as const, preset: 'claude_code' as const },
|
||||||
model: model ?? undefined,
|
model: model ?? undefined,
|
||||||
additionalDirectories: additionalDirectories ?? undefined,
|
additionalDirectories: additionalDirectories ?? undefined,
|
||||||
extraArgs: worktreeName ? { worktree: worktreeName } : undefined,
|
extraArgs: worktreeName ? { worktree: worktreeName } : undefined,
|
||||||
|
|
@ -237,108 +220,5 @@ if (claudePath) {
|
||||||
log('WARNING: Claude CLI not found — agent sessions will fail');
|
log('WARNING: Claude CLI not found — agent sessions will fail');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session sanitizer ──────────────────────────────────────────────────────
|
|
||||||
// Fixes "cache_control cannot be set for empty text blocks" API error on resume.
|
|
||||||
// The SDK stores empty text blocks with cache_control during streaming;
|
|
||||||
// the API rejects them on replay. We strip them before the SDK reads the file.
|
|
||||||
|
|
||||||
function encodeCwd(cwdPath: string): string {
|
|
||||||
return cwdPath.replace(/[^a-zA-Z0-9]/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeSessionFile(cwdPath: string, sdkSessionId: string): void {
|
|
||||||
const encoded = encodeCwd(cwdPath);
|
|
||||||
const filePath = join(homedir(), '.claude', 'projects', encoded, `${sdkSessionId}.jsonl`);
|
|
||||||
try {
|
|
||||||
sanitizeJsonlFile(filePath);
|
|
||||||
} catch (err) {
|
|
||||||
log(`sanitize: could not clean ${filePath}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeSessionFiles(cwdPath: string): void {
|
|
||||||
// For 'continue' mode, sanitize the most recent session
|
|
||||||
try {
|
|
||||||
const sessions = listSessions({ dir: cwdPath, limit: 1 });
|
|
||||||
if (sessions && sessions.length > 0) {
|
|
||||||
sanitizeSessionFile(cwdPath, (sessions[0] as Record<string, unknown>).sessionId as string);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log(`sanitize: listSessions failed: ${err}`);
|
|
||||||
// Fallback: try to find and sanitize the most recent .jsonl file
|
|
||||||
const encoded = encodeCwd(cwdPath);
|
|
||||||
const dir = join(homedir(), '.claude', 'projects', encoded);
|
|
||||||
try {
|
|
||||||
const files = readdirSync(dir).filter(f => f.endsWith('.jsonl'));
|
|
||||||
if (files.length > 0) {
|
|
||||||
// Sort by mtime descending
|
|
||||||
files.sort((a, b) => {
|
|
||||||
try {
|
|
||||||
return statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs;
|
|
||||||
} catch { return 0; }
|
|
||||||
});
|
|
||||||
sanitizeJsonlFile(join(dir, files[0]));
|
|
||||||
}
|
|
||||||
} catch { /* dir doesn't exist */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeJsonlFile(filePath: string): void {
|
|
||||||
if (!existsSync(filePath)) return;
|
|
||||||
|
|
||||||
const content = readFileSync(filePath, 'utf-8');
|
|
||||||
const lines = content.split('\n');
|
|
||||||
let modified = false;
|
|
||||||
|
|
||||||
const cleaned = lines.map(line => {
|
|
||||||
if (!line.trim()) return line;
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(line);
|
|
||||||
if (cleanContentBlocks(obj)) {
|
|
||||||
modified = true;
|
|
||||||
return JSON.stringify(obj);
|
|
||||||
}
|
|
||||||
} catch { /* skip unparseable lines */ }
|
|
||||||
return line;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (modified) {
|
|
||||||
writeFileSync(filePath, cleaned.join('\n'));
|
|
||||||
log(`sanitize: cleaned empty text blocks from ${filePath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanContentBlocks(obj: Record<string, unknown>): boolean {
|
|
||||||
let changed = false;
|
|
||||||
|
|
||||||
// Check message.content array
|
|
||||||
const msg = obj.message as Record<string, unknown> | undefined;
|
|
||||||
if (msg?.content && Array.isArray(msg.content)) {
|
|
||||||
const before = msg.content.length;
|
|
||||||
msg.content = (msg.content as Array<Record<string, unknown>>).filter(block => {
|
|
||||||
// Remove empty text blocks (with or without cache_control)
|
|
||||||
if (block.type === 'text' && (!block.text || !(block.text as string).trim())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if (msg.content.length !== before) changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check top-level content array (some formats)
|
|
||||||
if (obj.content && Array.isArray(obj.content)) {
|
|
||||||
const before = (obj.content as unknown[]).length;
|
|
||||||
obj.content = (obj.content as Array<Record<string, unknown>>).filter(block => {
|
|
||||||
if (block.type === 'text' && (!block.text || !(block.text as string).trim())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
if ((obj.content as unknown[]).length !== before) changed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Sidecar started');
|
log('Sidecar started');
|
||||||
send({ type: 'ready' });
|
send({ type: 'ready' });
|
||||||
|
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
/**
|
|
||||||
* Claude session listing — reads Claude SDK session files from disk.
|
|
||||||
*
|
|
||||||
* Sessions stored as JSONL at ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
|
|
||||||
* where <encoded-cwd> = absolute path with non-alphanumeric chars replaced by '-'.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { join } from "path";
|
|
||||||
import { homedir } from "os";
|
|
||||||
import { readdirSync, readFileSync, statSync } from "fs";
|
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ClaudeSessionInfo {
|
|
||||||
sessionId: string;
|
|
||||||
summary: string;
|
|
||||||
lastModified: number;
|
|
||||||
fileSize: number;
|
|
||||||
firstPrompt: string;
|
|
||||||
model: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Implementation ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function encodeCwd(cwd: string): string {
|
|
||||||
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List Claude sessions for a project CWD.
|
|
||||||
* Reads the first 5 lines of each .jsonl file to extract metadata.
|
|
||||||
* Returns sessions sorted by lastModified descending.
|
|
||||||
*/
|
|
||||||
export function listClaudeSessions(cwd: string): ClaudeSessionInfo[] {
|
|
||||||
const encoded = encodeCwd(cwd);
|
|
||||||
const sessionsDir = join(homedir(), ".claude", "projects", encoded);
|
|
||||||
|
|
||||||
let entries: string[];
|
|
||||||
try {
|
|
||||||
entries = readdirSync(sessionsDir);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
// ENOENT or permission error — no sessions yet
|
|
||||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
||||||
console.warn("[claude-sessions] readdir error:", err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl"));
|
|
||||||
const results: ClaudeSessionInfo[] = [];
|
|
||||||
|
|
||||||
for (const file of jsonlFiles) {
|
|
||||||
try {
|
|
||||||
const filePath = join(sessionsDir, file);
|
|
||||||
const stat = statSync(filePath);
|
|
||||||
const sessionId = file.replace(/\.jsonl$/, "");
|
|
||||||
|
|
||||||
// Read first 50 lines for metadata extraction (Claude sessions have
|
|
||||||
// many system/queue/hook events before the first user message)
|
|
||||||
const content = readFileSync(filePath, "utf-8");
|
|
||||||
const lines = content.split("\n").slice(0, 50);
|
|
||||||
|
|
||||||
let firstPrompt = "";
|
|
||||||
let model = "";
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(line);
|
|
||||||
|
|
||||||
// Extract model from init/system messages
|
|
||||||
if (!model && parsed.model) {
|
|
||||||
model = String(parsed.model);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract first user prompt
|
|
||||||
if (!firstPrompt && parsed.role === "user") {
|
|
||||||
const content = parsed.content;
|
|
||||||
if (typeof content === "string") {
|
|
||||||
firstPrompt = content;
|
|
||||||
} else if (Array.isArray(content)) {
|
|
||||||
// Content blocks format
|
|
||||||
const textBlock = content.find(
|
|
||||||
(b: Record<string, unknown>) => b.type === "text",
|
|
||||||
);
|
|
||||||
if (textBlock?.text) firstPrompt = String(textBlock.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claude session format: type="user" with message.content
|
|
||||||
if (!firstPrompt && parsed.type === "user" && parsed.message?.content) {
|
|
||||||
const mc = parsed.message.content;
|
|
||||||
if (typeof mc === "string") {
|
|
||||||
firstPrompt = mc;
|
|
||||||
} else if (Array.isArray(mc)) {
|
|
||||||
const textBlock = mc.find(
|
|
||||||
(b: Record<string, unknown>) => b.type === "text",
|
|
||||||
);
|
|
||||||
if (textBlock?.text) firstPrompt = String(textBlock.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check "human" wrapper (older format)
|
|
||||||
if (!firstPrompt && parsed.type === "human" && parsed.message?.content) {
|
|
||||||
const mc = parsed.message.content;
|
|
||||||
if (typeof mc === "string") {
|
|
||||||
firstPrompt = mc;
|
|
||||||
} else if (Array.isArray(mc)) {
|
|
||||||
const textBlock = mc.find(
|
|
||||||
(b: Record<string, unknown>) => b.type === "text",
|
|
||||||
);
|
|
||||||
if (textBlock?.text) firstPrompt = String(textBlock.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract model from assistant messages
|
|
||||||
if (!model && parsed.type === "assistant" && parsed.message?.model) {
|
|
||||||
model = String(parsed.message.model);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip malformed JSONL lines
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate first prompt for display
|
|
||||||
if (firstPrompt.length > 120) {
|
|
||||||
firstPrompt = firstPrompt.slice(0, 117) + "...";
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
sessionId,
|
|
||||||
summary: firstPrompt || "(no prompt found)",
|
|
||||||
lastModified: stat.mtimeMs,
|
|
||||||
fileSize: stat.size,
|
|
||||||
firstPrompt: firstPrompt || "",
|
|
||||||
model: model || "unknown",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Skip corrupt/unreadable files
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by lastModified descending (newest first)
|
|
||||||
results.sort((a, b) => b.lastModified - a.lastModified);
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Message types for display ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface SessionMessage {
|
|
||||||
id: string;
|
|
||||||
role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'system';
|
|
||||||
content: string;
|
|
||||||
timestamp: number;
|
|
||||||
model?: string;
|
|
||||||
toolName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load conversation messages from a Claude JSONL session file.
|
|
||||||
* Extracts user prompts and assistant responses for display.
|
|
||||||
*/
|
|
||||||
export function loadClaudeSessionMessages(cwd: string, sdkSessionId: string): SessionMessage[] {
|
|
||||||
const encoded = encodeCwd(cwd);
|
|
||||||
const filePath = join(homedir(), ".claude", "projects", encoded, `${sdkSessionId}.jsonl`);
|
|
||||||
|
|
||||||
let content: string;
|
|
||||||
try {
|
|
||||||
content = readFileSync(filePath, "utf-8");
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages: SessionMessage[] = [];
|
|
||||||
const lines = content.split("\n");
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(line);
|
|
||||||
|
|
||||||
// User messages
|
|
||||||
if (obj.type === "user" && obj.message?.content) {
|
|
||||||
const mc = obj.message.content;
|
|
||||||
let text = "";
|
|
||||||
if (typeof mc === "string") {
|
|
||||||
text = mc;
|
|
||||||
} else if (Array.isArray(mc)) {
|
|
||||||
const tb = mc.find((b: Record<string, unknown>) => b.type === "text");
|
|
||||||
if (tb?.text) text = String(tb.text);
|
|
||||||
}
|
|
||||||
if (text) {
|
|
||||||
messages.push({
|
|
||||||
id: obj.uuid || `user-${messages.length}`,
|
|
||||||
role: "user",
|
|
||||||
content: text,
|
|
||||||
timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assistant messages
|
|
||||||
if (obj.type === "assistant" && obj.message?.content) {
|
|
||||||
const mc = obj.message.content;
|
|
||||||
let text = "";
|
|
||||||
if (Array.isArray(mc)) {
|
|
||||||
for (const block of mc) {
|
|
||||||
if (block.type === "text" && block.text) {
|
|
||||||
text += block.text;
|
|
||||||
} else if (block.type === "tool_use") {
|
|
||||||
messages.push({
|
|
||||||
id: block.id || `tool-${messages.length}`,
|
|
||||||
role: "tool_call",
|
|
||||||
content: `${block.name}(${JSON.stringify(block.input || {}).slice(0, 200)})`,
|
|
||||||
timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(),
|
|
||||||
toolName: block.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (text) {
|
|
||||||
messages.push({
|
|
||||||
id: obj.uuid || obj.message?.id || `asst-${messages.length}`,
|
|
||||||
role: "assistant",
|
|
||||||
content: text,
|
|
||||||
timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(),
|
|
||||||
model: obj.message?.model,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tool results
|
|
||||||
if (obj.type === "tool_result" || (obj.role === "tool" && obj.content)) {
|
|
||||||
const rc = obj.content;
|
|
||||||
let text = "";
|
|
||||||
if (typeof rc === "string") {
|
|
||||||
text = rc;
|
|
||||||
} else if (Array.isArray(rc)) {
|
|
||||||
const tb = rc.find((b: Record<string, unknown>) => b.type === "text");
|
|
||||||
if (tb?.text) text = String(tb.text);
|
|
||||||
}
|
|
||||||
if (text && text.length > 0) {
|
|
||||||
messages.push({
|
|
||||||
id: obj.uuid || `result-${messages.length}`,
|
|
||||||
role: "tool_result",
|
|
||||||
content: text.length > 500 ? text.slice(0, 497) + "..." : text,
|
|
||||||
timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
import type { SidecarManager } from "../sidecar-manager.ts";
|
import type { SidecarManager } from "../sidecar-manager.ts";
|
||||||
import type { SessionDb } from "../session-db.ts";
|
import type { SessionDb } from "../session-db.ts";
|
||||||
import type { SearchDb } from "../search-db.ts";
|
import type { SearchDb } from "../search-db.ts";
|
||||||
import { listClaudeSessions, loadClaudeSessionMessages } from "../claude-sessions.ts";
|
|
||||||
|
|
||||||
export function createAgentHandlers(
|
export function createAgentHandlers(
|
||||||
sidecarManager: SidecarManager,
|
sidecarManager: SidecarManager,
|
||||||
|
|
@ -14,13 +13,13 @@ export function createAgentHandlers(
|
||||||
searchDb?: SearchDb,
|
searchDb?: SearchDb,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
"agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName, resumeMode, resumeSessionId }: Record<string, unknown>) => {
|
"agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName }: Record<string, unknown>) => {
|
||||||
try {
|
try {
|
||||||
const result = sidecarManager.startSession(
|
const result = sidecarManager.startSession(
|
||||||
sessionId as string,
|
sessionId as string,
|
||||||
provider as string,
|
provider as string,
|
||||||
prompt as string,
|
prompt as string,
|
||||||
{ cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName, resumeMode, resumeSessionId } as Record<string, unknown>,
|
{ cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName } as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
|
@ -170,23 +169,5 @@ export function createAgentHandlers(
|
||||||
return { messages: [] };
|
return { messages: [] };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"session.listClaude": ({ cwd }: { cwd: string }) => {
|
|
||||||
try {
|
|
||||||
return { sessions: listClaudeSessions(cwd) };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[session.listClaude]", err);
|
|
||||||
return { sessions: [] };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
"session.loadMessages": ({ cwd, sdkSessionId }: { cwd: string; sdkSessionId: string }) => {
|
|
||||||
try {
|
|
||||||
return { messages: loadClaudeSessionMessages(cwd, sdkSessionId) };
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[session.loadMessages]", err);
|
|
||||||
return { messages: [] };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,6 @@ export interface StartSessionOptions {
|
||||||
extraEnv?: Record<string, string>;
|
extraEnv?: Record<string, string>;
|
||||||
additionalDirectories?: string[];
|
additionalDirectories?: string[];
|
||||||
worktreeName?: string;
|
worktreeName?: string;
|
||||||
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */
|
|
||||||
resumeMode?: "new" | "continue" | "resume";
|
|
||||||
/** Required when resumeMode='resume' — the Claude SDK session ID to resume. */
|
|
||||||
resumeSessionId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageCallback = (sessionId: string, messages: AgentMessage[]) => void;
|
type MessageCallback = (sessionId: string, messages: AgentMessage[]) => void;
|
||||||
|
|
@ -287,12 +283,6 @@ export class SidecarManager {
|
||||||
if (options.worktreeName) {
|
if (options.worktreeName) {
|
||||||
queryMsg.worktreeName = options.worktreeName;
|
queryMsg.worktreeName = options.worktreeName;
|
||||||
}
|
}
|
||||||
if (options.resumeMode && options.resumeMode !== "new") {
|
|
||||||
queryMsg.resumeMode = options.resumeMode;
|
|
||||||
}
|
|
||||||
if (options.resumeSessionId) {
|
|
||||||
queryMsg.resumeSessionId = options.resumeSessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
dbg(`Sending query: ${JSON.stringify(queryMsg).slice(0, 200)}...`);
|
dbg(`Sending query: ${JSON.stringify(queryMsg).slice(0, 200)}...`);
|
||||||
this.writeToProcess(sessionId, queryMsg);
|
this.writeToProcess(sessionId, queryMsg);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import ChatInput from "./ChatInput.svelte";
|
import ChatInput from "./ChatInput.svelte";
|
||||||
import SessionPicker from "./SessionPicker.svelte";
|
|
||||||
import type { AgentMessage, AgentStatus } from "./agent-store.svelte.ts";
|
import type { AgentMessage, AgentStatus } from "./agent-store.svelte.ts";
|
||||||
import { getPendingResume } from "./agent-store.svelte.ts";
|
|
||||||
import { t } from "./i18n.svelte.ts";
|
import { t } from "./i18n.svelte.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -16,8 +14,6 @@
|
||||||
profile?: string;
|
profile?: string;
|
||||||
contextPct?: number;
|
contextPct?: number;
|
||||||
burnRate?: number;
|
burnRate?: number;
|
||||||
projectId?: string;
|
|
||||||
cwd?: string;
|
|
||||||
onSend?: (text: string) => void;
|
onSend?: (text: string) => void;
|
||||||
onStop?: () => void;
|
onStop?: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -29,8 +25,6 @@
|
||||||
tokens,
|
tokens,
|
||||||
model = "claude-opus-4-5",
|
model = "claude-opus-4-5",
|
||||||
provider = "claude",
|
provider = "claude",
|
||||||
projectId = "",
|
|
||||||
cwd = "",
|
|
||||||
contextPct = 0,
|
contextPct = 0,
|
||||||
onSend,
|
onSend,
|
||||||
onStop,
|
onStop,
|
||||||
|
|
@ -56,8 +50,6 @@
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
function getPendingResumeForProject() { return projectId ? getPendingResume(projectId) : undefined; }
|
|
||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
const text = promptText.trim();
|
const text = promptText.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
@ -123,14 +115,6 @@
|
||||||
<span class="strip-tokens">{fmtTokens(tokens)} tok</span>
|
<span class="strip-tokens">{fmtTokens(tokens)} tok</span>
|
||||||
<span class="strip-sep" aria-hidden="true"></span>
|
<span class="strip-sep" aria-hidden="true"></span>
|
||||||
<span class="strip-cost">{fmtCost(costUsd)}</span>
|
<span class="strip-cost">{fmtCost(costUsd)}</span>
|
||||||
{#if projectId && cwd && provider === "claude"}
|
|
||||||
<SessionPicker
|
|
||||||
{projectId}
|
|
||||||
{provider}
|
|
||||||
{cwd}
|
|
||||||
onNewSession={onSend}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{#if status === "running" && onStop}
|
{#if status === "running" && onStop}
|
||||||
<button
|
<button
|
||||||
class="strip-stop-btn"
|
class="strip-stop-btn"
|
||||||
|
|
@ -239,7 +223,7 @@
|
||||||
{model}
|
{model}
|
||||||
{provider}
|
{provider}
|
||||||
{contextPct}
|
{contextPct}
|
||||||
placeholder={getPendingResumeForProject() ? 'Type to resume session...' : t("agent.prompt.placeholder")}
|
placeholder={t("agent.prompt.placeholder")}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onInput={(v) => (promptText = v)}
|
onInput={(v) => (promptText = v)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@
|
||||||
getProjects,
|
getProjects,
|
||||||
getGroups,
|
getGroups,
|
||||||
getActiveGroupId,
|
getActiveGroupId,
|
||||||
getProjectsForGroup,
|
|
||||||
getActiveGroup, getFilteredProjects,
|
getActiveGroup, getFilteredProjects,
|
||||||
getTotalCostDerived, getTotalTokensDerived,
|
getTotalCostDerived, getTotalTokensDerived,
|
||||||
setActiveGroup,
|
setActiveGroup,
|
||||||
|
|
@ -384,7 +383,7 @@
|
||||||
title="{group.name} (Ctrl+{i + 1})"
|
title="{group.name} (Ctrl+{i + 1})"
|
||||||
>
|
>
|
||||||
<GroupStatusDots
|
<GroupStatusDots
|
||||||
projects={getProjectsForGroup(group.id)}
|
projects={group.projects ?? []}
|
||||||
groupNumber={i + 1}
|
groupNumber={i + 1}
|
||||||
isActive={getActiveGroupId() === group.id}
|
isActive={getActiveGroupId() === group.id}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -276,8 +276,6 @@
|
||||||
costUsd={getAgentCost()}
|
costUsd={getAgentCost()}
|
||||||
tokens={getAgentTokens()}
|
tokens={getAgentTokens()}
|
||||||
model={getAgentModel()}
|
model={getAgentModel()}
|
||||||
projectId={id}
|
|
||||||
{cwd}
|
|
||||||
{provider}
|
{provider}
|
||||||
{profile}
|
{profile}
|
||||||
{contextPct}
|
{contextPct}
|
||||||
|
|
|
||||||
|
|
@ -1,341 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import type { ClaudeSessionInfo } from './agent-store.svelte.ts';
|
|
||||||
import { listProjectSessions, setPendingResume, getPendingResume, clearPendingResume, loadSessionHistory } from './agent-store.svelte.ts';
|
|
||||||
import { t } from './i18n.svelte.ts';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
projectId: string;
|
|
||||||
provider: string;
|
|
||||||
cwd: string;
|
|
||||||
currentSessionId?: string;
|
|
||||||
onNewSession?: (prompt: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
projectId,
|
|
||||||
provider,
|
|
||||||
cwd,
|
|
||||||
currentSessionId,
|
|
||||||
onNewSession,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let open = $state(false);
|
|
||||||
let sessions = $state<ClaudeSessionInfo[]>([]);
|
|
||||||
let loading = $state(false);
|
|
||||||
let containerEl: HTMLDivElement;
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
fetchSessions();
|
|
||||||
// Close on outside click
|
|
||||||
function handleClick(e: MouseEvent) {
|
|
||||||
if (containerEl && !containerEl.contains(e.target as Node)) {
|
|
||||||
open = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClick);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClick);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchSessions() {
|
|
||||||
if (!cwd) return;
|
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
sessions = await listProjectSessions(projectId, cwd);
|
|
||||||
} catch {
|
|
||||||
sessions = [];
|
|
||||||
}
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle() {
|
|
||||||
open = !open;
|
|
||||||
if (open) fetchSessions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDate(ms: number): string {
|
|
||||||
const d = new Date(ms);
|
|
||||||
const now = Date.now();
|
|
||||||
const diffH = (now - ms) / 3_600_000;
|
|
||||||
if (diffH < 1) return `${Math.max(1, Math.round(diffH * 60))}m ago`;
|
|
||||||
if (diffH < 24) return `${Math.round(diffH)}h ago`;
|
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtSize(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes}B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncate(s: string, max: number): string {
|
|
||||||
return s.length > max ? s.slice(0, max - 3) + '...' : s;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleContinue() {
|
|
||||||
open = false;
|
|
||||||
setPendingResume(projectId, 'continue');
|
|
||||||
// Load the most recent session's messages for display
|
|
||||||
if (sessions.length > 0) {
|
|
||||||
await loadSessionHistory(projectId, sessions[0].sessionId, cwd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResume(sdkSessionId: string) {
|
|
||||||
open = false;
|
|
||||||
setPendingResume(projectId, 'resume', sdkSessionId);
|
|
||||||
// Load the selected session's messages for display
|
|
||||||
await loadSessionHistory(projectId, sdkSessionId, cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNew() {
|
|
||||||
open = false;
|
|
||||||
clearPendingResume(projectId);
|
|
||||||
// Don't start agent — just clear any pending resume so next Send creates fresh session
|
|
||||||
// The user types their prompt, then hits Send
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="session-picker" bind:this={containerEl}>
|
|
||||||
<button
|
|
||||||
class="picker-trigger"
|
|
||||||
onclick={toggle}
|
|
||||||
title="Session history"
|
|
||||||
aria-label="Session history"
|
|
||||||
aria-expanded={open}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
||||||
<path d="M8 3.5a.5.5 0 0 0-1 0V8a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 7.71V3.5z"/>
|
|
||||||
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="picker-dropdown" style:display={open ? 'flex' : 'none'}>
|
|
||||||
<div class="dropdown-header">
|
|
||||||
<span class="dropdown-title">Sessions</span>
|
|
||||||
<button class="new-btn" onclick={handleNew}>
|
|
||||||
+ New
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if sessions.length > 0}
|
|
||||||
<button class="continue-btn" onclick={handleContinue}>
|
|
||||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="continue-icon">
|
|
||||||
<path d="M4 2l8 6-8 6V2z"/>
|
|
||||||
</svg>
|
|
||||||
Continue last session
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="dropdown-divider"></div>
|
|
||||||
|
|
||||||
<div class="session-list">
|
|
||||||
{#if loading}
|
|
||||||
<div class="session-empty">Loading...</div>
|
|
||||||
{:else if sessions.length === 0}
|
|
||||||
<div class="session-empty">No previous sessions</div>
|
|
||||||
{:else}
|
|
||||||
{#each sessions as s (s.sessionId)}
|
|
||||||
<button
|
|
||||||
class="session-row"
|
|
||||||
class:active={currentSessionId === s.sessionId}
|
|
||||||
onclick={() => handleResume(s.sessionId)}
|
|
||||||
>
|
|
||||||
<div class="session-prompt">{truncate(s.firstPrompt || s.summary, 60)}</div>
|
|
||||||
<div class="session-meta">
|
|
||||||
<span class="meta-date">{fmtDate(s.lastModified)}</span>
|
|
||||||
<span class="meta-sep" aria-hidden="true"></span>
|
|
||||||
<span class="meta-size">{fmtSize(s.fileSize)}</span>
|
|
||||||
<span class="meta-sep" aria-hidden="true"></span>
|
|
||||||
<span class="meta-model">{s.model}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.session-picker {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--ctp-surface1);
|
|
||||||
border-radius: 0.2rem;
|
|
||||||
color: var(--ctp-overlay1);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.12s, border-color 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-trigger:hover {
|
|
||||||
color: var(--ctp-text);
|
|
||||||
border-color: var(--ctp-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-trigger svg {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.25rem);
|
|
||||||
right: 0;
|
|
||||||
z-index: 100;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 18rem;
|
|
||||||
max-height: 20rem;
|
|
||||||
background: var(--ctp-mantle);
|
|
||||||
border: 1px solid var(--ctp-surface1);
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--ctp-crust) 60%, transparent);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.375rem 0.5rem;
|
|
||||||
border-bottom: 1px solid var(--ctp-surface0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-title {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ctp-subtext1);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-btn {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--ctp-blue);
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
border-radius: 0.2rem;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.new-btn:hover {
|
|
||||||
background: color-mix(in srgb, var(--ctp-blue) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.continue-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.375rem 0.5rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--ctp-green);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.continue-btn:hover {
|
|
||||||
background: color-mix(in srgb, var(--ctp-green) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.continue-icon {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-divider {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--ctp-surface0);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-list {
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-list::-webkit-scrollbar {
|
|
||||||
width: 0.25rem;
|
|
||||||
}
|
|
||||||
.session-list::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.session-list::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--ctp-surface1);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-empty {
|
|
||||||
padding: 1rem 0.5rem;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--ctp-overlay0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.375rem 0.5rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-row:hover {
|
|
||||||
background: var(--ctp-surface0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-row.active {
|
|
||||||
background: color-mix(in srgb, var(--ctp-blue) 12%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-prompt {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--ctp-text);
|
|
||||||
line-height: 1.3;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
color: var(--ctp-overlay1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-sep {
|
|
||||||
width: 1px;
|
|
||||||
height: 0.5rem;
|
|
||||||
background: var(--ctp-surface1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-model {
|
|
||||||
color: var(--ctp-overlay0);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -2,23 +2,23 @@
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
import { FitAddon } from '@xterm/addon-fit';
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
|
// CanvasAddon and ImageAddon require xterm ^5.0.0 — disabled on xterm 6.x
|
||||||
|
// xterm 6's default renderer uses DOM (no Canvas/WebGL needed)
|
||||||
|
// TODO: re-enable when @xterm/addon-canvas releases a 6.x-compatible version
|
||||||
|
// import { CanvasAddon } from '@xterm/addon-canvas';
|
||||||
|
// import { ImageAddon } from '@xterm/addon-image';
|
||||||
import { appRpc } from './rpc.ts';
|
import { appRpc } from './rpc.ts';
|
||||||
import { fontStore } from './font-store.svelte.ts';
|
import { fontStore } from './font-store.svelte.ts';
|
||||||
import { themeStore } from './theme-store.svelte.ts';
|
import { themeStore } from './theme-store.svelte.ts';
|
||||||
import { getXtermTheme } from './themes.ts';
|
import { getXtermTheme } from './themes.ts';
|
||||||
import { appState } from './app-state.svelte.ts';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
/** Working directory to open the shell in. */
|
/** Working directory to open the shell in. */
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
/** When true, terminal is read-only (agent preview). No PTY, no stdin. */
|
|
||||||
readonly?: boolean;
|
|
||||||
/** Project ID — required for agent preview to read bash output. */
|
|
||||||
projectId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { sessionId, cwd, readonly = false, projectId }: Props = $props();
|
let { sessionId, cwd }: Props = $props();
|
||||||
|
|
||||||
let termEl: HTMLDivElement;
|
let termEl: HTMLDivElement;
|
||||||
let term: Terminal;
|
let term: Terminal;
|
||||||
|
|
@ -26,10 +26,8 @@
|
||||||
let unsubFont: (() => void) | null = null;
|
let unsubFont: (() => void) | null = null;
|
||||||
let ro: ResizeObserver | null = null;
|
let ro: ResizeObserver | null = null;
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
// Fix #5: Store listener cleanup functions to prevent leaks
|
||||||
let listenerCleanups: Array<() => void> = [];
|
let listenerCleanups: Array<() => void> = [];
|
||||||
/** Index into bashOutputLines — tracks how many lines have been written to xterm. */
|
|
||||||
let writtenIndex = 0;
|
|
||||||
let bashPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
/** Decode a base64 string from the daemon into a Uint8Array. */
|
/** Decode a base64 string from the daemon into a Uint8Array. */
|
||||||
function decodeBase64(b64: string): Uint8Array {
|
function decodeBase64(b64: string): Uint8Array {
|
||||||
|
|
@ -39,17 +37,6 @@
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write new bash output lines to the readonly terminal. */
|
|
||||||
function flushBashLines(): void {
|
|
||||||
if (!projectId || !term) return;
|
|
||||||
const t = appState.project.getState(projectId).terminals;
|
|
||||||
const lines = t.bashOutputLines;
|
|
||||||
while (writtenIndex < lines.length) {
|
|
||||||
term.writeln(lines[writtenIndex]);
|
|
||||||
writtenIndex++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const currentTheme = themeStore.currentTheme;
|
const currentTheme = themeStore.currentTheme;
|
||||||
const termFamily = fontStore.termFontFamily || 'JetBrains Mono, Fira Code, monospace';
|
const termFamily = fontStore.termFontFamily || 'JetBrains Mono, Fira Code, monospace';
|
||||||
|
|
@ -59,21 +46,20 @@
|
||||||
theme: getXtermTheme(currentTheme),
|
theme: getXtermTheme(currentTheme),
|
||||||
fontFamily: termFamily,
|
fontFamily: termFamily,
|
||||||
fontSize: termSize,
|
fontSize: termSize,
|
||||||
cursorBlink: !readonly,
|
cursorBlink: true,
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
scrollback: 5000,
|
scrollback: 5000,
|
||||||
disableStdin: readonly,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (readonly) {
|
|
||||||
term.attachCustomKeyEventHandler(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
fitAddon = new FitAddon();
|
fitAddon = new FitAddon();
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
|
// NOTE: CanvasAddon and ImageAddon MUST be loaded AFTER term.open()
|
||||||
|
// because they access _linkifier2 which is created during open()
|
||||||
|
|
||||||
const openAndLoadAddons = () => {
|
const openAndLoadAddons = () => {
|
||||||
term.open(termEl);
|
term.open(termEl);
|
||||||
|
// xterm 6.x uses improved default DOM renderer — no Canvas/WebGL addon needed
|
||||||
|
// Re-enable when addons release 6.x-compatible versions
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -92,102 +78,99 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Read cursor/scrollback settings ─────────────────────────────────
|
// ── Read cursor/scrollback settings ─────────────────────────────────
|
||||||
if (!readonly) {
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const { settings } = await appRpc.request['settings.getAll']({});
|
const { settings } = await appRpc.request['settings.getAll']({});
|
||||||
if (settings['cursor_style']) {
|
if (settings['cursor_style']) {
|
||||||
const style = settings['cursor_style'];
|
const style = settings['cursor_style'];
|
||||||
if (style === 'block' || style === 'underline' || style === 'bar') {
|
if (style === 'block' || style === 'underline' || style === 'bar') {
|
||||||
term.options.cursorStyle = style;
|
term.options.cursorStyle = style;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (settings['cursor_blink'] === 'false') {
|
}
|
||||||
term.options.cursorBlink = false;
|
if (settings['cursor_blink'] === 'false') {
|
||||||
}
|
term.options.cursorBlink = false;
|
||||||
if (settings['scrollback']) {
|
}
|
||||||
const sb = parseInt(settings['scrollback'], 10);
|
if (settings['scrollback']) {
|
||||||
if (!isNaN(sb) && sb >= 0) term.options.scrollback = sb;
|
const sb = parseInt(settings['scrollback'], 10);
|
||||||
}
|
if (!isNaN(sb) && sb >= 0) term.options.scrollback = sb;
|
||||||
} catch { /* non-critical — use defaults */ }
|
}
|
||||||
})();
|
} catch { /* non-critical — use defaults */ }
|
||||||
}
|
})();
|
||||||
|
|
||||||
// ── Subscribe to terminal font changes ─────────────────────────────────
|
// ── Subscribe to terminal font changes ─────────────────────────────────
|
||||||
|
|
||||||
unsubFont = fontStore.onTermFontChange((family: string, size: number) => {
|
unsubFont = fontStore.onTermFontChange((family: string, size: number) => {
|
||||||
term.options.fontFamily = family || 'JetBrains Mono, Fira Code, monospace';
|
term.options.fontFamily = family || 'JetBrains Mono, Fira Code, monospace';
|
||||||
term.options.fontSize = size;
|
term.options.fontSize = size;
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
if (!readonly) {
|
appRpc.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {});
|
||||||
appRpc.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {});
|
});
|
||||||
|
|
||||||
|
// ── Connect to PTY daemon (fire-and-forget from onMount) ───────────────
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
// Read default_shell and default_cwd from settings if not provided
|
||||||
|
let effectiveCwd = cwd;
|
||||||
|
try {
|
||||||
|
const { settings } = await appRpc.request['settings.getAll']({});
|
||||||
|
if (!effectiveCwd && settings['default_cwd']) {
|
||||||
|
effectiveCwd = settings['default_cwd'];
|
||||||
|
}
|
||||||
|
// default_shell is handled by agor-ptyd, not needed in create params
|
||||||
|
} catch { /* use provided or defaults */ }
|
||||||
|
|
||||||
|
const { cols, rows } = term;
|
||||||
|
const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd: effectiveCwd });
|
||||||
|
if (!result?.ok) {
|
||||||
|
term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`);
|
||||||
|
term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m');
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Receive output from daemon ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const outputHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => {
|
||||||
|
if (destroyed || sid !== sessionId) return;
|
||||||
|
term.write(decodeBase64(data));
|
||||||
|
};
|
||||||
|
appRpc.addMessageListener('pty.output', outputHandler);
|
||||||
|
|
||||||
|
const closedHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => {
|
||||||
|
if (destroyed || sid !== sessionId) return;
|
||||||
|
term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`);
|
||||||
|
};
|
||||||
|
appRpc.addMessageListener('pty.closed', closedHandler);
|
||||||
|
|
||||||
|
// Fix #5: Store cleanup functions for message listeners
|
||||||
|
listenerCleanups.push(
|
||||||
|
() => appRpc.removeMessageListener?.('pty.output', outputHandler),
|
||||||
|
() => appRpc.removeMessageListener?.('pty.closed', closedHandler),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Send user input to daemon ──────────────────────────────────────────
|
||||||
|
|
||||||
|
// Feature 5: Max terminal paste chunk (64KB) — truncate with warning
|
||||||
|
const MAX_PASTE_CHUNK = 64 * 1024;
|
||||||
|
term.onData((data: string) => {
|
||||||
|
let payload = data;
|
||||||
|
if (payload.length > MAX_PASTE_CHUNK) {
|
||||||
|
console.warn(`[terminal] Paste truncated from ${payload.length} to ${MAX_PASTE_CHUNK} bytes`);
|
||||||
|
payload = payload.slice(0, MAX_PASTE_CHUNK);
|
||||||
|
term.writeln('\r\n\x1b[33m[agor] Paste truncated to 64KB\x1b[0m');
|
||||||
|
}
|
||||||
|
appRpc.request['pty.write']({ sessionId, data: payload }).catch((err: unknown) => {
|
||||||
|
console.error('[pty.write] error:', err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (readonly) {
|
// ── Sync resize events to daemon ───────────────────────────────────────
|
||||||
// ── Agent preview mode: read bash output from project state ──────────
|
|
||||||
flushBashLines();
|
|
||||||
bashPollTimer = setInterval(flushBashLines, 500);
|
|
||||||
} else {
|
|
||||||
// ── Connect to PTY daemon (fire-and-forget from onMount) ─────────────
|
|
||||||
void (async () => {
|
|
||||||
let effectiveCwd = cwd;
|
|
||||||
try {
|
|
||||||
const { settings } = await appRpc.request['settings.getAll']({});
|
|
||||||
if (!effectiveCwd && settings['default_cwd']) {
|
|
||||||
effectiveCwd = settings['default_cwd'];
|
|
||||||
}
|
|
||||||
} catch { /* use provided or defaults */ }
|
|
||||||
|
|
||||||
const { cols, rows } = term;
|
term.onResize(({ cols: c, rows: r }) => {
|
||||||
const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd: effectiveCwd });
|
appRpc.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {});
|
||||||
if (!result?.ok) {
|
|
||||||
term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`);
|
|
||||||
term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ── Receive output from daemon ─────────────────────────────────────────
|
|
||||||
const outputHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => {
|
|
||||||
if (destroyed || sid !== sessionId) return;
|
|
||||||
term.write(decodeBase64(data));
|
|
||||||
};
|
|
||||||
appRpc.addMessageListener('pty.output', outputHandler);
|
|
||||||
|
|
||||||
const closedHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => {
|
|
||||||
if (destroyed || sid !== sessionId) return;
|
|
||||||
term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`);
|
|
||||||
};
|
|
||||||
appRpc.addMessageListener('pty.closed', closedHandler);
|
|
||||||
|
|
||||||
listenerCleanups.push(
|
|
||||||
() => appRpc.removeMessageListener?.('pty.output', outputHandler),
|
|
||||||
() => appRpc.removeMessageListener?.('pty.closed', closedHandler),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Send user input to daemon ──────────────────────────────────────────
|
|
||||||
const MAX_PASTE_CHUNK = 64 * 1024;
|
|
||||||
term.onData((data: string) => {
|
|
||||||
let payload = data;
|
|
||||||
if (payload.length > MAX_PASTE_CHUNK) {
|
|
||||||
console.warn(`[terminal] Paste truncated from ${payload.length} to ${MAX_PASTE_CHUNK} bytes`);
|
|
||||||
payload = payload.slice(0, MAX_PASTE_CHUNK);
|
|
||||||
term.writeln('\r\n\x1b[33m[agor] Paste truncated to 64KB\x1b[0m');
|
|
||||||
}
|
|
||||||
appRpc.request['pty.write']({ sessionId, data: payload }).catch((err: unknown) => {
|
|
||||||
console.error('[pty.write] error:', err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Sync resize events to daemon ───────────────────────────────────────
|
|
||||||
term.onResize(({ cols: c, rows: r }) => {
|
|
||||||
appRpc.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── ResizeObserver: re-fit on container resize + visibility ────────────
|
|
||||||
ro = new ResizeObserver(() => {
|
|
||||||
requestAnimationFrame(() => { fitAddon.fit(); });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ro = new ResizeObserver(() => { fitAddon.fit(); });
|
||||||
ro.observe(termEl);
|
ro.observe(termEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -195,14 +178,12 @@
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
unsubFont?.();
|
unsubFont?.();
|
||||||
ro?.disconnect();
|
ro?.disconnect();
|
||||||
if (bashPollTimer) clearInterval(bashPollTimer);
|
// Fix #5: Clean up all message listeners to prevent leaks
|
||||||
for (const cleanup of listenerCleanups) {
|
for (const cleanup of listenerCleanups) {
|
||||||
try { cleanup(); } catch { /* ignore */ }
|
try { cleanup(); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
listenerCleanups = [];
|
listenerCleanups = [];
|
||||||
if (!readonly) {
|
appRpc.request['pty.close']({ sessionId }).catch(() => {});
|
||||||
appRpc.request['pty.close']({ sessionId }).catch(() => {});
|
|
||||||
}
|
|
||||||
term?.dispose();
|
term?.dispose();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -40,23 +40,10 @@
|
||||||
blurTerminal();
|
blurTerminal();
|
||||||
appState.project.terminals.toggleExpanded(projectId);
|
appState.project.terminals.toggleExpanded(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePreview() {
|
|
||||||
blurTerminal();
|
|
||||||
appState.project.terminals.toggleAgentPreview(projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasPreview(): boolean {
|
|
||||||
return getTerminals().tabs.some(t => t.kind === 'agentPreview');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Wrapper: flex shrinks to tab bar height when collapsed, fills space when expanded -->
|
<!-- Wrapper: uses flex to push tab bar to bottom when terminal is collapsed -->
|
||||||
<div
|
<div class="term-wrapper" style="--accent: {accent}">
|
||||||
class="term-wrapper"
|
|
||||||
style="--accent: {accent}"
|
|
||||||
style:flex={getTerminals().expanded ? '1' : '0 0 auto'}
|
|
||||||
>
|
|
||||||
<!-- Tab bar: always visible, acts as divider -->
|
<!-- Tab bar: always visible, acts as divider -->
|
||||||
<div class="term-bar" role="toolbar" aria-label="Terminal tabs" tabindex="-1" onmousedown={blurTerminal}>
|
<div class="term-bar" role="toolbar" aria-label="Terminal tabs" tabindex="-1" onmousedown={blurTerminal}>
|
||||||
<button
|
<button
|
||||||
|
|
@ -84,7 +71,6 @@
|
||||||
<div
|
<div
|
||||||
class="term-tab"
|
class="term-tab"
|
||||||
class:active={getTerminals().activeTabId === tab.id}
|
class:active={getTerminals().activeTabId === tab.id}
|
||||||
class:preview={tab.kind === 'agentPreview'}
|
|
||||||
role="tab"
|
role="tab"
|
||||||
tabindex={getTerminals().activeTabId === tab.id ? 0 : -1}
|
tabindex={getTerminals().activeTabId === tab.id ? 0 : -1}
|
||||||
aria-selected={getTerminals().activeTabId === tab.id}
|
aria-selected={getTerminals().activeTabId === tab.id}
|
||||||
|
|
@ -92,7 +78,7 @@
|
||||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') activateTab(tab.id); }}
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') activateTab(tab.id); }}
|
||||||
>
|
>
|
||||||
<span class="tab-label">{tab.title}</span>
|
<span class="tab-label">{tab.title}</span>
|
||||||
{#if tab.kind === 'pty'}
|
{#if getTerminals().tabs.length > 1}
|
||||||
<button
|
<button
|
||||||
class="tab-close"
|
class="tab-close"
|
||||||
aria-label="Close {tab.title}"
|
aria-label="Close {tab.title}"
|
||||||
|
|
@ -101,37 +87,17 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<button class="tab-add" onclick={() => addTab()} title="New terminal">+</button>
|
<button class="tab-add" onclick={() => addTab()}>+</button>
|
||||||
<button
|
|
||||||
class="tab-eye"
|
|
||||||
class:active={hasPreview()}
|
|
||||||
onclick={togglePreview}
|
|
||||||
title={hasPreview() ? 'Hide agent preview' : 'Show agent preview'}
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
|
||||||
<circle cx="12" cy="12" r="3"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Terminal panes: always in DOM, display toggled.
|
<!-- Terminal panes: always in DOM, display toggled.
|
||||||
WebKitGTK corrupts hit-test tree on DOM add/remove during click. -->
|
WebKitGTK corrupts hit-test tree on DOM add/remove during click. -->
|
||||||
<div class="term-panes" style:display={getTerminals().expanded ? 'block' : 'none'}>
|
<div class="term-panes" style:display={getTerminals().expanded ? 'block' : 'none'}>
|
||||||
<!-- Empty state placeholder (always in DOM, toggled via display) -->
|
|
||||||
<div class="term-empty" style:display={getTerminals().tabs.length === 0 ? 'flex' : 'none'}>
|
|
||||||
No terminals — click + to add one
|
|
||||||
</div>
|
|
||||||
{#each getTerminals().tabs as tab (tab.id)}
|
{#each getTerminals().tabs as tab (tab.id)}
|
||||||
{#if getTerminals().mounted.has(tab.id)}
|
{#if getTerminals().mounted.has(tab.id)}
|
||||||
<div class="term-pane" style:display={getTerminals().activeTabId === tab.id ? 'flex' : 'none'}>
|
<div class="term-pane" style:display={getTerminals().activeTabId === tab.id ? 'flex' : 'none'}>
|
||||||
<Terminal
|
<Terminal sessionId={tab.id} {cwd} />
|
||||||
sessionId={tab.id}
|
|
||||||
{cwd}
|
|
||||||
readonly={tab.kind === 'agentPreview'}
|
|
||||||
{projectId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -142,6 +108,7 @@
|
||||||
.term-wrapper {
|
.term-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,7 +169,6 @@
|
||||||
}
|
}
|
||||||
.term-tab:hover { color: var(--ctp-text); }
|
.term-tab:hover { color: var(--ctp-text); }
|
||||||
.term-tab.active { color: var(--ctp-text); border-bottom-color: var(--accent); }
|
.term-tab.active { color: var(--ctp-text); border-bottom-color: var(--accent); }
|
||||||
.term-tab.preview { font-style: italic; }
|
|
||||||
.tab-label { pointer-events: none; }
|
.tab-label { pointer-events: none; }
|
||||||
|
|
||||||
.tab-close {
|
.tab-close {
|
||||||
|
|
@ -224,7 +190,7 @@
|
||||||
}
|
}
|
||||||
.tab-close:hover { background: var(--ctp-surface1); color: var(--ctp-red); }
|
.tab-close:hover { background: var(--ctp-surface1); color: var(--ctp-red); }
|
||||||
|
|
||||||
.tab-add, .tab-eye {
|
.tab-add {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
|
|
@ -240,9 +206,7 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-left: 0.125rem;
|
margin-left: 0.125rem;
|
||||||
}
|
}
|
||||||
.tab-add:hover, .tab-eye:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
.tab-add:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
|
||||||
.tab-eye svg { width: 0.75rem; height: 0.75rem; }
|
|
||||||
.tab-eye.active { color: var(--ctp-blue); border-color: var(--ctp-blue); }
|
|
||||||
|
|
||||||
/* Terminal panes — fill remaining space below tab bar */
|
/* Terminal panes — fill remaining space below tab bar */
|
||||||
.term-panes {
|
.term-panes {
|
||||||
|
|
@ -256,14 +220,4 @@
|
||||||
inset: 0;
|
inset: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.term-empty {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--ctp-overlay0);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -47,21 +47,6 @@ interface StartOptions {
|
||||||
extraEnv?: Record<string, string>;
|
extraEnv?: Record<string, string>;
|
||||||
additionalDirectories?: string[];
|
additionalDirectories?: string[];
|
||||||
worktreeName?: string;
|
worktreeName?: string;
|
||||||
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */
|
|
||||||
resumeMode?: 'new' | 'continue' | 'resume';
|
|
||||||
/** Required when resumeMode='resume' — the Claude SDK session ID to resume. */
|
|
||||||
resumeSessionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Claude session listing types ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ClaudeSessionInfo {
|
|
||||||
sessionId: string;
|
|
||||||
summary: string;
|
|
||||||
lastModified: number;
|
|
||||||
fileSize: number;
|
|
||||||
firstPrompt: string;
|
|
||||||
model: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toast callback (set by App.svelte) ────────────────────────────────────────
|
// ── Toast callback (set by App.svelte) ────────────────────────────────────────
|
||||||
|
|
@ -127,78 +112,6 @@ function validateExtraEnv(env: Record<string, string> | undefined): Record<strin
|
||||||
// Map projectId -> sessionId for lookup
|
// Map projectId -> sessionId for lookup
|
||||||
const projectSessionMap = new Map<string, string>();
|
const projectSessionMap = new Map<string, string>();
|
||||||
|
|
||||||
// Pending resume: when the user selects a session to resume, we store the
|
|
||||||
// resume options here. The next startAgent() call reads and clears them.
|
|
||||||
// This avoids sending an empty prompt — the user types their message first.
|
|
||||||
interface PendingResume {
|
|
||||||
mode: 'continue' | 'resume';
|
|
||||||
sdkSessionId?: string;
|
|
||||||
}
|
|
||||||
const pendingResumes = new Map<string, PendingResume>();
|
|
||||||
|
|
||||||
/** Set a pending resume for a project — next startAgent will use these options. */
|
|
||||||
export function setPendingResume(projectId: string, mode: 'continue' | 'resume', sdkSessionId?: string): void {
|
|
||||||
pendingResumes.set(projectId, { mode, sdkSessionId });
|
|
||||||
bump();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if a project has a pending resume. */
|
|
||||||
export function getPendingResume(projectId: string): PendingResume | undefined {
|
|
||||||
void _v;
|
|
||||||
return pendingResumes.get(projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear pending resume (called after startAgent consumes it). */
|
|
||||||
export function clearPendingResume(projectId: string): void {
|
|
||||||
pendingResumes.delete(projectId);
|
|
||||||
bump();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load full conversation history from a Claude JSONL session file into the store.
|
|
||||||
* Called when user selects a session from the picker — shows the conversation
|
|
||||||
* BEFORE the user sends their next prompt.
|
|
||||||
*/
|
|
||||||
export async function loadSessionHistory(projectId: string, sdkSessionId: string, cwd: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { messages } = await appRpc.request['session.loadMessages']({ cwd, sdkSessionId }) as {
|
|
||||||
messages: Array<{ id: string; role: string; content: string; timestamp: number; model?: string; toolName?: string }>;
|
|
||||||
};
|
|
||||||
if (!messages || messages.length === 0) return;
|
|
||||||
|
|
||||||
// Create a display-only session to show the history
|
|
||||||
const displaySessionId = `${projectId}-history-${Date.now()}`;
|
|
||||||
const converted: AgentMessage[] = messages.map((m, i) => ({
|
|
||||||
id: m.id || `hist-${i}`,
|
|
||||||
seqId: i,
|
|
||||||
role: m.role === 'user' ? 'user' as const
|
|
||||||
: m.role === 'assistant' ? 'assistant' as const
|
|
||||||
: m.role === 'tool_call' ? 'tool_call' as const
|
|
||||||
: m.role === 'tool_result' ? 'tool_result' as const
|
|
||||||
: 'system' as const,
|
|
||||||
content: m.content,
|
|
||||||
timestamp: m.timestamp,
|
|
||||||
toolName: m.toolName,
|
|
||||||
}));
|
|
||||||
|
|
||||||
sessions[displaySessionId] = {
|
|
||||||
sessionId: displaySessionId,
|
|
||||||
projectId,
|
|
||||||
provider: 'claude',
|
|
||||||
status: 'done',
|
|
||||||
messages: converted,
|
|
||||||
costUsd: 0,
|
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
model: messages.find(m => m.model)?.model || 'unknown',
|
|
||||||
};
|
|
||||||
projectSessionMap.set(projectId, displaySessionId);
|
|
||||||
bump();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[agent-store] loadSessionHistory error:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map sessionId -> reactive session state
|
// Map sessionId -> reactive session state
|
||||||
let sessions = $state<Record<string, AgentSession>>({});
|
let sessions = $state<Record<string, AgentSession>>({});
|
||||||
|
|
||||||
|
|
@ -384,8 +297,6 @@ function ensureListeners() {
|
||||||
scheduleCleanup(session.sessionId, session.projectId);
|
scheduleCleanup(session.sessionId, session.projectId);
|
||||||
// Fix #14 (Codex audit): Enforce max sessions per project on completion
|
// Fix #14 (Codex audit): Enforce max sessions per project on completion
|
||||||
enforceMaxSessions(session.projectId);
|
enforceMaxSessions(session.projectId);
|
||||||
// Invalidate Claude session cache so picker refreshes
|
|
||||||
invalidateSessionCache(session.projectId);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -591,22 +502,8 @@ async function _startAgentInner(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
options: StartOptions,
|
options: StartOptions,
|
||||||
): Promise<{ ok: boolean; error?: string }> {
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
// Check for pending resume (user selected a session in the picker)
|
// If there's an existing done/error session for this project, clear it first
|
||||||
const pending = pendingResumes.get(projectId);
|
clearSession(projectId);
|
||||||
if (pending) {
|
|
||||||
pendingResumes.delete(projectId);
|
|
||||||
if (!options.resumeMode) {
|
|
||||||
options.resumeMode = pending.mode;
|
|
||||||
options.resumeSessionId = pending.sdkSessionId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isResume = options.resumeMode === 'continue' || options.resumeMode === 'resume';
|
|
||||||
|
|
||||||
// If resuming, keep existing session messages; if new, clear old session
|
|
||||||
if (!isResume) {
|
|
||||||
clearSession(projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = `${projectId}-${Date.now()}`;
|
const sessionId = `${projectId}-${Date.now()}`;
|
||||||
|
|
||||||
|
|
@ -636,38 +533,25 @@ async function _startAgentInner(
|
||||||
}
|
}
|
||||||
} catch { /* use provided or defaults */ }
|
} catch { /* use provided or defaults */ }
|
||||||
|
|
||||||
// Create reactive session state — carry forward messages if resuming
|
// Create reactive session state
|
||||||
const existingSessionId = projectSessionMap.get(projectId);
|
|
||||||
const existingMessages = (isResume && existingSessionId && sessions[existingSessionId])
|
|
||||||
? [...sessions[existingSessionId].messages]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Add the new user prompt to messages
|
|
||||||
const newUserMsg = {
|
|
||||||
id: `${sessionId}-user-0`,
|
|
||||||
seqId: nextSeqId(sessionId),
|
|
||||||
role: 'user' as const,
|
|
||||||
content: prompt,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
sessions[sessionId] = {
|
sessions[sessionId] = {
|
||||||
sessionId,
|
sessionId,
|
||||||
projectId,
|
projectId,
|
||||||
provider,
|
provider,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
messages: [...existingMessages, newUserMsg],
|
messages: [{
|
||||||
|
id: `${sessionId}-user-0`,
|
||||||
|
seqId: nextSeqId(sessionId),
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}],
|
||||||
costUsd: 0,
|
costUsd: 0,
|
||||||
inputTokens: 0,
|
inputTokens: 0,
|
||||||
outputTokens: 0,
|
outputTokens: 0,
|
||||||
model: defaultModel ?? 'claude-opus-4-5',
|
model: defaultModel ?? 'claude-opus-4-5',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clean up the old session entry if resuming (we moved messages to the new one)
|
|
||||||
if (isResume && existingSessionId && existingSessionId !== sessionId) {
|
|
||||||
delete sessions[existingSessionId];
|
|
||||||
}
|
|
||||||
|
|
||||||
projectSessionMap.set(projectId, sessionId);
|
projectSessionMap.set(projectId, sessionId);
|
||||||
bump(); // Force re-render — new session created
|
bump(); // Force re-render — new session created
|
||||||
resetStallTimer(sessionId, projectId);
|
resetStallTimer(sessionId, projectId);
|
||||||
|
|
@ -683,8 +567,6 @@ async function _startAgentInner(
|
||||||
permissionMode: permissionMode,
|
permissionMode: permissionMode,
|
||||||
claudeConfigDir: options.claudeConfigDir,
|
claudeConfigDir: options.claudeConfigDir,
|
||||||
extraEnv: validateExtraEnv(options.extraEnv),
|
extraEnv: validateExtraEnv(options.extraEnv),
|
||||||
resumeMode: options.resumeMode,
|
|
||||||
resumeSessionId: options.resumeSessionId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
|
|
@ -946,75 +828,6 @@ function enforceMaxSessions(projectId: string): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session continuity API ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Claude session list cache per project (keyed by projectId)
|
|
||||||
const claudeSessionCache = new Map<string, { sessions: ClaudeSessionInfo[]; fetchedAt: number }>();
|
|
||||||
const CACHE_TTL_MS = 30_000; // 30 seconds
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List Claude SDK sessions from disk for a project CWD.
|
|
||||||
* Cached for 30 seconds; invalidated on agent completion.
|
|
||||||
*/
|
|
||||||
export async function listProjectSessions(projectId: string, cwd: string): Promise<ClaudeSessionInfo[]> {
|
|
||||||
const cached = claudeSessionCache.get(projectId);
|
|
||||||
if (cached && (Date.now() - cached.fetchedAt) < CACHE_TTL_MS) {
|
|
||||||
return cached.sessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await appRpc.request['session.listClaude']({ cwd });
|
|
||||||
const sessions = (result?.sessions ?? []) as ClaudeSessionInfo[];
|
|
||||||
claudeSessionCache.set(projectId, { sessions, fetchedAt: Date.now() });
|
|
||||||
return sessions;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[listProjectSessions] error:', err);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Invalidate the Claude session cache for a project. */
|
|
||||||
export function invalidateSessionCache(projectId: string): void {
|
|
||||||
claudeSessionCache.delete(projectId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Continue the most recent Claude session for a project.
|
|
||||||
* Uses SDK `continue: true` — picks up where the last session left off.
|
|
||||||
*/
|
|
||||||
export async function continueLastSession(
|
|
||||||
projectId: string,
|
|
||||||
provider: string,
|
|
||||||
prompt: string,
|
|
||||||
cwd: string,
|
|
||||||
options: Omit<StartOptions, 'resumeMode' | 'resumeSessionId' | 'cwd'> = {},
|
|
||||||
): Promise<{ ok: boolean; error?: string }> {
|
|
||||||
return startAgent(projectId, provider, prompt, {
|
|
||||||
...options,
|
|
||||||
cwd,
|
|
||||||
resumeMode: 'continue',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resume a specific Claude session by its SDK session ID.
|
|
||||||
*/
|
|
||||||
export async function resumeSession(
|
|
||||||
projectId: string,
|
|
||||||
provider: string,
|
|
||||||
sdkSessionId: string,
|
|
||||||
prompt: string,
|
|
||||||
cwd: string,
|
|
||||||
options: Omit<StartOptions, 'resumeMode' | 'resumeSessionId' | 'cwd'> = {},
|
|
||||||
): Promise<{ ok: boolean; error?: string }> {
|
|
||||||
return startAgent(projectId, provider, prompt, {
|
|
||||||
...options,
|
|
||||||
cwd,
|
|
||||||
resumeMode: 'resume',
|
|
||||||
resumeSessionId: sdkSessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: Do NOT call ensureListeners() at module load — appRpc may not be
|
// NOTE: Do NOT call ensureListeners() at module load — appRpc may not be
|
||||||
// initialized yet (setAppRpc runs in main.ts after module imports resolve).
|
// initialized yet (setAppRpc runs in main.ts after module imports resolve).
|
||||||
// Listeners are registered lazily on first startAgent/getSession/sendPrompt call.
|
// Listeners are registered lazily on first startAgent/getSession/sendPrompt call.
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ import {
|
||||||
getProjectState,
|
getProjectState,
|
||||||
getActiveTab, isTabActivated, setActiveTab,
|
getActiveTab, isTabActivated, setActiveTab,
|
||||||
addTerminalTab, closeTerminalTab, activateTerminalTab, toggleTerminalExpanded,
|
addTerminalTab, closeTerminalTab, activateTerminalTab, toggleTerminalExpanded,
|
||||||
toggleAgentPreview, appendBashOutput,
|
|
||||||
setFileState, setFileMulti, nextFileRequestToken, getFileRequestToken,
|
setFileState, setFileMulti, nextFileRequestToken, getFileRequestToken,
|
||||||
setCommsState, setCommsMulti,
|
setCommsState, setCommsMulti,
|
||||||
setTaskState, setTaskMulti, nextTaskPollToken, getTaskPollToken,
|
setTaskState, setTaskMulti, nextTaskPollToken, getTaskPollToken,
|
||||||
|
|
@ -137,8 +136,6 @@ export const appState = {
|
||||||
closeTab: closeTerminalTab,
|
closeTab: closeTerminalTab,
|
||||||
activateTab: activateTerminalTab,
|
activateTab: activateTerminalTab,
|
||||||
toggleExpanded: toggleTerminalExpanded,
|
toggleExpanded: toggleTerminalExpanded,
|
||||||
toggleAgentPreview: toggleAgentPreview,
|
|
||||||
appendBashOutput: appendBashOutput,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
files: {
|
files: {
|
||||||
|
|
|
||||||
|
|
@ -26,19 +26,15 @@ let _version = $state(0);
|
||||||
|
|
||||||
// ── Factory ───────────────────────────────────────────────────────────────
|
// ── Factory ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const MAX_BASH_OUTPUT_LINES = 500;
|
|
||||||
|
|
||||||
function createProjectState(projectId: string): ProjectState {
|
function createProjectState(projectId: string): ProjectState {
|
||||||
const firstTabId = `${projectId}-t1`;
|
const firstTabId = `${projectId}-t1`;
|
||||||
return {
|
return {
|
||||||
terminals: {
|
terminals: {
|
||||||
tabs: [{ kind: 'pty', id: firstTabId, title: 'shell 1' }],
|
tabs: [{ id: firstTabId, title: 'shell 1' }],
|
||||||
activeTabId: firstTabId,
|
activeTabId: firstTabId,
|
||||||
expanded: true,
|
expanded: true,
|
||||||
nextId: 2,
|
nextId: 2,
|
||||||
mounted: new Set([firstTabId]),
|
mounted: new Set([firstTabId]),
|
||||||
bashOutputLines: [],
|
|
||||||
bashLinesVersion: 0,
|
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
childrenCache: new Map(),
|
childrenCache: new Map(),
|
||||||
|
|
@ -131,7 +127,7 @@ export function setActiveTab(projectId: string, tab: ProjectTab): void {
|
||||||
export function addTerminalTab(projectId: string): void {
|
export function addTerminalTab(projectId: string): void {
|
||||||
const t = ensureProject(projectId).terminals;
|
const t = ensureProject(projectId).terminals;
|
||||||
const id = `${projectId}-t${t.nextId}`;
|
const id = `${projectId}-t${t.nextId}`;
|
||||||
t.tabs = [...t.tabs, { kind: 'pty', id, title: `shell ${t.nextId}` }];
|
t.tabs = [...t.tabs, { id, title: `shell ${t.nextId}` }];
|
||||||
t.nextId++;
|
t.nextId++;
|
||||||
t.activeTabId = id;
|
t.activeTabId = id;
|
||||||
t.mounted.add(id);
|
t.mounted.add(id);
|
||||||
|
|
@ -164,38 +160,6 @@ export function toggleTerminalExpanded(projectId: string): void {
|
||||||
bump();
|
bump();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleAgentPreview(projectId: string): void {
|
|
||||||
const t = ensureProject(projectId).terminals;
|
|
||||||
const existing = t.tabs.find(tab => tab.kind === 'agentPreview');
|
|
||||||
if (existing) {
|
|
||||||
// Remove the preview tab
|
|
||||||
t.tabs = t.tabs.filter(tab => tab.kind !== 'agentPreview');
|
|
||||||
t.mounted.delete(existing.id);
|
|
||||||
if (t.activeTabId === existing.id) {
|
|
||||||
const next = t.tabs[0];
|
|
||||||
t.activeTabId = next?.id ?? '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Add a preview tab
|
|
||||||
const id = `${projectId}-preview`;
|
|
||||||
t.tabs = [...t.tabs, { kind: 'agentPreview', id, title: 'Agent Preview' }];
|
|
||||||
t.activeTabId = id;
|
|
||||||
t.mounted.add(id);
|
|
||||||
if (!t.expanded) t.expanded = true;
|
|
||||||
}
|
|
||||||
bump();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendBashOutput(projectId: string, line: string): void {
|
|
||||||
const t = ensureProject(projectId).terminals;
|
|
||||||
t.bashOutputLines.push(line);
|
|
||||||
if (t.bashOutputLines.length > MAX_BASH_OUTPUT_LINES) {
|
|
||||||
t.bashOutputLines.splice(0, t.bashOutputLines.length - MAX_BASH_OUTPUT_LINES);
|
|
||||||
}
|
|
||||||
t.bashLinesVersion++;
|
|
||||||
bump();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── File actions ──────────────────────────────────────────────────────────
|
// ── File actions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function setFileState<K extends keyof FileState>(
|
export function setFileState<K extends keyof FileState>(
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ export type { ProjectTab };
|
||||||
|
|
||||||
// ── Terminal ──────────────────────────────────────────────────────────────
|
// ── Terminal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type TermTab =
|
export interface TermTab {
|
||||||
| { kind: 'pty'; id: string; title: string }
|
id: string;
|
||||||
| { kind: 'agentPreview'; id: string; title: string };
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TerminalState {
|
export interface TerminalState {
|
||||||
tabs: TermTab[];
|
tabs: TermTab[];
|
||||||
|
|
@ -20,10 +21,6 @@ export interface TerminalState {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
nextId: number;
|
nextId: number;
|
||||||
mounted: Set<string>;
|
mounted: Set<string>;
|
||||||
/** Ring buffer of bash tool_call output lines for agent preview. */
|
|
||||||
bashOutputLines: string[];
|
|
||||||
/** Bumped on every append — drives polling in readonly terminal. */
|
|
||||||
bashLinesVersion: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Files ─────────────────────────────────────────────────────────────────
|
// ── Files ─────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -76,11 +76,6 @@ export function getActiveGroup(): Group {
|
||||||
export function getFilteredProjects(): Project[] {
|
export function getFilteredProjects(): Project[] {
|
||||||
return projects.filter(p => (p.groupId ?? 'dev') === activeGroupId);
|
return projects.filter(p => (p.groupId ?? 'dev') === activeGroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get projects for a specific group (used by GroupStatusDots). */
|
|
||||||
export function getProjectsForGroup(groupId: string): Project[] {
|
|
||||||
return projects.filter(p => (p.groupId ?? 'dev') === groupId);
|
|
||||||
}
|
|
||||||
export function getTotalCostDerived(): number {
|
export function getTotalCostDerived(): number {
|
||||||
return projects.reduce((s, p) => s + p.costUsd, 0);
|
return projects.reduce((s, p) => s + p.costUsd, 0);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -389,10 +389,6 @@ export type PtyRPCRequests = {
|
||||||
extraEnv?: Record<string, string>;
|
extraEnv?: Record<string, string>;
|
||||||
additionalDirectories?: string[];
|
additionalDirectories?: string[];
|
||||||
worktreeName?: string;
|
worktreeName?: string;
|
||||||
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific session). */
|
|
||||||
resumeMode?: "new" | "continue" | "resume";
|
|
||||||
/** Required when resumeMode='resume' — the Claude SDK session ID to resume. */
|
|
||||||
resumeSessionId?: string;
|
|
||||||
};
|
};
|
||||||
response: { ok: boolean; error?: string };
|
response: { ok: boolean; error?: string };
|
||||||
};
|
};
|
||||||
|
|
@ -481,36 +477,6 @@ export type PtyRPCRequests = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** List Claude SDK sessions from disk for a project CWD. */
|
|
||||||
"session.listClaude": {
|
|
||||||
params: { cwd: string };
|
|
||||||
response: {
|
|
||||||
sessions: Array<{
|
|
||||||
sessionId: string;
|
|
||||||
summary: string;
|
|
||||||
lastModified: number;
|
|
||||||
fileSize: number;
|
|
||||||
firstPrompt: string;
|
|
||||||
model: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Load full conversation messages from a Claude JSONL session file. */
|
|
||||||
"session.loadMessages": {
|
|
||||||
params: { cwd: string; sdkSessionId: string };
|
|
||||||
response: {
|
|
||||||
messages: Array<{
|
|
||||||
id: string;
|
|
||||||
role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'system';
|
|
||||||
content: string;
|
|
||||||
timestamp: number;
|
|
||||||
model?: string;
|
|
||||||
toolName?: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── btmsg RPC ──────────────────────────────────────────────────────────
|
// ── btmsg RPC ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Register an agent in btmsg. */
|
/** Register an agent in btmsg. */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue