feat: add openclaw chat history APIs

This commit is contained in:
DaniAkash
2026-04-23 16:46:11 +05:30
parent f05b129831
commit 333c7465db
6 changed files with 908 additions and 0 deletions

View File

@@ -51,6 +51,16 @@ function getPodmanOverrideValidationError(body: {
return null
}
function parsePositiveIntQuery(
value: string | undefined,
fallback: number,
): number {
if (value === undefined) return fallback
const parsed = Number(value)
if (!Number.isFinite(parsed)) return fallback
return Math.max(1, Math.trunc(parsed))
}
export function createOpenClawRoutes() {
return new Hono()
.get('/status', async (c) => {
@@ -224,6 +234,51 @@ export function createOpenClawRoutes() {
}
})
.get('/agents/:id/sessions', async (c) => {
const { id } = c.req.param()
const limit = parsePositiveIntQuery(c.req.query('limit'), 20)
try {
const sessions = await getOpenClawService().listSessions(id)
return c.json({
agentId: id,
sessions: sessions.slice(0, Math.min(limit, 100)),
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/agents/:id/session', async (c) => {
const { id } = c.req.param()
try {
const session = await getOpenClawService().resolveAgentSession(id)
return c.json(session)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.get('/agents/:id/history', async (c) => {
const { id } = c.req.param()
const limit = parsePositiveIntQuery(c.req.query('limit'), 50)
try {
const page = await getOpenClawService().getAgentHistoryPage(id, {
sessionKey: c.req.query('sessionKey'),
cursor: c.req.query('cursor'),
limit,
})
return c.json(page)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return c.json({ error: message }, 500)
}
})
.post('/agents/:id/chat', async (c) => {
const { id } = c.req.param()
const body = await c.req.json<{

View File

@@ -31,6 +31,37 @@ export interface OpenClawAgentRecord {
model?: string
}
export interface OpenClawSessionEntry {
key: string
updatedAt: number
sessionId: string
agentId: string
kind: string
status?: string
totalTokens?: number
model?: string
modelProvider?: string
}
export interface OpenClawChatBlock {
type: 'text' | 'toolCall' | 'thinking'
text?: string
name?: string
arguments?: unknown
thinking?: string
}
export interface OpenClawChatMessage {
role: 'user' | 'assistant' | 'toolResult'
content: OpenClawChatBlock[]
timestamp?: number
usage?: { input: number; output: number }
stopReason?: string
toolName?: string
toolCallId?: string
isError?: boolean
}
export class OpenClawCliClient {
constructor(private readonly executor: ContainerExecutor) {}
@@ -191,6 +222,53 @@ export class OpenClawCliClient {
await this.listAgents()
}
async listSessions(agentId?: string): Promise<OpenClawSessionEntry[]> {
const args = ['sessions', '--json']
if (agentId) {
args.push('--agent', agentId)
} else {
args.push('--all-agents')
}
const output = await this.runCommand(args)
const parsed = parseFirstMatchingJson<
{ sessions?: unknown[]; count?: number } | unknown[]
>(output, isSessionListPayload)
if (parsed === null) {
throw new Error(
`Failed to parse OpenClaw sessions output: ${output.slice(0, 200)}`,
)
}
const entries = Array.isArray(parsed) ? parsed : (parsed.sessions ?? [])
return entries.map(toSessionEntry)
}
async getChatHistory(sessionKey: string): Promise<OpenClawChatMessage[]> {
const output = await this.runCommand([
'gateway',
'call',
'chat.history',
'--params',
JSON.stringify({ sessionKey }),
'--json',
])
const parsed = parseFirstMatchingJson<{ messages?: unknown[] }>(
output,
(value) => isPlainObject(value) && 'messages' in value,
)
if (parsed === null) {
throw new Error(
`Failed to parse OpenClaw chat history output: ${output.slice(0, 200)}`,
)
}
return (parsed.messages ?? []).map(toChatMessage)
}
private agentWorkspace(name: string): string {
return name === 'main'
? `${OPENCLAW_CONTAINER_HOME}/workspace`
@@ -405,3 +483,99 @@ function isStructuredLogPayload(value: unknown): boolean {
(typeof value.message === 'string' || typeof value.msg === 'string')
)
}
function isSessionListPayload(value: unknown): boolean {
if (Array.isArray(value)) return true
if (!isPlainObject(value)) return false
return 'sessions' in value || 'count' in value
}
function toSessionEntry(raw: unknown): OpenClawSessionEntry {
const record = isPlainObject(raw) ? raw : {}
return {
key: String(record.key ?? ''),
updatedAt: typeof record.updatedAt === 'number' ? record.updatedAt : 0,
sessionId: String(record.sessionId ?? ''),
agentId: String(record.agentId ?? ''),
kind: String(record.kind ?? ''),
status: typeof record.status === 'string' ? record.status : undefined,
totalTokens:
typeof record.totalTokens === 'number' ? record.totalTokens : undefined,
model: typeof record.model === 'string' ? record.model : undefined,
modelProvider:
typeof record.modelProvider === 'string'
? record.modelProvider
: undefined,
}
}
function toChatMessage(raw: unknown): OpenClawChatMessage {
const record = isPlainObject(raw) ? raw : {}
const role = isOpenClawMessageRole(record.role) ? record.role : 'assistant'
const message: OpenClawChatMessage = {
role,
content: toChatBlocks(record.content),
}
if (typeof record.timestamp === 'number') message.timestamp = record.timestamp
if (isPlainObject(record.usage)) {
const { input, output } = record.usage
if (typeof input === 'number' && typeof output === 'number') {
message.usage = { input, output }
}
}
if (typeof record.stopReason === 'string') {
message.stopReason = record.stopReason
}
if (typeof record.toolName === 'string') message.toolName = record.toolName
if (typeof record.toolCallId === 'string') {
message.toolCallId = record.toolCallId
}
if (typeof record.isError === 'boolean') message.isError = record.isError
return message
}
function toChatBlocks(content: unknown): OpenClawChatBlock[] {
if (typeof content === 'string') {
return [{ type: 'text', text: content }]
}
if (!Array.isArray(content)) return []
const blocks: OpenClawChatBlock[] = []
for (const rawBlock of content) {
if (!isPlainObject(rawBlock)) continue
if (rawBlock.type === 'toolCall') {
const block: OpenClawChatBlock = { type: 'toolCall' }
if (typeof rawBlock.name === 'string') block.name = rawBlock.name
if (rawBlock.arguments !== undefined) {
block.arguments = rawBlock.arguments
}
blocks.push(block)
continue
}
if (rawBlock.type === 'thinking') {
const block: OpenClawChatBlock = { type: 'thinking' }
if (typeof rawBlock.thinking === 'string') {
block.thinking = rawBlock.thinking
}
blocks.push(block)
continue
}
const block: OpenClawChatBlock = { type: 'text' }
if (typeof rawBlock.text === 'string') block.text = rawBlock.text
blocks.push(block)
}
return blocks
}
function isOpenClawMessageRole(
value: unknown,
): value is OpenClawChatMessage['role'] {
return value === 'user' || value === 'assistant' || value === 'toolResult'
}

View File

@@ -30,8 +30,10 @@ import {
} from './errors'
import {
type OpenClawAgentRecord,
type OpenClawChatMessage,
OpenClawCliClient,
type OpenClawConfigBatchEntry,
type OpenClawSessionEntry,
} from './openclaw-cli-client'
import {
getHostWorkspaceDir,
@@ -115,6 +117,188 @@ export interface OpenClawPodmanOverridesResponse {
effectivePodmanPath: string
}
export type OpenClawSessionSource =
| 'user-chat'
| 'cron'
| 'hook'
| 'channel'
| 'other'
export interface BrowserOSOpenClawSession {
key: string
updatedAt: number
sessionId: string
agentId: string
kind: string
source: OpenClawSessionSource
status?: string
totalTokens?: number
model?: string
modelProvider?: string
}
export interface BrowserOSOpenClawAgentSessionResponse {
agentId: string
exists: boolean
sessionKey: string | null
session: BrowserOSOpenClawSession | null
}
export interface BrowserOSChatHistoryItem {
id: string
role: 'user' | 'assistant'
text: string
timestamp?: number
messageSeq: number
sessionKey: string
source: OpenClawSessionSource
}
export interface BrowserOSOpenClawHistoryPageResponse {
agentId: string
sessionKey: string | null
session: BrowserOSOpenClawSession | null
items: BrowserOSChatHistoryItem[]
page: {
cursor?: string
hasMore: boolean
limit: number
}
}
interface HistoryPageInput {
sessionKey?: string
cursor?: string
limit?: number
}
function normalizeHistoryLimit(limit?: number): number {
if (limit === undefined || !Number.isFinite(limit)) return 50
return Math.max(1, Math.min(100, Math.trunc(limit)))
}
function toBrowserOSSession(
session: OpenClawSessionEntry,
): BrowserOSOpenClawSession {
return {
...session,
source: classifySessionSource(session.key),
}
}
function classifySessionSource(key: string): OpenClawSessionSource {
if (key.includes(':cron:')) return 'cron'
if (key.includes(':hook:')) return 'hook'
if (key.includes('openai-user:browseros')) return 'user-chat'
if (key.includes('qa-channel')) return 'channel'
return 'other'
}
function filterOpenClawSystemMessages(
messages: OpenClawChatMessage[],
): OpenClawChatMessage[] {
const result: OpenClawChatMessage[] = []
for (const message of messages) {
const text = getTextContent(message).trim()
if (message.role === 'assistant' && text.startsWith('HEARTBEAT')) continue
if (
message.role === 'user' &&
text.includes('Handle this reminder internally')
) {
continue
}
if (
message.role === 'user' &&
text.startsWith('[Chat messages since your last reply')
) {
const marker = '[Current message - respond to this]'
const index = text.indexOf(marker)
if (index >= 0) {
const actual = text
.slice(index + marker.length)
.trim()
.replace(/^User:\s*/i, '')
if (actual) {
result.push({
...message,
content: [{ type: 'text', text: actual }],
})
}
}
continue
}
result.push(message)
}
return result
}
function normalizeChatHistoryMessages(input: {
sessionKey: string
source: OpenClawSessionSource
messages: OpenClawChatMessage[]
}): BrowserOSChatHistoryItem[] {
return input.messages
.map((message, index): BrowserOSChatHistoryItem | null => {
if (message.role !== 'user' && message.role !== 'assistant') return null
const text = getTextContent(message).trim()
if (!text) return null
return {
id: `${input.sessionKey}:${index}`,
role: message.role,
text,
timestamp: message.timestamp,
messageSeq: index,
sessionKey: input.sessionKey,
source: input.source,
}
})
.filter((item): item is BrowserOSChatHistoryItem => item !== null)
}
function getTextContent(message: OpenClawChatMessage): string {
return message.content
.filter((block) => block.type === 'text')
.map((block) => block.text ?? '')
.join('')
}
function encodeHistoryCursor(input: {
sessionKey: string
end: number
}): string {
return Buffer.from(JSON.stringify(input), 'utf-8').toString('base64url')
}
function decodeHistoryCursor(
cursor?: string,
): { sessionKey: string; end: number } | null {
if (!cursor) return null
try {
const parsed = JSON.parse(
Buffer.from(cursor, 'base64url').toString('utf-8'),
) as {
sessionKey?: unknown
end?: unknown
}
if (typeof parsed.sessionKey !== 'string') return null
if (typeof parsed.end !== 'number' || !Number.isFinite(parsed.end)) {
return null
}
return {
sessionKey: parsed.sessionKey,
end: Math.max(0, Math.trunc(parsed.end)),
}
} catch {
return null
}
}
export class OpenClawService {
private runtime: ContainerRuntime
private cliClient: OpenClawCliClient
@@ -573,6 +757,87 @@ export class OpenClawService {
return this.runControlPlaneCall(() => this.cliClient.listAgents())
}
async listSessions(agentId?: string): Promise<BrowserOSOpenClawSession[]> {
logger.debug('Listing OpenClaw sessions', { agentId })
const sessions = await this.cliClient.listSessions(agentId)
return sessions
.map(toBrowserOSSession)
.sort((a, b) => b.updatedAt - a.updatedAt)
}
async resolveAgentSession(
agentId: string,
): Promise<BrowserOSOpenClawAgentSessionResponse> {
const sessions = await this.listSessions(agentId)
const session =
sessions.find((entry) => entry.source === 'user-chat') ??
sessions.find((entry) => entry.kind.toLowerCase().includes('chat')) ??
sessions[0] ??
null
return {
agentId,
exists: !!session,
sessionKey: session?.key ?? null,
session,
}
}
async getChatHistory(sessionKey: string): Promise<OpenClawChatMessage[]> {
await this.assertGatewayReady()
logger.debug('Fetching OpenClaw chat history', { sessionKey })
return this.runControlPlaneCall(() =>
this.cliClient.getChatHistory(sessionKey),
)
}
async getAgentHistoryPage(
agentId: string,
input: HistoryPageInput = {},
): Promise<BrowserOSOpenClawHistoryPageResponse> {
const limit = normalizeHistoryLimit(input.limit)
const cursor = decodeHistoryCursor(input.cursor)
const resolved = input.sessionKey
? await this.resolveSpecificAgentSession(agentId, input.sessionKey)
: await this.resolveAgentSession(agentId)
const session = resolved.session
if (!session) {
return {
agentId,
sessionKey: null,
session: null,
items: [],
page: { hasMore: false, limit },
}
}
const sessionKey = cursor?.sessionKey ?? session.key
const rawMessages = await this.getChatHistory(sessionKey)
const items = normalizeChatHistoryMessages({
sessionKey,
source: session.source,
messages: filterOpenClawSystemMessages(rawMessages),
})
const end = Math.min(cursor?.end ?? items.length, items.length)
const start = Math.max(0, end - limit)
const pageItems = items.slice(start, end)
const nextCursor =
start > 0 ? encodeHistoryCursor({ sessionKey, end: start }) : undefined
return {
agentId,
sessionKey,
session,
items: pageItems,
page: {
cursor: nextCursor,
hasMore: start > 0,
limit,
},
}
}
// ── Chat Stream (HTTP) ───────────────────────────────────────────────
async chatStream(
@@ -598,6 +863,29 @@ export class OpenClawService {
)
}
private async resolveSpecificAgentSession(
agentId: string,
sessionKey: string,
): Promise<BrowserOSOpenClawAgentSessionResponse> {
const sessions = await this.listSessions(agentId)
const session =
sessions.find((entry) => entry.key === sessionKey) ??
toBrowserOSSession({
key: sessionKey,
updatedAt: 0,
sessionId: '',
agentId,
kind: '',
})
return {
agentId,
exists: true,
sessionKey,
session,
}
}
// ── Podman Overrides ─────────────────────────────────────────────────
async applyPodmanOverrides(input: {

View File

@@ -264,6 +264,180 @@ describe('createOpenClawRoutes', () => {
expect(response.status).toBe(404)
})
it('returns OpenClaw sessions for an agent', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const listSessions = mock(async () => [
{
key: 'openai-user:browseros:main:session-1',
updatedAt: 20,
sessionId: 'session-1',
agentId: 'main',
kind: 'chat',
source: 'user-chat',
},
])
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ listSessions }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/agents/main/sessions?limit=1')
expect(response.status).toBe(200)
expect(listSessions).toHaveBeenCalledWith('main')
expect(await response.json()).toEqual({
agentId: 'main',
sessions: [
{
key: 'openai-user:browseros:main:session-1',
updatedAt: 20,
sessionId: 'session-1',
agentId: 'main',
kind: 'chat',
source: 'user-chat',
},
],
})
})
it('returns the resolved active OpenClaw session for an agent', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const resolveAgentSession = mock(async () => ({
agentId: 'main',
exists: true,
sessionKey: 'session-1',
session: {
key: 'session-1',
updatedAt: 20,
sessionId: 'session-1',
agentId: 'main',
kind: 'chat',
source: 'other',
},
}))
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ resolveAgentSession }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request('/agents/main/session')
expect(response.status).toBe(200)
expect(resolveAgentSession).toHaveBeenCalledWith('main')
expect(await response.json()).toEqual({
agentId: 'main',
exists: true,
sessionKey: 'session-1',
session: {
key: 'session-1',
updatedAt: 20,
sessionId: 'session-1',
agentId: 'main',
kind: 'chat',
source: 'other',
},
})
})
it('returns a normalized OpenClaw history page for an agent', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'
)
const getAgentHistoryPage = mock(async () => ({
agentId: 'main',
sessionKey: 'session-1',
session: {
key: 'session-1',
updatedAt: 20,
sessionId: 'session-1',
agentId: 'main',
kind: 'chat',
source: 'other',
},
items: [
{
id: 'session-1:0',
role: 'user',
text: 'Hello',
timestamp: 1,
messageSeq: 0,
sessionKey: 'session-1',
source: 'other',
},
],
page: {
cursor: 'older-cursor',
hasMore: true,
limit: 25,
},
}))
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
...actualOpenClawService,
getOpenClawService: () => ({ getAgentHistoryPage }) as never,
}))
const { createOpenClawRoutes } = await import(
'../../../src/api/routes/openclaw'
)
const route = createOpenClawRoutes()
const response = await route.request(
'/agents/main/history?sessionKey=session-1&cursor=abc&limit=25',
)
expect(response.status).toBe(200)
expect(getAgentHistoryPage).toHaveBeenCalledWith('main', {
sessionKey: 'session-1',
cursor: 'abc',
limit: 25,
})
expect(await response.json()).toEqual({
agentId: 'main',
sessionKey: 'session-1',
session: {
key: 'session-1',
updatedAt: 20,
sessionId: 'session-1',
agentId: 'main',
kind: 'chat',
source: 'other',
},
items: [
{
id: 'session-1:0',
role: 'user',
text: 'Hello',
timestamp: 1,
messageSeq: 0,
sessionKey: 'session-1',
source: 'other',
},
],
page: {
cursor: 'older-cursor',
hasMore: true,
limit: 25,
},
})
})
it('returns the current podman overrides on GET', async () => {
const actualOpenClawService = await import(
'../../../src/api/services/openclaw/openclaw-service'

View File

@@ -264,6 +264,109 @@ describe('OpenClawCliClient', () => {
await expect(client.listAgents()).rejects.toThrow('agent already exists')
})
it('lists sessions for a specific agent', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
expect(command).toEqual([
'node',
'dist/index.js',
'sessions',
'--json',
'--agent',
'main',
])
onLog?.(
JSON.stringify({
sessions: [
{
key: 'openai-user:browseros:main:session-1',
updatedAt: 1710000000000,
sessionId: 'session-1',
agentId: 'main',
kind: 'chat',
status: 'active',
totalTokens: 120,
model: 'openai/gpt-5.4-mini',
modelProvider: 'openai',
},
],
count: 1,
}),
)
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const sessions = await client.listSessions('main')
expect(sessions).toEqual([
{
key: 'openai-user:browseros:main:session-1',
updatedAt: 1710000000000,
sessionId: 'session-1',
agentId: 'main',
kind: 'chat',
status: 'active',
totalTokens: 120,
model: 'openai/gpt-5.4-mini',
modelProvider: 'openai',
},
])
})
it('fetches chat history through the OpenClaw gateway call command', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {
expect(command).toEqual([
'node',
'dist/index.js',
'gateway',
'call',
'chat.history',
'--params',
'{"sessionKey":"session-1"}',
'--json',
])
onLog?.(
JSON.stringify({
messages: [
{
role: 'user',
content: [{ type: 'text', text: 'Hello' }],
timestamp: 1710000000001,
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Hi there' }],
timestamp: 1710000000002,
usage: { input: 5, output: 6 },
},
],
}),
)
return 0
},
)
const client = new OpenClawCliClient({ execInContainer })
const history = await client.getChatHistory('session-1')
expect(history).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Hello' }],
timestamp: 1710000000001,
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Hi there' }],
timestamp: 1710000000002,
usage: { input: 5, output: 6 },
},
])
})
it('parses config get output from mixed logs and pretty-printed JSON', async () => {
const execInContainer = mock(
async (command: string[], onLog?: (line: string) => void) => {

View File

@@ -47,7 +47,9 @@ type MutableOpenClawService = OpenClawService & {
probe?: ReturnType<typeof mock>
createAgent?: ReturnType<typeof mock>
getConfig?: ReturnType<typeof mock>
getChatHistory?: ReturnType<typeof mock>
listAgents?: ReturnType<typeof mock>
listSessions?: ReturnType<typeof mock>
setDefaultModel?: ReturnType<typeof mock>
}
bootstrapCliClient: {
@@ -147,6 +149,118 @@ describe('OpenClawService', () => {
])
})
it('resolves the latest user-chat session for an agent', async () => {
const service = new OpenClawService() as MutableOpenClawService
service.cliClient = {
listSessions: mock(async () => [
{
key: 'agent:main:cron:daily',
updatedAt: 30,
sessionId: 'cron-session',
agentId: 'main',
kind: 'cron',
},
{
key: 'openai-user:browseros:main:chat-session',
updatedAt: 20,
sessionId: 'chat-session',
agentId: 'main',
kind: 'chat',
},
]),
}
await expect(service.resolveAgentSession('main')).resolves.toEqual({
agentId: 'main',
exists: true,
sessionKey: 'openai-user:browseros:main:chat-session',
session: {
key: 'openai-user:browseros:main:chat-session',
updatedAt: 20,
sessionId: 'chat-session',
agentId: 'main',
kind: 'chat',
source: 'user-chat',
},
})
})
it('returns normalized paginated chat history for an agent session', async () => {
const service = new OpenClawService() as MutableOpenClawService
service.runtime = {
isReady: async () => true,
}
service.cliClient = {
listSessions: mock(async () => [
{
key: 'openai-user:browseros:main:chat-session',
updatedAt: 20,
sessionId: 'chat-session',
agentId: 'main',
kind: 'chat',
},
]),
getChatHistory: mock(async () => [
{
role: 'assistant',
content: [{ type: 'text', text: 'HEARTBEAT_OK' }],
},
{
role: 'user',
content: [{ type: 'text', text: 'First question' }],
timestamp: 1,
},
{
role: 'assistant',
content: [{ type: 'text', text: 'First answer' }],
timestamp: 2,
},
{
role: 'user',
content: [
{
type: 'text',
text:
'[Chat messages since your last reply]\n' +
'[Current message - respond to this]\n' +
'User: Second question',
},
],
timestamp: 3,
},
]),
}
const page = await service.getAgentHistoryPage('main', { limit: 2 })
expect(page.agentId).toBe('main')
expect(page.sessionKey).toBe('openai-user:browseros:main:chat-session')
expect(page.items).toEqual([
{
id: 'openai-user:browseros:main:chat-session:1',
role: 'assistant',
text: 'First answer',
timestamp: 2,
messageSeq: 1,
sessionKey: 'openai-user:browseros:main:chat-session',
source: 'user-chat',
},
{
id: 'openai-user:browseros:main:chat-session:2',
role: 'user',
text: 'Second question',
timestamp: 3,
messageSeq: 2,
sessionKey: 'openai-user:browseros:main:chat-session',
source: 'user-chat',
},
])
expect(page.page.hasMore).toBe(true)
expect(typeof page.page.cursor).toBe('string')
})
it('maps successful cli client probes into connected status', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'openclaw-service-'))
await mkdir(join(tempDir, '.openclaw'), { recursive: true })