Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
101 changes: 101 additions & 0 deletions src/__tests__/usage.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
43 changes: 24 additions & 19 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
157 changes: 157 additions & 0 deletions src/ui/page/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1610,6 +1610,10 @@ export const pageScript = String.raw` // --- Token management (Task 1.1) ---
"</td>" +
"<td class='usage-td usage-td-num usage-td-turns'>" + s.turnCount + "</td>" +
"<td class='usage-td usage-td-age'>" + fmtRelative(s.lastUsedAt) + "</td>" +
"<td class='usage-td usage-td-reset'>" +
(s.channel === "discord" ? "<button class='usage-compact-btn' type='button' data-compact-session='" + escAttr(s.sessionId) + "' data-compact-label='" + escAttr(s.label) + "'>Compact</button> " : "") +
"<button class='usage-reset-btn' type='button' data-reset-session='" + escAttr(s.sessionId) + "' data-reset-label='" + escAttr(s.label) + "'>Reset</button>" +
"</td>" +
"</tr>";
}).join("");
usageWrap.innerHTML =
Expand All @@ -1623,11 +1627,164 @@ export const pageScript = String.raw` // --- Token management (Task 1.1) ---
"<th class='usage-th'>Est. Cost</th>" +
"<th class='usage-th usage-th-num'>Turns</th>" +
"<th class='usage-th'>Last Active</th>" +
"<th class='usage-th'></th>" +
"</tr></thead>" +
"<tbody>" + rows + "</tbody>" +
"</table>";
}

// 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(); })
Expand Down
Loading
Loading