mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-01 12:21:25 +00:00
* fix: make cleanup "keep" persist subagent sessions indefinitely * feat: expose subagent session metadata in sessions list * fix: include status and timing in sessions_list tool * fix: hide injected timestamp prefixes in chat ui * feat: push session list updates over websocket * feat: expose child subagent sessions in subagents list * feat: add admin http endpoint to kill sessions * Emit session.message websocket events for transcript updates * Estimate session costs in sessions list * Add direct session history HTTP and SSE endpoints * Harden dashboard session events and history APIs * Add session lifecycle gateway methods * Add dashboard session API improvements * Add dashboard session model and parent linkage support * fix: tighten dashboard session API metadata * Fix dashboard session cost metadata * Persist accumulated session cost * fix: stop followup queue drain cfg crash * Fix dashboard session create and model metadata * fix: stop guessing session model costs * Gateway: cache OpenRouter pricing for configured models * Gateway: add timeout session status * Fix subagent spawn test config loading * Gateway: preserve operator scopes without device identity * Emit user message transcript events and deduplicate plugin warnings * feat: emit sessions.changed lifecycle event on subagent spawn Adds a session-lifecycle-events module (similar to transcript-events) that emits create events when subagents are spawned. The gateway server.impl.ts listens for these events and broadcasts sessions.changed with reason=create to SSE subscribers, so dashboards can pick up new subagent sessions without polling. * Gateway: allow persistent dashboard orchestrator sessions * fix: preserve operator scopes for token-authenticated backend clients Backend clients (like agent-dashboard) that authenticate with a valid gateway token but don't present a device identity were getting their scopes stripped. The scope-clearing logic ran before checking the device identity decision, so even when evaluateMissingDeviceIdentity returned 'allow' (because roleCanSkipDeviceIdentity passed for token-authed operators), scopes were already cleared. Fix: also check decision.kind before clearing scopes, so token-authenticated operators keep their requested scopes. * Gateway: allow operator-token session kills * Fix stale active subagent status after follow-up runs * Fix dashboard image attachments in sessions send * Fix completed session follow-up status updates * feat: stream session tool events to operator UIs * Add sessions.steer gateway coverage * Persist subagent timing in session store * Fix subagent session transcript event keys * Fix active subagent session status in gateway * bump session label max to 512 * Fix gateway send session reactivation * fix: publish terminal session lifecycle state * feat: change default session reset to effectively never - Change DEFAULT_RESET_MODE from "daily" to "idle" - Change DEFAULT_IDLE_MINUTES from 60 to 0 (0 = disabled/never) - Allow idleMinutes=0 through normalization (don't clamp to 1) - Treat idleMinutes=0 as "no idle expiry" in evaluateSessionFreshness - Default behavior: mode "idle" + idleMinutes 0 = sessions never auto-reset - Update test assertion for new default mode * fix: prep session management followups (#50101) (thanks @clay-datacurve) --------- Co-authored-by: Tyler Yust <TYTYYUST@YAHOO.COM>
253 lines
7.3 KiB
TypeScript
253 lines
7.3 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
|
import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
|
|
import { parseSessionThreadInfo } from "./delivery-info.js";
|
|
import {
|
|
resolveDefaultSessionStorePath,
|
|
resolveSessionFilePath,
|
|
resolveSessionFilePathOptions,
|
|
resolveSessionTranscriptPath,
|
|
} from "./paths.js";
|
|
import { resolveAndPersistSessionFile } from "./session-file.js";
|
|
import { loadSessionStore, normalizeStoreSessionKey } from "./store.js";
|
|
import type { SessionEntry } from "./types.js";
|
|
|
|
function stripQuery(value: string): string {
|
|
const noHash = value.split("#")[0] ?? value;
|
|
return noHash.split("?")[0] ?? noHash;
|
|
}
|
|
|
|
function extractFileNameFromMediaUrl(value: string): string | null {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
const cleaned = stripQuery(trimmed);
|
|
try {
|
|
const parsed = new URL(cleaned);
|
|
const base = path.basename(parsed.pathname);
|
|
if (!base) {
|
|
return null;
|
|
}
|
|
try {
|
|
return decodeURIComponent(base);
|
|
} catch {
|
|
return base;
|
|
}
|
|
} catch {
|
|
const base = path.basename(cleaned);
|
|
if (!base || base === "/" || base === ".") {
|
|
return null;
|
|
}
|
|
return base;
|
|
}
|
|
}
|
|
|
|
export function resolveMirroredTranscriptText(params: {
|
|
text?: string;
|
|
mediaUrls?: string[];
|
|
}): string | null {
|
|
const mediaUrls = params.mediaUrls?.filter((url) => url && url.trim()) ?? [];
|
|
if (mediaUrls.length > 0) {
|
|
const names = mediaUrls
|
|
.map((url) => extractFileNameFromMediaUrl(url))
|
|
.filter((name): name is string => Boolean(name && name.trim()));
|
|
if (names.length > 0) {
|
|
return names.join(", ");
|
|
}
|
|
return "media";
|
|
}
|
|
|
|
const text = params.text ?? "";
|
|
const trimmed = text.trim();
|
|
return trimmed ? trimmed : null;
|
|
}
|
|
|
|
async function ensureSessionHeader(params: {
|
|
sessionFile: string;
|
|
sessionId: string;
|
|
}): Promise<void> {
|
|
if (fs.existsSync(params.sessionFile)) {
|
|
return;
|
|
}
|
|
await fs.promises.mkdir(path.dirname(params.sessionFile), { recursive: true });
|
|
const header = {
|
|
type: "session",
|
|
version: CURRENT_SESSION_VERSION,
|
|
id: params.sessionId,
|
|
timestamp: new Date().toISOString(),
|
|
cwd: process.cwd(),
|
|
};
|
|
await fs.promises.writeFile(params.sessionFile, `${JSON.stringify(header)}\n`, {
|
|
encoding: "utf-8",
|
|
mode: 0o600,
|
|
});
|
|
}
|
|
|
|
export async function resolveSessionTranscriptFile(params: {
|
|
sessionId: string;
|
|
sessionKey: string;
|
|
sessionEntry: SessionEntry | undefined;
|
|
sessionStore?: Record<string, SessionEntry>;
|
|
storePath?: string;
|
|
agentId: string;
|
|
threadId?: string | number;
|
|
}): Promise<{ sessionFile: string; sessionEntry: SessionEntry | undefined }> {
|
|
const sessionPathOpts = resolveSessionFilePathOptions({
|
|
agentId: params.agentId,
|
|
storePath: params.storePath,
|
|
});
|
|
let sessionFile = resolveSessionFilePath(params.sessionId, params.sessionEntry, sessionPathOpts);
|
|
let sessionEntry = params.sessionEntry;
|
|
|
|
if (params.sessionStore && params.storePath) {
|
|
const threadIdFromSessionKey = parseSessionThreadInfo(params.sessionKey).threadId;
|
|
const fallbackSessionFile = !sessionEntry?.sessionFile
|
|
? resolveSessionTranscriptPath(
|
|
params.sessionId,
|
|
params.agentId,
|
|
params.threadId ?? threadIdFromSessionKey,
|
|
)
|
|
: undefined;
|
|
const resolvedSessionFile = await resolveAndPersistSessionFile({
|
|
sessionId: params.sessionId,
|
|
sessionKey: params.sessionKey,
|
|
sessionStore: params.sessionStore,
|
|
storePath: params.storePath,
|
|
sessionEntry,
|
|
agentId: sessionPathOpts?.agentId,
|
|
sessionsDir: sessionPathOpts?.sessionsDir,
|
|
fallbackSessionFile,
|
|
});
|
|
sessionFile = resolvedSessionFile.sessionFile;
|
|
sessionEntry = resolvedSessionFile.sessionEntry;
|
|
}
|
|
|
|
return {
|
|
sessionFile,
|
|
sessionEntry,
|
|
};
|
|
}
|
|
|
|
export async function appendAssistantMessageToSessionTranscript(params: {
|
|
agentId?: string;
|
|
sessionKey: string;
|
|
text?: string;
|
|
mediaUrls?: string[];
|
|
idempotencyKey?: string;
|
|
/** Optional override for store path (mostly for tests). */
|
|
storePath?: string;
|
|
}): Promise<{ ok: true; sessionFile: string; messageId: string } | { ok: false; reason: string }> {
|
|
const sessionKey = params.sessionKey.trim();
|
|
if (!sessionKey) {
|
|
return { ok: false, reason: "missing sessionKey" };
|
|
}
|
|
|
|
const mirrorText = resolveMirroredTranscriptText({
|
|
text: params.text,
|
|
mediaUrls: params.mediaUrls,
|
|
});
|
|
if (!mirrorText) {
|
|
return { ok: false, reason: "empty text" };
|
|
}
|
|
|
|
const storePath = params.storePath ?? resolveDefaultSessionStorePath(params.agentId);
|
|
const store = loadSessionStore(storePath, { skipCache: true });
|
|
const normalizedKey = normalizeStoreSessionKey(sessionKey);
|
|
const entry = (store[normalizedKey] ?? store[sessionKey]) as SessionEntry | undefined;
|
|
if (!entry?.sessionId) {
|
|
return { ok: false, reason: `unknown sessionKey: ${sessionKey}` };
|
|
}
|
|
|
|
let sessionFile: string;
|
|
try {
|
|
const resolvedSessionFile = await resolveAndPersistSessionFile({
|
|
sessionId: entry.sessionId,
|
|
sessionKey,
|
|
sessionStore: store,
|
|
storePath,
|
|
sessionEntry: entry,
|
|
agentId: params.agentId,
|
|
sessionsDir: path.dirname(storePath),
|
|
});
|
|
sessionFile = resolvedSessionFile.sessionFile;
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
reason: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
|
|
await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId });
|
|
|
|
const existingMessageId = params.idempotencyKey
|
|
? await transcriptHasIdempotencyKey(sessionFile, params.idempotencyKey)
|
|
: undefined;
|
|
if (existingMessageId) {
|
|
return { ok: true, sessionFile, messageId: existingMessageId };
|
|
}
|
|
|
|
const message = {
|
|
role: "assistant" as const,
|
|
content: [{ type: "text", text: mirrorText }],
|
|
api: "openai-responses",
|
|
provider: "openclaw",
|
|
model: "delivery-mirror",
|
|
usage: {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 0,
|
|
cost: {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
total: 0,
|
|
},
|
|
},
|
|
stopReason: "stop" as const,
|
|
timestamp: Date.now(),
|
|
...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}),
|
|
} as Parameters<SessionManager["appendMessage"]>[0];
|
|
const sessionManager = SessionManager.open(sessionFile);
|
|
const messageId = sessionManager.appendMessage(message);
|
|
|
|
emitSessionTranscriptUpdate({ sessionFile, sessionKey, message, messageId });
|
|
return { ok: true, sessionFile, messageId };
|
|
}
|
|
|
|
async function transcriptHasIdempotencyKey(
|
|
transcriptPath: string,
|
|
idempotencyKey: string,
|
|
): Promise<string | undefined> {
|
|
try {
|
|
const raw = await fs.promises.readFile(transcriptPath, "utf-8");
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
if (!line.trim()) {
|
|
continue;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(line) as {
|
|
id?: unknown;
|
|
message?: { idempotencyKey?: unknown };
|
|
};
|
|
if (
|
|
parsed.message?.idempotencyKey === idempotencyKey &&
|
|
typeof parsed.id === "string" &&
|
|
parsed.id
|
|
) {
|
|
return parsed.id;
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
return undefined;
|
|
}
|