feat: extract session cache to agent-kit, add resume to claude-code agent

Move getCachedSessionId/setCachedSessionId from workflow-agent-hermes
into workflow-agent-kit so all agent adapters can share the same
session cache logic.

Add cross-process session resume to workflow-agent-claude-code:
on re-entry (isFirstVisit=false), look up the cached sessionId and
use 'claude --resume' to continue with full conversation history.

Cache file renamed from hermes-sessions.json to agent-sessions.json
to reflect its shared nature.

Refs #418
This commit is contained in:
2026-05-23 07:44:02 +00:00
parent aad2792754
commit 1afaeacd57
4 changed files with 92 additions and 66 deletions
@@ -6,6 +6,8 @@ import {
type AgentRunResult,
buildRolePrompt,
createAgent,
getCachedSessionId,
setCachedSessionId,
} from "@uncaged/workflow-agent-kit";
import { parseClaudeCodeJsonOutput, storeClaudeCodeDetail } from "./session-detail.js";
@@ -125,8 +127,26 @@ async function processClaudeOutput(stdout: string, store: Store): Promise<AgentR
async function runClaudeCode(ctx: AgentContext): Promise<AgentRunResult> {
const fullPrompt = buildClaudeCodePrompt(ctx);
// Try resuming a cached session for re-entry scenarios (e.g. reviewer reject → developer re-entry).
if (!ctx.isFirstVisit) {
const cachedSessionId = await getCachedSessionId(ctx.threadId, ctx.role);
if (cachedSessionId !== null) {
try {
const { stdout } = await spawnClaudeResume(cachedSessionId, fullPrompt);
const result = await processClaudeOutput(stdout, ctx.store);
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
return result;
} catch {
// Resume failed — fall through to fresh run.
}
}
}
const { stdout } = await spawnClaudeRun(fullPrompt);
return processClaudeOutput(stdout, ctx.store);
const result = await processClaudeOutput(stdout, ctx.store);
await setCachedSessionId(ctx.threadId, ctx.role, result.sessionId);
return result;
}
async function continueClaudeCode(
@@ -1,52 +1,5 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { resolveStorageRoot } from "@uncaged/workflow-agent-kit";
import type { ThreadId } from "@uncaged/workflow-protocol";
type HermesSessionCache = Record<string, string>;
function getCachePath(): string {
return join(resolveStorageRoot(), "cache", "hermes-sessions.json");
}
function cacheKey(threadId: ThreadId, role: string): string {
return `${threadId}:${role}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readCache(): Promise<HermesSessionCache> {
const path = getCachePath();
try {
const text = await readFile(path, "utf8");
const raw = JSON.parse(text) as unknown;
if (!isRecord(raw)) {
return {};
}
const cache: HermesSessionCache = {};
for (const [key, value] of Object.entries(raw)) {
if (typeof value === "string" && value !== "") {
cache[key] = value;
}
}
return cache;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
async function writeCache(cache: HermesSessionCache): Promise<void> {
const path = getCachePath();
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
}
// Re-export session cache from the shared agent-kit package.
export { getCachedSessionId, setCachedSessionId } from "@uncaged/workflow-agent-kit";
export function isResumeDisabled(): boolean {
// Hermes ACP session/resume is broken: _restore fails for custom providers
@@ -62,19 +15,3 @@ export function isResumeDisabled(): boolean {
}
return true;
}
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
const cache = await readCache();
const sessionId = cache[cacheKey(threadId, role)];
return sessionId ?? null;
}
export async function setCachedSessionId(
threadId: ThreadId,
role: string,
sessionId: string,
): Promise<void> {
const cache = await readCache();
cache[cacheKey(threadId, role)] = sessionId;
await writeCache(cache);
}
+1
View File
@@ -13,6 +13,7 @@ export type { FrontmatterFastPathResult } from "./frontmatter.js";
export { tryFrontmatterFastPath } from "./frontmatter.js";
export { createAgent } from "./run.js";
export { getConfigPath, getEnvPath, loadWorkflowConfig, resolveStorageRoot } from "./storage.js";
export { getCachedSessionId, setCachedSessionId } from "./session-cache.js";
export type {
AgentContext,
AgentContinueFn,
@@ -0,0 +1,68 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { ThreadId } from "@uncaged/workflow-protocol";
import { resolveStorageRoot } from "./storage.js";
type SessionCache = Record<string, string>;
function getCachePath(): string {
return join(resolveStorageRoot(), "cache", "agent-sessions.json");
}
function cacheKey(threadId: ThreadId, role: string): string {
return `${threadId}:${role}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readCache(): Promise<SessionCache> {
const path = getCachePath();
try {
const text = await readFile(path, "utf8");
const raw = JSON.parse(text) as unknown;
if (!isRecord(raw)) {
return {};
}
const cache: SessionCache = {};
for (const [key, value] of Object.entries(raw)) {
if (typeof value === "string" && value !== "") {
cache[key] = value;
}
}
return cache;
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code === "ENOENT") {
return {};
}
throw e;
}
}
async function writeCache(cache: SessionCache): Promise<void> {
const path = getCachePath();
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
}
/** Read the cached session ID for a thread+role pair. */
export async function getCachedSessionId(threadId: ThreadId, role: string): Promise<string | null> {
const cache = await readCache();
const sessionId = cache[cacheKey(threadId, role)];
return sessionId ?? null;
}
/** Write the session ID for a thread+role pair into the cache. */
export async function setCachedSessionId(
threadId: ThreadId,
role: string,
sessionId: string,
): Promise<void> {
const cache = await readCache();
cache[cacheKey(threadId, role)] = sessionId;
await writeCache(cache);
}