Skip to content
Merged
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.40",
"version": "1.0.41",
"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.40",
"version": "1.0.41",
"description": "Cron-like daemon that runs Claude prompts on a schedule"
}
2 changes: 1 addition & 1 deletion src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1050,7 +1050,7 @@ async function execClaude(
const existing = threadId
? await getThreadSession(threadId)
: await getSession(agentName);
const isNew = !existing;
const isNew = !existing?.sessionId;
// Start the watchdog clock for resumed sessions (we know the ID immediately).
if (existing) startSession(existing.sessionId);
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
Expand Down
10 changes: 6 additions & 4 deletions src/sessionManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { join } from "path";
import { hasValidSessionId } from "./sessionValidate";

const HEARTBEAT_DIR = join(process.cwd(), ".claude", "claudeclaw");
const SESSIONS_FILE = join(HEARTBEAT_DIR, "sessions.json");
Expand Down Expand Up @@ -40,7 +41,7 @@ export async function getThreadSession(
): Promise<{ sessionId: string; turnCount: number; compactWarned: boolean } | null> {
const data = await loadSessions();
const session = data.threads[threadId];
if (!session) return null;
if (!hasValidSessionId(session)) return null;

if (typeof session.turnCount !== "number") session.turnCount = 0;
if (typeof session.compactWarned !== "boolean") session.compactWarned = false;
Expand Down Expand Up @@ -97,14 +98,15 @@ export async function markThreadCompactWarned(threadId: string): Promise<void> {
await saveSessions(data);
}

/** List all active thread sessions. */
/** List all active thread sessions. Drops corrupted rows missing a sessionId. */
export async function listThreadSessions(): Promise<ThreadSession[]> {
const data = await loadSessions();
return Object.values(data.threads);
return Object.values(data.threads).filter(hasValidSessionId);
}

/** Peek at a thread session without updating lastUsedAt. */
export async function peekThreadSession(threadId: string): Promise<ThreadSession | null> {
const data = await loadSessions();
return data.threads[threadId] ?? null;
const session = data.threads[threadId];
return hasValidSessionId(session) ? session : null;
}
6 changes: 6 additions & 0 deletions src/sessionValidate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** Parsed session records must carry a non-empty sessionId or be treated as absent. */
export function hasValidSessionId(value: unknown): value is { sessionId: string } {
if (!value || typeof value !== "object") return false;
const sessionId = (value as { sessionId?: unknown }).sessionId;
return typeof sessionId === "string" && sessionId.length > 0;
}
22 changes: 18 additions & 4 deletions src/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { dirname, join } from "path";
import { unlink, readdir, rename, mkdir } from "fs/promises";
import { getAgentsDir } from "./config";
import { hasValidSessionId } from "./sessionValidate";

const HEARTBEAT_DIR = join(process.cwd(), ".claude", "claudeclaw");
const SESSION_FILE = join(HEARTBEAT_DIR, "session.json");
Expand All @@ -14,6 +15,8 @@ export interface GlobalSession {
messageCount?: number;
}

export { hasValidSessionId } from "./sessionValidate";

// Module-level cache is for the GLOBAL session only.
// Agent sessions bypass this cache — they read/write directly.
let current: GlobalSession | null = null;
Expand All @@ -26,16 +29,26 @@ function sessionPathFor(agentName?: string): string {
async function loadSession(agentName?: string): Promise<GlobalSession | null> {
if (agentName) {
try {
return await Bun.file(sessionPathFor(agentName)).json();
const session = await Bun.file(sessionPathFor(agentName)).json();
return hasValidSessionId(session) ? session : null;
} catch {
return null;
}
}
if (current) return current;
if (current) {
if (hasValidSessionId(current)) return current;
current = null;
}
try {
current = await Bun.file(SESSION_FILE).json();
const session = await Bun.file(SESSION_FILE).json();
if (!hasValidSessionId(session)) {
current = null;
return null;
}
current = session;
return current;
} catch {
current = null;
return null;
}
}
Expand Down Expand Up @@ -126,7 +139,8 @@ function fallbackSessionPathFor(agentName?: string, threadId?: string): string {

async function loadFallbackSession(agentName?: string, threadId?: string): Promise<GlobalSession | null> {
try {
return await Bun.file(fallbackSessionPathFor(agentName, threadId)).json();
const session = await Bun.file(fallbackSessionPathFor(agentName, threadId)).json();
return hasValidSessionId(session) ? session : null;
} catch {
return null;
}
Expand Down
46 changes: 46 additions & 0 deletions tests/sessions-missing-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { readFileSync } from "node:fs";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { hasValidSessionId } from "../src/sessionValidate.ts";

describe("hasValidSessionId", () => {
it("accepts sessions with a non-empty sessionId", () => {
assert.equal(
hasValidSessionId({
sessionId: "11111111-1111-4111-8111-111111111111",
createdAt: "2026-01-01T00:00:00.000Z",
lastUsedAt: "2026-01-01T00:00:00.000Z",
turnCount: 0,
compactWarned: false,
}),
true,
);
});

it("rejects parseable JSON missing sessionId (issue #228)", () => {
assert.equal(
hasValidSessionId({
turnCount: 0,
compactWarned: false,
lastUsedAt: "2026-01-01T00:00:00.000Z",
}),
false,
);
assert.equal(hasValidSessionId({ sessionId: "" }), false);
assert.equal(hasValidSessionId(null), false);
});
});

describe("runner treats missing sessionId as new session", () => {
it("uses optional sessionId when computing isNew", () => {
const src = readFileSync(new URL("../src/runner.ts", import.meta.url), "utf8");
assert.match(src, /const isNew = !existing\?\.sessionId;/);
});
});

describe("thread session peeking", () => {
it("validates sessions.json rows before returning them", () => {
const src = readFileSync(new URL("../src/sessionManager.ts", import.meta.url), "utf8");
assert.match(src, /return hasValidSessionId\(session\) \? session : null;/);
});
});
Loading