From 404ba91b0c1b19411382558a17cd945336520be3 Mon Sep 17 00:00:00 2001 From: rudi193-cmd Date: Thu, 4 Jun 2026 06:14:44 -0600 Subject: [PATCH 1/5] fix(sessions): treat session.json without sessionId as absent (#228) --- src/runner.ts | 2 +- src/sessionManager.ts | 3 ++- src/sessionValidate.ts | 6 +++++ src/sessions.ts | 22 +++++++++++++---- tests/sessions-missing-id.test.ts | 39 +++++++++++++++++++++++++++++++ 5 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 src/sessionValidate.ts create mode 100644 tests/sessions-missing-id.test.ts diff --git a/src/runner.ts b/src/runner.ts index 7d0ee6a1..171642c9 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -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, "-"); diff --git a/src/sessionManager.ts b/src/sessionManager.ts index 177634c4..36428334 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -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"); @@ -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; diff --git a/src/sessionValidate.ts b/src/sessionValidate.ts new file mode 100644 index 00000000..7d4f860e --- /dev/null +++ b/src/sessionValidate.ts @@ -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; +} diff --git a/src/sessions.ts b/src/sessions.ts index 9df78436..c54fdf3d 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -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"); @@ -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; @@ -26,16 +29,26 @@ function sessionPathFor(agentName?: string): string { async function loadSession(agentName?: string): Promise { 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; } } @@ -126,7 +139,8 @@ function fallbackSessionPathFor(agentName?: string, threadId?: string): string { async function loadFallbackSession(agentName?: string, threadId?: string): Promise { 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; } diff --git a/tests/sessions-missing-id.test.ts b/tests/sessions-missing-id.test.ts new file mode 100644 index 00000000..164a0a9c --- /dev/null +++ b/tests/sessions-missing-id.test.ts @@ -0,0 +1,39 @@ +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;/); + }); +}); From 5a6e6336c3ddb9f61fd722af5b816fdd59aaa48f Mon Sep 17 00:00:00 2001 From: rudi193-cmd Date: Fri, 12 Jun 2026 01:54:14 -0600 Subject: [PATCH 2/5] fix(sessions): validate thread session peek results Co-authored-by: Cursor --- src/sessionManager.ts | 3 ++- tests/sessions-missing-id.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/sessionManager.ts b/src/sessionManager.ts index 36428334..b468a5f1 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -107,5 +107,6 @@ export async function listThreadSessions(): Promise { /** Peek at a thread session without updating lastUsedAt. */ export async function peekThreadSession(threadId: string): Promise { const data = await loadSessions(); - return data.threads[threadId] ?? null; + const session = data.threads[threadId]; + return hasValidSessionId(session) ? session : null; } diff --git a/tests/sessions-missing-id.test.ts b/tests/sessions-missing-id.test.ts index 164a0a9c..befc09b3 100644 --- a/tests/sessions-missing-id.test.ts +++ b/tests/sessions-missing-id.test.ts @@ -37,3 +37,10 @@ describe("runner treats missing sessionId as new session", () => { 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;/); + }); +}); From a9eb7cb23f1c455d066124ebaf8516d199f77758 Mon Sep 17 00:00:00 2001 From: rudi193-cmd Date: Sun, 28 Jun 2026 12:02:00 -0600 Subject: [PATCH 3/5] fix(sessions): filter corrupted rows in listThreadSessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listThreadSessions was the last thread read path returning rows without going through hasValidSessionId. A sessions.json row missing sessionId would reach the Discord /status thread-sessions loop and crash on ts.sessionId.slice(0, 8) — the same TypeError class #234 eliminates. Apply the existing guard. (review follow-up, #234) --- src/sessionManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/sessionManager.ts b/src/sessionManager.ts index b468a5f1..89a6a90d 100644 --- a/src/sessionManager.ts +++ b/src/sessionManager.ts @@ -98,10 +98,10 @@ export async function markThreadCompactWarned(threadId: string): Promise { await saveSessions(data); } -/** List all active thread sessions. */ +/** List all active thread sessions. Drops corrupted rows missing a sessionId. */ export async function listThreadSessions(): Promise { const data = await loadSessions(); - return Object.values(data.threads); + return Object.values(data.threads).filter(hasValidSessionId); } /** Peek at a thread session without updating lastUsedAt. */ From 54854a92355ae586769dbe5c3fb87ef10ac170a7 Mon Sep 17 00:00:00 2001 From: rudi193-cmd Date: Sun, 28 Jun 2026 12:03:29 -0600 Subject: [PATCH 4/5] chore: bump plugin + marketplace version to 1.0.40 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Required by plugin-version-guard / marketplace-version-guard — master advanced to 1.0.39 after #233 merged, matching this branch. (#234) --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2965c7a7..f5430793 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.39", + "version": "1.0.40", "keywords": [ "cron", "heartbeat", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9e77a27c..2688e3ab 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,5 @@ { "name": "claudeclaw", - "version": "1.0.39", + "version": "1.0.40", "description": "Cron-like daemon that runs Claude prompts on a schedule" } From f10c8e6d02d582b4747bef7f6042841811c89f30 Mon Sep 17 00:00:00 2001 From: TerrysPOV Date: Sun, 19 Jul 2026 09:53:01 +0100 Subject: [PATCH 5/5] chore(release): bump plugin and marketplace to 1.0.41 --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f5430793..a38a6a41 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.40", + "version": "1.0.41", "keywords": [ "cron", "heartbeat", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2688e3ab..e2be5931 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" }