diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 888422e7..85219676 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "claudeclaw", "source": "./", "description": "Cron-like daemon that runs Claude prompts on a schedule", - "version": "1.0.44", + "version": "1.0.45", "keywords": [ "cron", "heartbeat", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 644babac..cc993e55 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "claudeclaw", - "version": "1.0.44", + "version": "1.0.45", "description": "Cron-like daemon that runs Claude prompts on a schedule" } diff --git a/src/__tests__/usage.test.ts b/src/__tests__/usage.test.ts new file mode 100644 index 00000000..7cdcd1f4 --- /dev/null +++ b/src/__tests__/usage.test.ts @@ -0,0 +1,101 @@ +import { describe, test, expect, beforeEach, afterAll } from "bun:test"; +import { mkdir, writeFile, readFile, readdir, rm } from "fs/promises"; +import { join } from "path"; + +const TEST_ROOT = join(import.meta.dir, "../../test-sandbox-usage"); +const CLAUDECLAW_DIR = join(TEST_ROOT, ".claude", "claudeclaw"); + +const GLOBAL_SESSION_ID = "11111111-1111-1111-1111-111111111111"; +const THREAD_SESSION_ID = "22222222-2222-2222-2222-222222222222"; +const MISSING_SESSION_ID = "99999999-9999-9999-9999-999999999999"; + +async function resetSandbox() { + await rm(TEST_ROOT, { recursive: true, force: true }); + await mkdir(CLAUDECLAW_DIR, { recursive: true }); +} + +afterAll(async () => { + await rm(TEST_ROOT, { recursive: true, force: true }); +}); + +/** Run resetSessionById() in the sandbox dir via a child bun process (so process.cwd() == TEST_ROOT). */ +async function resetSessionInSandbox(sessionId: string): Promise<{ ok: boolean; error?: string }> { + const script = ` +import { resetSessionById } from ${JSON.stringify(join(import.meta.dir, "..", "ui", "services", "usage"))}; +try { + await resetSessionById(${JSON.stringify(sessionId)}); + process.stdout.write(JSON.stringify({ ok: true })); +} catch (err) { + process.stdout.write(JSON.stringify({ ok: false, error: String(err) })); +} +`; + const scriptPath = join(TEST_ROOT, "_run.ts"); + await writeFile(scriptPath, script); + const proc = Bun.spawn(["bun", "run", scriptPath], { + cwd: TEST_ROOT, + stdout: "pipe", + stderr: "pipe", + }); + const out = await new Response(proc.stdout).text(); + await proc.exited; + return JSON.parse(out || "{}"); +} + +describe("resetSessionById", () => { + beforeEach(resetSandbox); + + test("global web session: backs up session.json and clears it", async () => { + await writeFile( + join(CLAUDECLAW_DIR, "session.json"), + JSON.stringify({ + sessionId: GLOBAL_SESSION_ID, + createdAt: new Date().toISOString(), + lastUsedAt: new Date().toISOString(), + turnCount: 3, + }) + ); + + const result = await resetSessionInSandbox(GLOBAL_SESSION_ID); + expect(result.ok).toBe(true); + + const files = await readdir(CLAUDECLAW_DIR); + expect(files).not.toContain("session.json"); + expect(files.some((f) => f.startsWith("session_backup_"))).toBe(true); + }); + + test("thread session: removes the thread from sessions.json", async () => { + await writeFile( + join(CLAUDECLAW_DIR, "sessions.json"), + JSON.stringify({ + threads: { + "thread-1": { + sessionId: THREAD_SESSION_ID, + threadId: "thread-1", + createdAt: new Date().toISOString(), + lastUsedAt: new Date().toISOString(), + turnCount: 1, + compactWarned: false, + }, + }, + }) + ); + + const result = await resetSessionInSandbox(THREAD_SESSION_ID); + expect(result.ok).toBe(true); + + const sessions = JSON.parse(await readFile(join(CLAUDECLAW_DIR, "sessions.json"), "utf-8")); + expect(sessions.threads["thread-1"]).toBeUndefined(); + }); + + test("unknown sessionId: rejects with 'session not found'", async () => { + const result = await resetSessionInSandbox(MISSING_SESSION_ID); + expect(result.ok).toBe(false); + expect(result.error).toContain("session not found"); + }); + + test("invalid (non-UUID) sessionId: rejects without touching disk", async () => { + const result = await resetSessionInSandbox("not-a-uuid"); + expect(result.ok).toBe(false); + expect(result.error).toContain("invalid sessionId"); + }); +}); diff --git a/src/runner.ts b/src/runner.ts index 171642c9..1d8e51ad 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -995,28 +995,33 @@ export async function compactCurrentThreadSession( threadId: string, agentName?: string ): Promise<{ success: boolean; message: string }> { - const existing = await getThreadSession(threadId); - if (!existing) return { success: false, message: "No active session to compact." }; + // Share the same per-thread queue as normal message runs (see execClaude below) so a + // compact triggered from the web dashboard can't race a Discord/Telegram turn that's + // actively resuming this thread's session. + return enqueue(async () => { + const existing = await getThreadSession(threadId); + if (!existing) return { success: false, message: "No active session to compact." }; - const settings = getSettings(); - const securityArgs = buildSecurityArgs(settings.security); - const baseEnv = cleanSpawnEnv(); - const timeoutMs = settings.sessionTimeoutMs; + const settings = getSettings(); + const securityArgs = buildSecurityArgs(settings.security); + const baseEnv = cleanSpawnEnv(); + const timeoutMs = settings.sessionTimeoutMs; - const compactCwd = agentName ? await ensureAgentDir(agentName) : undefined; - const ok = await runCompact( - existing.sessionId, - settings.model, - settings.api, - baseEnv, - securityArgs, - timeoutMs, - compactCwd - ); + const compactCwd = agentName ? await ensureAgentDir(agentName) : undefined; + const ok = await runCompact( + existing.sessionId, + settings.model, + settings.api, + baseEnv, + securityArgs, + timeoutMs, + compactCwd + ); - return ok - ? { success: true, message: `✅ Thread session compact complete (${existing.sessionId.slice(0, 8)})` } - : { success: false, message: `❌ Compact failed (${existing.sessionId.slice(0, 8)})` }; + return ok + ? { success: true, message: `✅ Thread session compact complete (${existing.sessionId.slice(0, 8)})` } + : { success: false, message: `❌ Compact failed (${existing.sessionId.slice(0, 8)})` }; + }, threadId); } async function execClaude( diff --git a/src/ui/page/script.ts b/src/ui/page/script.ts index ae4c5981..b8c554e4 100644 --- a/src/ui/page/script.ts +++ b/src/ui/page/script.ts @@ -1610,6 +1610,10 @@ export const pageScript = String.raw` // --- Token management (Task 1.1) --- "" + "" + s.turnCount + "" + "" + fmtRelative(s.lastUsedAt) + "" + + "" + + (s.channel === "discord" ? " " : "") + + "" + + "" + ""; }).join(""); usageWrap.innerHTML = @@ -1623,11 +1627,164 @@ export const pageScript = String.raw` // --- Token management (Task 1.1) --- "Est. Cost" + "Turns" + "Last Active" + + "" + "" + "" + rows + "" + ""; } + // Per-session in-flight lock — prevents concurrent compact/reset on the same session. + var inFlightSessions = new Set(); + + if (usageWrap) { + // ── Compact flow ────────────────────────────────────────────────────────── + usageWrap.addEventListener("click", function(event) { + var btn = event.target && event.target.closest && event.target.closest("[data-compact-session]"); + if (!btn || !(btn instanceof HTMLButtonElement)) return; + var sessionId = btn.getAttribute("data-compact-session") || ""; + var label = btn.getAttribute("data-compact-label") || sessionId; + if (!sessionId || inFlightSessions.has(sessionId)) return; + var compactModal = document.getElementById("confirm-compact-modal"); + var compactLabelEl = document.getElementById("confirm-compact-label"); + var compactOk = document.getElementById("confirm-compact-ok"); + var compactCancel = document.getElementById("confirm-compact-cancel"); + var compactProgress = document.getElementById("confirm-compact-progress"); + var compactStatus = document.getElementById("confirm-compact-status"); + if (!compactModal || !compactLabelEl || !compactOk || !compactCancel) return; + if (compactModal.classList.contains("open")) return; // one modal instance at a time — avoids stacking listeners + compactLabelEl.textContent = label; + compactModal.classList.add("open"); + compactModal.setAttribute("aria-hidden", "false"); + var statusTimer = null; + var statusFadeTimer = null; + var statusMessages = [ + "Compacting conversation history…", + "Summarizing and condensing the session…", + "Still working, hang tight…", + "Almost there…", + ]; + var startStatusCycle = function() { + if (!compactStatus) return; + var idx = 0; + var showNext = function() { + compactStatus.classList.remove("visible"); + statusFadeTimer = setTimeout(function() { + compactStatus.textContent = statusMessages[idx % statusMessages.length]; + compactStatus.classList.add("visible"); + idx++; + statusTimer = setTimeout(showNext, 4000); + }, 350); + }; + showNext(); + }; + var stopStatusCycle = function() { + if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; } + if (statusFadeTimer) { clearTimeout(statusFadeTimer); statusFadeTimer = null; } + if (compactStatus) { compactStatus.classList.remove("visible"); compactStatus.textContent = ""; } + }; + var cleanupCompact = function() { + stopStatusCycle(); + inFlightSessions.delete(sessionId); + compactModal.classList.remove("open"); + compactModal.setAttribute("aria-hidden", "true"); + if (compactProgress) compactProgress.classList.remove("active"); + compactOk.removeEventListener("click", onCompactOk); + compactCancel.removeEventListener("click", onCompactCancel); + compactOk.disabled = false; + compactOk.textContent = "Compact"; + compactCancel.style.display = ""; + btn.disabled = false; + btn.textContent = "Compact"; + }; + var onCompactCancel = function() { + cleanupCompact(); + }; + var onCompactOk = function() { + inFlightSessions.add(sessionId); + compactOk.disabled = true; + compactOk.textContent = "Working…"; + compactCancel.style.display = "none"; + if (compactProgress) compactProgress.classList.add("active"); + startStatusCycle(); + btn.disabled = true; + btn.textContent = "Compacting…"; + fetch("/api/usage/" + encodeURIComponent(sessionId) + "/compact", { method: "POST" }) + .then(function(r) { return r.json(); }) + .then(function(data) { + cleanupCompact(); + if (data.ok) { + fetchUsage(); + } else { + alert("Compact failed: " + (data.error || data.message || "unknown error")); + } + }) + .catch(function(err) { + cleanupCompact(); + alert("Compact failed: " + String(err)); + }); + }; + compactOk.addEventListener("click", onCompactOk); + compactCancel.addEventListener("click", onCompactCancel); + }); + + // ── Reset flow ──────────────────────────────────────────────────────────── + usageWrap.addEventListener("click", function(event) { + var btn = event.target && event.target.closest && event.target.closest("[data-reset-session]"); + if (!btn || !(btn instanceof HTMLButtonElement)) return; + var sessionId = btn.getAttribute("data-reset-session") || ""; + var label = btn.getAttribute("data-reset-label") || sessionId; + if (!sessionId || inFlightSessions.has(sessionId)) return; + var confirmModal = document.getElementById("confirm-reset-modal"); + var confirmLabelEl = document.getElementById("confirm-reset-label"); + var confirmOk = document.getElementById("confirm-reset-ok"); + var confirmCancel = document.getElementById("confirm-reset-cancel"); + if (!confirmModal || !confirmLabelEl || !confirmOk || !confirmCancel) return; + if (confirmModal.classList.contains("open")) return; // one modal instance at a time — avoids stacking listeners + confirmLabelEl.textContent = label; + confirmModal.classList.add("open"); + confirmModal.setAttribute("aria-hidden", "false"); + var cleanupReset = function() { + inFlightSessions.delete(sessionId); + confirmModal.classList.remove("open"); + confirmModal.setAttribute("aria-hidden", "true"); + confirmOk.removeEventListener("click", onResetOk); + confirmCancel.removeEventListener("click", onResetCancel); + confirmOk.disabled = false; + confirmOk.textContent = "Reset"; + confirmCancel.style.display = ""; + btn.disabled = false; + btn.textContent = "Reset"; + }; + var onResetCancel = function() { + cleanupReset(); + }; + var onResetOk = function() { + inFlightSessions.add(sessionId); + confirmOk.disabled = true; + confirmOk.textContent = "Resetting…"; + confirmCancel.style.display = "none"; + btn.disabled = true; + btn.textContent = "Resetting…"; + fetch("/api/usage/" + encodeURIComponent(sessionId) + "/reset", { method: "DELETE" }) + .then(function(r) { return r.json(); }) + .then(function(data) { + cleanupReset(); + if (data.ok) { + fetchUsage(); + } else { + alert("Reset failed: " + (data.error || "unknown error")); + } + }) + .catch(function(err) { + cleanupReset(); + alert("Reset failed: " + String(err)); + }); + }; + confirmOk.addEventListener("click", onResetOk); + confirmCancel.addEventListener("click", onResetCancel); + }); + } + function fetchUsage() { fetch("/api/usage") .then(function(r) { return r.json(); }) diff --git a/src/ui/page/styles.ts b/src/ui/page/styles.ts index 2596805c..8cea6621 100644 --- a/src/ui/page/styles.ts +++ b/src/ui/page/styles.ts @@ -964,6 +964,96 @@ export const pageStyles = String.raw` :root { background: linear-gradient(180deg, #a9d4ff, #789fce); } + .confirm-card { + width: min(440px, 100%); + border: 1px solid #d8e4ff20; + border-radius: 16px; + background: #0b1220f2; + box-shadow: 0 20px 44px #00000066; + display: flex; + flex-direction: column; + overflow: hidden; + } + .confirm-body { + padding: 18px 18px 14px; + display: flex; + flex-direction: column; + gap: 8px; + } + .confirm-msg { + margin: 0; + font-size: 15px; + color: #ccd9f5; + } + .confirm-msg strong { + color: #e8f0ff; + } + .confirm-sub { + margin: 0; + font-size: 13px; + color: #7a90b2; + line-height: 1.5; + } + .confirm-note { + margin: 0; + font-size: 12px; + color: #566880; + font-style: italic; + } + .confirm-warn { color: rgba(239, 68, 68, 0.85); } + .confirm-status { + min-height: 18px; + font-size: 13px; + color: #4db87a; + font-family: "JetBrains Mono", monospace; + letter-spacing: 0.02em; + opacity: 0; + transition: opacity 0.35s ease; + } + .confirm-status.visible { + opacity: 1; + } + .confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 18px; + border-top: 1px solid #ffffff12; + } + .hb-btn.solid.danger { + border-color: #c8303080; + background: linear-gradient(180deg, #8b1a1ad4 0%, #6b1414ce 100%); + color: #f8c8c8; + } + .hb-btn.solid.danger:hover { + filter: brightness(1.08); + } + .hb-btn.solid.danger:disabled { + opacity: 0.55; + cursor: default; + filter: none; + } + .confirm-progress { + height: 3px; + background: #ffffff08; + overflow: hidden; + opacity: 0; + transition: opacity 0.2s; + } + .confirm-progress.active { + opacity: 1; + } + .confirm-progress-fill { + height: 100%; + width: 40%; + background: linear-gradient(90deg, transparent, #3cb879cc, transparent); + animation: progress-sweep 1.6s ease-in-out infinite; + } + @keyframes progress-sweep { + 0% { transform: translateX(-200%); } + 100% { transform: translateX(350%); } + } + .dock-shell { position: fixed; left: 50%; @@ -1683,6 +1773,33 @@ export const pageStyles = String.raw` :root { transition: width 0.3s ease; } .usage-cost-label { position: relative; padding-left: 6px; font-variant-numeric: tabular-nums; color: var(--fg); } +.usage-td-reset { text-align: right; padding-right: 0; width: 1%; white-space: nowrap; } +.usage-reset-btn { + font-size: 10px; + font-family: inherit; + padding: 3px 8px; + border-radius: 4px; + border: 1px solid rgba(239, 68, 68, 0.35); + background: rgba(239, 68, 68, 0.08); + color: rgba(239, 68, 68, 0.75); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.usage-reset-btn:hover { background: rgba(239, 68, 68, 0.18); color: rgb(239, 68, 68); border-color: rgba(239, 68, 68, 0.6); } +.usage-reset-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.usage-compact-btn { + font-size: 10px; + font-family: inherit; + padding: 3px 8px; + border-radius: 4px; + border: 1px solid rgba(99, 179, 237, 0.35); + background: rgba(99, 179, 237, 0.08); + color: rgba(99, 179, 237, 0.75); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.usage-compact-btn:hover { background: rgba(99, 179, 237, 0.18); color: rgb(99, 179, 237); border-color: rgba(99, 179, 237, 0.6); } +.usage-compact-btn:disabled { opacity: 0.45; cursor: not-allowed; } @media (max-width: 640px) { .tab-btn { padding: 0 12px; font-size: 10px; } diff --git a/src/ui/page/template.ts b/src/ui/page/template.ts index eec4dd26..4e09798c 100644 --- a/src/ui/page/template.ts +++ b/src/ui/page/template.ts @@ -111,6 +111,39 @@ ${pageStyles} + +