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}
+
+
+
+ Compact Session
+
+
+
Compact ?
+
Compresses conversation history into a summary. The session stays active but this can't be reversed.
+
This can take up to 2 minutes.
+
+
+
+
+
+
+
+
+
+
+
+
+ Reset Session
+
+
+
Reset ?
+
Permanently clears the session. The next interaction starts fresh. This can't be undone.
+
+
+
+
+
+
+