feat(sessions): add channelIdleMinutes config for per-channel session idle durations (#1353)

* feat(sessions): add channelIdleMinutes config for per-channel session idle durations

Add new `channelIdleMinutes` config option to allow different session idle
timeouts per channel. For example, Discord sessions can now be configured
to last 7 days (10080 minutes) while other channels use shorter defaults.

Config example:
  sessions:
    channelIdleMinutes:
      discord: 10080  # 7 days

The channel-specific idle is passed as idleMinutesOverride to the existing
resolveSessionResetPolicy, integrating cleanly with the new reset policy
architecture.

* fix

* feat: add per-channel session reset overrides (#1353) (thanks @cash-echo-bot)

---------

Co-authored-by: Cash Williams <cashwilliams@gmail.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Echo
2026-01-21 13:10:31 -06:00
committed by GitHub
parent 403904ecd1
commit c415ccaed5
14 changed files with 123 additions and 28 deletions

View File

@@ -11,7 +11,7 @@ Docs: https://docs.clawd.bot
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. - Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs. - CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability. - CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
- Queue: allow per-channel debounce overrides and plugin defaults. (#1190) Thanks @cheeeee. - Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
### Fixes ### Fixes
- Embedded runner: persist injected history images so attachments arent reloaded each turn. (#1374) Thanks @Nicell. - Embedded runner: persist injected history images so attachments arent reloaded each turn. (#1374) Thanks @Nicell.

View File

@@ -60,6 +60,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session. - Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility. - Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector). - Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
- Per-channel overrides (optional): `resetByChannel` overrides the reset policy for a channel (applies to all session types for that channel and takes precedence over `reset`/`resetByType`).
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. `/new <model>` accepts a model alias, `provider/model`, or provider name (fuzzy match) to set the new session model. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset. - Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. `/new <model>` accepts a model alias, `provider/model`, or provider name (fuzzy match) to set the new session model. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them. - Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse). - Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
@@ -109,6 +110,9 @@ Send these as standalone messages so they register.
dm: { mode: "idle", idleMinutes: 240 }, dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 } group: { mode: "idle", idleMinutes: 120 }
}, },
resetByChannel: {
discord: { mode: "idle", idleMinutes: 10080 }
},
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json", store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
mainKey: "main", mainKey: "main",

View File

@@ -151,7 +151,9 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
atHour: 4, atHour: 4,
idleMinutes: 60 idleMinutes: 60
}, },
heartbeatIdleMinutes: 120, resetByChannel: {
discord: { mode: "idle", idleMinutes: 10080 }
},
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/default/sessions/sessions.json", store: "~/.clawdbot/agents/default/sessions/sessions.json",
typingIntervalSeconds: 5, typingIntervalSeconds: 5,

View File

@@ -2453,6 +2453,9 @@ Controls session scoping, reset policy, reset triggers, and where the session st
dm: { mode: "idle", idleMinutes: 240 }, dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 } group: { mode: "idle", idleMinutes: 120 }
}, },
resetByChannel: {
discord: { mode: "idle", idleMinutes: 10080 }
},
resetTriggers: ["/new", "/reset"], resetTriggers: ["/new", "/reset"],
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json // Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
// You can override with {agentId} templating: // You can override with {agentId} templating:
@@ -2488,7 +2491,7 @@ Fields:
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins. - `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`. - `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility. - If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled). - `resetByChannel`: channel-specific reset policy overrides (keyed by channel id, applies to all session types for that channel; overrides `reset`/`resetByType`).
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (05, default 5). - `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (05, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches. - `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow. - `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.

View File

@@ -36,7 +36,9 @@ describe("injectHistoryImagesIntoMessages", () => {
const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[0, [image]]])); const didMutate = injectHistoryImagesIntoMessages(messages, new Map([[0, [image]]]));
expect(didMutate).toBe(false); expect(didMutate).toBe(false);
expect((messages[0]?.content as unknown[]).length).toBe(2); const content = messages[0]?.content as unknown[] | undefined;
expect(content).toBeDefined();
expect(content).toHaveLength(2);
}); });
it("ignores non-user messages and out-of-range indices", () => { it("ignores non-user messages and out-of-range indices", () => {

View File

@@ -436,3 +436,42 @@ describe("initSessionState reset policy", () => {
} }
}); });
}); });
describe("initSessionState channel reset overrides", () => {
it("uses channel-specific reset policy when configured", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-channel-idle-"));
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:discord:dm:123";
const sessionId = "session-override";
const updatedAt = Date.now() - (10080 - 1) * 60_000;
await saveSessionStore(storePath, {
[sessionKey]: {
sessionId,
updatedAt,
},
});
const cfg = {
session: {
store: storePath,
idleMinutes: 60,
resetByType: { dm: { mode: "idle", idleMinutes: 10 } },
resetByChannel: { discord: { mode: "idle", idleMinutes: 10080 } },
},
} as ClawdbotConfig;
const result = await initSessionState({
ctx: {
Body: "Hello",
SessionKey: sessionKey,
Provider: "discord",
},
cfg,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(false);
expect(result.sessionEntry.sessionId).toBe(sessionId);
});
});

View File

@@ -11,6 +11,7 @@ import {
evaluateSessionFreshness, evaluateSessionFreshness,
type GroupKeyResolution, type GroupKeyResolution,
loadSessionStore, loadSessionStore,
resolveChannelResetConfig,
resolveThreadFlag, resolveThreadFlag,
resolveSessionResetPolicy, resolveSessionResetPolicy,
resolveSessionResetType, resolveSessionResetType,
@@ -106,6 +107,7 @@ export async function initSessionState(params: {
sessionKey: sessionCtxForState.SessionKey, sessionKey: sessionCtxForState.SessionKey,
config: cfg, config: cfg,
}); });
const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined;
const resetTriggers = sessionCfg?.resetTriggers?.length const resetTriggers = sessionCfg?.resetTriggers?.length
? sessionCfg.resetTriggers ? sessionCfg.resetTriggers
: DEFAULT_RESET_TRIGGERS; : DEFAULT_RESET_TRIGGERS;
@@ -129,7 +131,6 @@ export async function initSessionState(params: {
let persistedModelOverride: string | undefined; let persistedModelOverride: string | undefined;
let persistedProviderOverride: string | undefined; let persistedProviderOverride: string | undefined;
const groupResolution = resolveGroupSessionKey(sessionCtxForState) ?? undefined;
const normalizedChatType = normalizeChatType(ctx.ChatType); const normalizedChatType = normalizeChatType(ctx.ChatType);
const isGroup = const isGroup =
normalizedChatType != null && normalizedChatType !== "direct" ? true : Boolean(groupResolution); normalizedChatType != null && normalizedChatType !== "direct" ? true : Boolean(groupResolution);
@@ -195,7 +196,19 @@ export async function initSessionState(params: {
parentSessionKey: ctx.ParentSessionKey, parentSessionKey: ctx.ParentSessionKey,
}); });
const resetType = resolveSessionResetType({ sessionKey, isGroup, isThread }); const resetType = resolveSessionResetType({ sessionKey, isGroup, isThread });
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType }); const channelReset = resolveChannelResetConfig({
sessionCfg,
channel:
groupResolution?.channel ??
(ctx.OriginatingChannel as string | undefined) ??
ctx.Surface ??
ctx.Provider,
});
const resetPolicy = resolveSessionResetPolicy({
sessionCfg,
resetType,
resetOverride: channelReset,
});
const freshEntry = entry const freshEntry = entry
? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh ? evaluateSessionFreshness({ updatedAt: entry.updatedAt, now, policy: resetPolicy }).fresh
: false; : false;

View File

@@ -12,6 +12,7 @@ import {
evaluateSessionFreshness, evaluateSessionFreshness,
loadSessionStore, loadSessionStore,
resolveAgentIdFromSessionKey, resolveAgentIdFromSessionKey,
resolveChannelResetConfig,
resolveExplicitAgentSessionKey, resolveExplicitAgentSessionKey,
resolveSessionResetPolicy, resolveSessionResetPolicy,
resolveSessionResetType, resolveSessionResetType,
@@ -99,7 +100,15 @@ export function resolveSession(opts: {
const sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined; const sessionEntry = sessionKey ? sessionStore[sessionKey] : undefined;
const resetType = resolveSessionResetType({ sessionKey }); const resetType = resolveSessionResetType({ sessionKey });
const resetPolicy = resolveSessionResetPolicy({ sessionCfg, resetType }); const channelReset = resolveChannelResetConfig({
sessionCfg,
channel: sessionEntry?.lastChannel ?? sessionEntry?.channel,
});
const resetPolicy = resolveSessionResetPolicy({
sessionCfg,
resetType,
resetOverride: channelReset,
});
const fresh = sessionEntry const fresh = sessionEntry
? evaluateSessionFreshness({ updatedAt: sessionEntry.updatedAt, now, policy: resetPolicy }) ? evaluateSessionFreshness({ updatedAt: sessionEntry.updatedAt, now, policy: resetPolicy })
.fresh .fresh

View File

@@ -1,5 +1,6 @@
import type { SessionConfig } from "../types.base.js"; import type { SessionConfig, SessionResetConfig } from "../types.base.js";
import { DEFAULT_IDLE_MINUTES } from "./types.js"; import { DEFAULT_IDLE_MINUTES } from "./types.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
export type SessionResetMode = "daily" | "idle"; export type SessionResetMode = "daily" | "idle";
export type SessionResetType = "dm" | "group" | "thread"; export type SessionResetType = "dm" | "group" | "thread";
@@ -67,13 +68,13 @@ export function resolveDailyResetAtMs(now: number, atHour: number): number {
export function resolveSessionResetPolicy(params: { export function resolveSessionResetPolicy(params: {
sessionCfg?: SessionConfig; sessionCfg?: SessionConfig;
resetType: SessionResetType; resetType: SessionResetType;
idleMinutesOverride?: number; resetOverride?: SessionResetConfig;
}): SessionResetPolicy { }): SessionResetPolicy {
const sessionCfg = params.sessionCfg; const sessionCfg = params.sessionCfg;
const baseReset = sessionCfg?.reset; const baseReset = params.resetOverride ?? sessionCfg?.reset;
const typeReset = sessionCfg?.resetByType?.[params.resetType]; const typeReset = params.resetOverride ? undefined : sessionCfg?.resetByType?.[params.resetType];
const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType); const hasExplicitReset = Boolean(baseReset || sessionCfg?.resetByType);
const legacyIdleMinutes = sessionCfg?.idleMinutes; const legacyIdleMinutes = params.resetOverride ? undefined : sessionCfg?.idleMinutes;
const mode = const mode =
typeReset?.mode ?? typeReset?.mode ??
baseReset?.mode ?? baseReset?.mode ??
@@ -81,11 +82,7 @@ export function resolveSessionResetPolicy(params: {
const atHour = normalizeResetAtHour( const atHour = normalizeResetAtHour(
typeReset?.atHour ?? baseReset?.atHour ?? DEFAULT_RESET_AT_HOUR, typeReset?.atHour ?? baseReset?.atHour ?? DEFAULT_RESET_AT_HOUR,
); );
const idleMinutesRaw = const idleMinutesRaw = typeReset?.idleMinutes ?? baseReset?.idleMinutes ?? legacyIdleMinutes;
params.idleMinutesOverride ??
typeReset?.idleMinutes ??
baseReset?.idleMinutes ??
legacyIdleMinutes;
let idleMinutes: number | undefined; let idleMinutes: number | undefined;
if (idleMinutesRaw != null) { if (idleMinutesRaw != null) {
@@ -100,6 +97,19 @@ export function resolveSessionResetPolicy(params: {
return { mode, atHour, idleMinutes }; return { mode, atHour, idleMinutes };
} }
export function resolveChannelResetConfig(params: {
sessionCfg?: SessionConfig;
channel?: string | null;
}): SessionResetConfig | undefined {
const resetByChannel = params.sessionCfg?.resetByChannel;
if (!resetByChannel) return undefined;
const normalized = normalizeMessageChannel(params.channel);
const fallback = params.channel?.trim().toLowerCase();
const key = normalized ?? fallback;
if (!key) return undefined;
return resetByChannel[key] ?? resetByChannel[key.toLowerCase()];
}
export function evaluateSessionFreshness(params: { export function evaluateSessionFreshness(params: {
updatedAt: number; updatedAt: number;
now: number; now: number;

View File

@@ -77,9 +77,10 @@ export type SessionConfig = {
identityLinks?: Record<string, string[]>; identityLinks?: Record<string, string[]>;
resetTriggers?: string[]; resetTriggers?: string[];
idleMinutes?: number; idleMinutes?: number;
heartbeatIdleMinutes?: number;
reset?: SessionResetConfig; reset?: SessionResetConfig;
resetByType?: SessionResetByTypeConfig; resetByType?: SessionResetByTypeConfig;
/** Channel-specific reset overrides (e.g. { discord: { mode: "idle", idleMinutes: 10080 } }). */
resetByChannel?: Record<string, SessionResetConfig>;
store?: string; store?: string;
typingIntervalSeconds?: number; typingIntervalSeconds?: number;
typingMode?: TypingMode; typingMode?: TypingMode;

View File

@@ -24,7 +24,6 @@ export const SessionSchema = z
identityLinks: z.record(z.string(), z.array(z.string())).optional(), identityLinks: z.record(z.string(), z.array(z.string())).optional(),
resetTriggers: z.array(z.string()).optional(), resetTriggers: z.array(z.string()).optional(),
idleMinutes: z.number().int().positive().optional(), idleMinutes: z.number().int().positive().optional(),
heartbeatIdleMinutes: z.number().int().positive().optional(),
reset: SessionResetConfigSchema.optional(), reset: SessionResetConfigSchema.optional(),
resetByType: z resetByType: z
.object({ .object({
@@ -34,6 +33,7 @@ export const SessionSchema = z
}) })
.strict() .strict()
.optional(), .optional(),
resetByChannel: z.record(z.string(), SessionResetConfigSchema).optional(),
store: z.string().optional(), store: z.string().optional(),
typingIntervalSeconds: z.number().int().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(),
typingMode: z typingMode: z

View File

@@ -31,8 +31,11 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch
return response; return response;
}) as FetchWithPreconnect; }) as FetchWithPreconnect;
const fetchWithPreconnect = fetchImpl as FetchWithPreconnect;
wrapped.preconnect = wrapped.preconnect =
typeof fetchImpl.preconnect === "function" ? fetchImpl.preconnect.bind(fetchImpl) : () => {}; typeof fetchWithPreconnect.preconnect === "function"
? fetchWithPreconnect.preconnect.bind(fetchWithPreconnect)
: () => {};
return Object.assign(wrapped, fetchImpl); return Object.assign(wrapped, fetchImpl);
} }

View File

@@ -8,7 +8,7 @@ import { saveSessionStore } from "../../config/sessions.js";
import { getSessionSnapshot } from "./session-snapshot.js"; import { getSessionSnapshot } from "./session-snapshot.js";
describe("getSessionSnapshot", () => { describe("getSessionSnapshot", () => {
it("uses heartbeat idle override while daily reset still applies", async () => { it("uses channel reset overrides when configured", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0)); vi.setSystemTime(new Date(2026, 0, 18, 5, 0, 0));
try { try {
@@ -20,6 +20,7 @@ describe("getSessionSnapshot", () => {
[sessionKey]: { [sessionKey]: {
sessionId: "snapshot-session", sessionId: "snapshot-session",
updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(), updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(),
lastChannel: "whatsapp",
}, },
}); });
@@ -27,7 +28,9 @@ describe("getSessionSnapshot", () => {
session: { session: {
store: storePath, store: storePath,
reset: { mode: "daily", atHour: 4, idleMinutes: 240 }, reset: { mode: "daily", atHour: 4, idleMinutes: 240 },
heartbeatIdleMinutes: 30, resetByChannel: {
whatsapp: { mode: "idle", idleMinutes: 360 },
},
}, },
} as Parameters<typeof getSessionSnapshot>[0]; } as Parameters<typeof getSessionSnapshot>[0];
@@ -35,9 +38,10 @@ describe("getSessionSnapshot", () => {
sessionKey, sessionKey,
}); });
expect(snapshot.resetPolicy.idleMinutes).toBe(30); expect(snapshot.resetPolicy.mode).toBe("idle");
expect(snapshot.fresh).toBe(false); expect(snapshot.resetPolicy.idleMinutes).toBe(360);
expect(snapshot.dailyResetAt).toBe(new Date(2026, 0, 18, 4, 0, 0).getTime()); expect(snapshot.fresh).toBe(true);
expect(snapshot.dailyResetAt).toBeUndefined();
} finally { } finally {
vi.useRealTimers(); vi.useRealTimers();
} }

View File

@@ -2,6 +2,7 @@ import type { loadConfig } from "../../config/config.js";
import { import {
evaluateSessionFreshness, evaluateSessionFreshness,
loadSessionStore, loadSessionStore,
resolveChannelResetConfig,
resolveThreadFlag, resolveThreadFlag,
resolveSessionResetPolicy, resolveSessionResetPolicy,
resolveSessionResetType, resolveSessionResetType,
@@ -13,7 +14,7 @@ import { normalizeMainKey } from "../../routing/session-key.js";
export function getSessionSnapshot( export function getSessionSnapshot(
cfg: ReturnType<typeof loadConfig>, cfg: ReturnType<typeof loadConfig>,
from: string, from: string,
isHeartbeat = false, _isHeartbeat = false,
ctx?: { ctx?: {
sessionKey?: string | null; sessionKey?: string | null;
isGroup?: boolean; isGroup?: boolean;
@@ -34,6 +35,7 @@ export function getSessionSnapshot(
); );
const store = loadSessionStore(resolveStorePath(sessionCfg?.store)); const store = loadSessionStore(resolveStorePath(sessionCfg?.store));
const entry = store[key]; const entry = store[key];
const isThread = resolveThreadFlag({ const isThread = resolveThreadFlag({
sessionKey: key, sessionKey: key,
messageThreadId: ctx?.messageThreadId ?? null, messageThreadId: ctx?.messageThreadId ?? null,
@@ -42,11 +44,14 @@ export function getSessionSnapshot(
parentSessionKey: ctx?.parentSessionKey ?? null, parentSessionKey: ctx?.parentSessionKey ?? null,
}); });
const resetType = resolveSessionResetType({ sessionKey: key, isGroup: ctx?.isGroup, isThread }); const resetType = resolveSessionResetType({ sessionKey: key, isGroup: ctx?.isGroup, isThread });
const idleMinutesOverride = isHeartbeat ? sessionCfg?.heartbeatIdleMinutes : undefined; const channelReset = resolveChannelResetConfig({
sessionCfg,
channel: entry?.lastChannel ?? entry?.channel,
});
const resetPolicy = resolveSessionResetPolicy({ const resetPolicy = resolveSessionResetPolicy({
sessionCfg, sessionCfg,
resetType, resetType,
idleMinutesOverride, resetOverride: channelReset,
}); });
const now = Date.now(); const now = Date.now();
const freshness = entry const freshness = entry