refactor discord thread bindings to idle and max-age lifecycle

This commit is contained in:
Onur Solmaz
2026-02-26 19:14:28 +01:00
parent c92c3ad224
commit 41eacccc42
32 changed files with 965 additions and 365 deletions

View File

@@ -635,7 +635,8 @@ Default slash command settings:
- `/focus <target>` bind current/new thread to a subagent/session target
- `/unfocus` remove current thread binding
- `/agents` show active runs and binding state
- `/session ttl <duration|off>` inspect/update auto-unfocus TTL for focused bindings
- `/session idle <duration|off>` inspect/update inactivity auto-unfocus for focused bindings
- `/session max-age <duration|off>` inspect/update hard max age for focused bindings
Config:
@@ -644,14 +645,16 @@ Default slash command settings:
session: {
threadBindings: {
enabled: true,
ttlHours: 24,
idleHours: 24,
maxAgeHours: 0,
},
},
channels: {
discord: {
threadBindings: {
enabled: true,
ttlHours: 24,
idleHours: 24,
maxAgeHours: 0,
spawnSubagentSessions: false, // opt-in
},
},

View File

@@ -244,7 +244,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
},
threadBindings: {
enabled: true,
ttlHours: 24,
idleHours: 24,
maxAgeHours: 0,
spawnSubagentSessions: false, // opt-in for sessions_spawn({ thread: true })
},
voice: {
@@ -277,8 +278,9 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered).
- `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars.
- `channels.discord.threadBindings` controls Discord thread-bound routing:
- `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session ttl`, and bound delivery/routing)
- `ttlHours`: Discord override for auto-unfocus TTL (`0` disables)
- `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and bound delivery/routing)
- `idleHours`: Discord override for inactivity auto-unfocus in hours (`0` disables)
- `maxAgeHours`: Discord override for hard max age in hours (`0` disables)
- `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides.
@@ -1246,7 +1248,8 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
},
threadBindings: {
enabled: true,
ttlHours: 24, // default auto-unfocus TTL for thread-bound sessions (0 disables)
idleHours: 24, // default inactivity auto-unfocus in hours (`0` disables)
maxAgeHours: 0, // default hard max age in hours (`0` disables)
},
mainKey: "main", // legacy (runtime always uses "main")
agentToAgent: { maxPingPongTurns: 5 },
@@ -1273,7 +1276,8 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- **`maintenance`**: `warn` warns the active session on eviction; `enforce` applies pruning and rotation.
- **`threadBindings`**: global defaults for thread-bound session features.
- `enabled`: master default switch (providers can override; Discord uses `channels.discord.threadBindings.enabled`)
- `ttlHours`: default auto-unfocus TTL in hours (`0` disables; providers can override)
- `idleHours`: default inactivity auto-unfocus in hours (`0` disables; providers can override)
- `maxAgeHours`: default hard max age in hours (`0` disables; providers can override)
</Accordion>

View File

@@ -184,7 +184,8 @@ When validation fails:
dmScope: "per-channel-peer", // recommended for multi-user
threadBindings: {
enabled: true,
ttlHours: 24,
idleHours: 24,
maxAgeHours: 0,
},
reset: {
mode: "daily",
@@ -196,7 +197,7 @@ When validation fails:
```
- `dmScope`: `main` (shared) | `per-peer` | `per-channel-peer` | `per-account-channel-peer`
- `threadBindings`: global defaults for thread-bound session routing (Discord supports `/focus`, `/unfocus`, `/agents`, and `/session ttl`).
- `threadBindings`: global defaults for thread-bound session routing (Discord supports `/focus`, `/unfocus`, `/agents`, `/session idle`, and `/session max-age`).
- See [Session Management](/concepts/session) for scoping, identity links, and send policy.
- See [full reference](/gateway/configuration-reference#session) for all fields.

View File

@@ -1050,13 +1050,13 @@ Basic flow:
- Spawn with `sessions_spawn` using `thread: true` (and optionally `mode: "session"` for persistent follow-up).
- Or manually bind with `/focus <target>`.
- Use `/agents` to inspect binding state.
- Use `/session ttl <duration|off>` to control auto-unfocus.
- Use `/session idle <duration|off>` and `/session max-age <duration|off>` to control auto-unfocus.
- Use `/unfocus` to detach the thread.
Required config:
- Global defaults: `session.threadBindings.enabled`, `session.threadBindings.ttlHours`.
- Discord overrides: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`.
- Global defaults: `session.threadBindings.enabled`, `session.threadBindings.idleHours`, `session.threadBindings.maxAgeHours`.
- Discord overrides: `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.idleHours`, `channels.discord.threadBindings.maxAgeHours`.
- Auto-bind on spawn: set `channels.discord.threadBindings.spawnSubagentSessions: true`.
Docs: [Sub-agents](/tools/subagents), [Discord](/channels/discord), [Configuration Reference](/gateway/configuration-reference), [Slash commands](/tools/slash-commands).

View File

@@ -78,7 +78,8 @@ Text + native (when enabled):
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt)
- `/whoami` (show your sender id; alias: `/id`)
- `/session ttl <duration|off>` (manage session-level settings, such as TTL)
- `/session idle <duration|off>` (manage inactivity auto-unfocus for focused thread bindings)
- `/session max-age <duration|off>` (manage hard max-age auto-unfocus for focused thread bindings)
- `/subagents list|kill|log|info|send|steer|spawn` (inspect, control, or spawn sub-agent runs for the current session)
- `/agents` (list thread-bound agents for this session)
- `/focus <target>` (Discord: bind this thread, or a new thread, to a session/subagent target)
@@ -124,7 +125,7 @@ Notes:
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from OpenClaw session logs.
- `/restart` is enabled by default; set `commands.restart: false` to disable it.
- Discord-only native command: `/vc join|leave|status` controls voice channels (requires `channels.discord.voice` and native commands; not available as text).
- Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session ttl`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`).
- Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`).
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.

View File

@@ -30,7 +30,8 @@ These commands work on channels that support persistent thread bindings. See **T
- `/focus <subagent-label|session-key|session-id|session-label>`
- `/unfocus`
- `/agents`
- `/session ttl <duration|off>`
- `/session idle <duration|off>`
- `/session max-age <duration|off>`
`/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup).
@@ -93,14 +94,14 @@ When thread bindings are enabled for a channel, a sub-agent can stay bound to a
### Thread supporting channels
- Discord (currently the only supported channel): supports persistent thread-bound subagent sessions (`sessions_spawn` with `thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`, `/session ttl`), and adapter keys `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.ttlHours`, and `channels.discord.threadBindings.spawnSubagentSessions`.
- Discord (currently the only supported channel): supports persistent thread-bound subagent sessions (`sessions_spawn` with `thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`), and adapter keys `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.idleHours`, `channels.discord.threadBindings.maxAgeHours`, and `channels.discord.threadBindings.spawnSubagentSessions`.
Quick flow:
1. Spawn with `sessions_spawn` using `thread: true` (and optionally `mode: "session"`).
2. OpenClaw creates or binds a thread to that session target in the active channel.
3. Replies and follow-up messages in that thread route to the bound session.
4. Use `/session ttl` to inspect/update auto-unfocus TTL.
4. Use `/session idle` to inspect/update inactivity auto-unfocus and `/session max-age` to control the hard cap.
5. Use `/unfocus` to detach manually.
Manual controls:
@@ -108,11 +109,11 @@ Manual controls:
- `/focus <target>` binds the current thread (or creates one) to a sub-agent/session target.
- `/unfocus` removes the binding for the current bound thread.
- `/agents` lists active runs and binding state (`thread:<id>` or `unbound`).
- `/session ttl` only works for focused bound threads.
- `/session idle` and `/session max-age` only work for focused bound threads.
Config switches:
- Global default: `session.threadBindings.enabled`, `session.threadBindings.ttlHours`
- Global default: `session.threadBindings.enabled`, `session.threadBindings.idleHours`, `session.threadBindings.maxAgeHours`
- Channel override and spawn auto-bind keys are adapter-specific. See **Thread supporting channels** above.
See [Configuration Reference](/gateway/configuration-reference) and [Slash commands](/tools/slash-commands) for current adapter details.

View File

@@ -265,15 +265,15 @@ function buildChatCommands(): ChatCommandDefinition[] {
defineChatCommand({
key: "session",
nativeName: "session",
description: "Manage session-level settings (for example /session ttl).",
description: "Manage session-level settings (for example /session idle).",
textAlias: "/session",
category: "session",
args: [
{
name: "action",
description: "ttl",
description: "idle | max-age",
type: "string",
choices: ["ttl"],
choices: ["idle", "max-age"],
},
{
name: "value",

View File

@@ -0,0 +1,198 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
const hoisted = vi.hoisted(() => {
const getThreadBindingManagerMock = vi.fn();
const setThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
const setThreadBindingMaxAgeBySessionKeyMock = vi.fn();
return {
getThreadBindingManagerMock,
setThreadBindingIdleTimeoutBySessionKeyMock,
setThreadBindingMaxAgeBySessionKeyMock,
};
});
vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../discord/monitor/thread-bindings.js")>();
return {
...actual,
getThreadBindingManager: hoisted.getThreadBindingManagerMock,
setThreadBindingIdleTimeoutBySessionKey: hoisted.setThreadBindingIdleTimeoutBySessionKeyMock,
setThreadBindingMaxAgeBySessionKey: hoisted.setThreadBindingMaxAgeBySessionKeyMock,
};
});
const { handleSessionCommand } = await import("./commands-session.js");
const { buildCommandTestParams } = await import("./commands.test-harness.js");
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
type FakeBinding = {
accountId: string;
channelId: string;
threadId: string;
targetKind: "subagent" | "acp";
targetSessionKey: string;
agentId: string;
boundBy: string;
boundAt: number;
lastActivityAt: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
};
function createDiscordCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
return buildCommandTestParams(commandBody, baseCfg, {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
OriginatingTo: "channel:thread-1",
AccountId: "default",
MessageThreadId: "thread-1",
...overrides,
});
}
function createFakeBinding(overrides: Partial<FakeBinding> = {}): FakeBinding {
const now = Date.now();
return {
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
boundBy: "user-1",
boundAt: now,
lastActivityAt: now,
...overrides,
};
}
function createFakeThreadBindingManager(binding: FakeBinding | null) {
return {
getByThreadId: vi.fn((_threadId: string) => binding),
getIdleTimeoutMs: vi.fn(() => 24 * 60 * 60 * 1000),
getMaxAgeMs: vi.fn(() => 0),
};
}
describe("/session idle and /session max-age", () => {
beforeEach(() => {
hoisted.getThreadBindingManagerMock.mockClear();
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockClear();
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockClear();
vi.useRealTimers();
});
it("sets idle timeout for the focused session", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const binding = createFakeBinding();
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
{
...binding,
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(createDiscordCommandParams("/session idle 2h"), true);
const text = result?.reply?.text ?? "";
expect(hoisted.setThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
idleTimeoutMs: 2 * 60 * 60 * 1000,
});
expect(text).toContain("Idle timeout set to 2h");
expect(text).toContain("2026-02-20T02:00:00.000Z");
});
it("shows active idle timeout when no value is provided", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const binding = createFakeBinding({
idleTimeoutMs: 2 * 60 * 60 * 1000,
lastActivityAt: Date.now(),
});
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
const result = await handleSessionCommand(createDiscordCommandParams("/session idle"), true);
expect(result?.reply?.text).toContain("Idle timeout active (2h");
expect(result?.reply?.text).toContain("2026-02-20T02:00:00.000Z");
});
it("sets max age for the focused session", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const binding = createFakeBinding();
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
{
...binding,
boundAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createDiscordCommandParams("/session max-age 3h"),
true,
);
const text = result?.reply?.text ?? "";
expect(hoisted.setThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
maxAgeMs: 3 * 60 * 60 * 1000,
});
expect(text).toContain("Max age set to 3h");
expect(text).toContain("2026-02-20T03:00:00.000Z");
});
it("disables max age when set to off", async () => {
const binding = createFakeBinding({ maxAgeMs: 2 * 60 * 60 * 1000 });
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([{ ...binding, maxAgeMs: 0 }]);
const result = await handleSessionCommand(
createDiscordCommandParams("/session max-age off"),
true,
);
expect(hoisted.setThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
maxAgeMs: 0,
});
expect(result?.reply?.text).toContain("Max age disabled");
});
it("is unavailable outside discord", async () => {
const params = buildCommandTestParams("/session idle 2h", baseCfg);
const result = await handleSessionCommand(params, true);
expect(result?.reply?.text).toContain("currently available for Discord thread-bound sessions");
});
it("requires binding owner for lifecycle updates", async () => {
const binding = createFakeBinding({ boundBy: "owner-1" });
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
const result = await handleSessionCommand(
createDiscordCommandParams("/session idle 2h", {
SenderId: "other-user",
}),
true,
);
expect(hoisted.setThreadBindingIdleTimeoutBySessionKeyMock).not.toHaveBeenCalled();
expect(result?.reply?.text).toContain("Only owner-1 can update session lifecycle settings");
});
});

View File

@@ -1,147 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
const hoisted = vi.hoisted(() => {
const getThreadBindingManagerMock = vi.fn();
const setThreadBindingTtlBySessionKeyMock = vi.fn();
return {
getThreadBindingManagerMock,
setThreadBindingTtlBySessionKeyMock,
};
});
vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../discord/monitor/thread-bindings.js")>();
return {
...actual,
getThreadBindingManager: hoisted.getThreadBindingManagerMock,
setThreadBindingTtlBySessionKey: hoisted.setThreadBindingTtlBySessionKeyMock,
};
});
const { handleSessionCommand } = await import("./commands-session.js");
const { buildCommandTestParams } = await import("./commands.test-harness.js");
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
type FakeBinding = {
threadId: string;
targetSessionKey: string;
expiresAt?: number;
boundBy?: string;
};
function createDiscordCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
return buildCommandTestParams(commandBody, baseCfg, {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
OriginatingTo: "channel:thread-1",
AccountId: "default",
MessageThreadId: "thread-1",
...overrides,
});
}
function createFakeThreadBindingManager(binding: FakeBinding | null) {
return {
getByThreadId: vi.fn((_threadId: string) => binding),
};
}
describe("/session ttl", () => {
beforeEach(() => {
hoisted.getThreadBindingManagerMock.mockClear();
hoisted.setThreadBindingTtlBySessionKeyMock.mockClear();
vi.useRealTimers();
});
it("sets ttl for the focused session", async () => {
const binding: FakeBinding = {
threadId: "thread-1",
targetSessionKey: "agent:main:subagent:child",
};
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
hoisted.setThreadBindingTtlBySessionKeyMock.mockReturnValue([
{
...binding,
boundAt: Date.now(),
expiresAt: new Date("2026-02-21T02:00:00.000Z").getTime(),
},
]);
const result = await handleSessionCommand(createDiscordCommandParams("/session ttl 2h"), true);
const text = result?.reply?.text ?? "";
expect(hoisted.setThreadBindingTtlBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
ttlMs: 2 * 60 * 60 * 1000,
});
expect(text).toContain("Session TTL set to 2h");
expect(text).toContain("2026-02-21T02:00:00.000Z");
});
it("shows active ttl when no value is provided", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const binding: FakeBinding = {
threadId: "thread-1",
targetSessionKey: "agent:main:subagent:child",
expiresAt: new Date("2026-02-20T02:00:00.000Z").getTime(),
};
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
const result = await handleSessionCommand(createDiscordCommandParams("/session ttl"), true);
expect(result?.reply?.text).toContain("Session TTL active (2h");
});
it("disables ttl when set to off", async () => {
const binding: FakeBinding = {
threadId: "thread-1",
targetSessionKey: "agent:main:subagent:child",
expiresAt: new Date("2026-02-20T02:00:00.000Z").getTime(),
};
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
hoisted.setThreadBindingTtlBySessionKeyMock.mockReturnValue([
{ ...binding, boundAt: Date.now(), expiresAt: undefined },
]);
const result = await handleSessionCommand(createDiscordCommandParams("/session ttl off"), true);
expect(hoisted.setThreadBindingTtlBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
ttlMs: 0,
});
expect(result?.reply?.text).toContain("Session TTL disabled");
});
it("is unavailable outside discord", async () => {
const params = buildCommandTestParams("/session ttl 2h", baseCfg);
const result = await handleSessionCommand(params, true);
expect(result?.reply?.text).toContain("currently available for Discord thread-bound sessions");
});
it("requires binding owner for ttl updates", async () => {
const binding: FakeBinding = {
threadId: "thread-1",
targetSessionKey: "agent:main:subagent:child",
boundBy: "owner-1",
};
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));
const result = await handleSessionCommand(
createDiscordCommandParams("/session ttl 2h", {
SenderId: "other-user",
}),
true,
);
expect(hoisted.setThreadBindingTtlBySessionKeyMock).not.toHaveBeenCalled();
expect(result?.reply?.text).toContain("Only owner-1 can update session TTL");
});
});

View File

@@ -4,9 +4,14 @@ import { isRestartEnabled } from "../../config/commands.js";
import type { SessionEntry } from "../../config/sessions.js";
import { updateSessionStore } from "../../config/sessions.js";
import {
formatThreadBindingTtlLabel,
formatThreadBindingDurationLabel,
getThreadBindingManager,
setThreadBindingTtlBySessionKey,
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingInactivityExpiresAt,
resolveThreadBindingMaxAgeExpiresAt,
resolveThreadBindingMaxAgeMs,
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
} from "../../discord/monitor/thread-bindings.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
@@ -48,7 +53,9 @@ function resolveAbortTarget(params: {
}
const SESSION_COMMAND_PREFIX = "/session";
const SESSION_TTL_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]);
const SESSION_DURATION_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]);
const SESSION_ACTION_IDLE = "idle";
const SESSION_ACTION_MAX_AGE = "max-age";
function isDiscordSurface(params: Parameters<CommandHandler>[0]): boolean {
const channel =
@@ -69,21 +76,21 @@ function resolveDiscordAccountId(params: Parameters<CommandHandler>[0]): string
}
function resolveSessionCommandUsage() {
return "Usage: /session ttl <duration|off> (example: /session ttl 24h)";
return "Usage: /session idle <duration|off> | /session max-age <duration|off> (example: /session idle 24h)";
}
function parseSessionTtlMs(raw: string): number {
function parseSessionDurationMs(raw: string): number {
const normalized = raw.trim().toLowerCase();
if (!normalized) {
throw new Error("missing ttl");
throw new Error("missing duration");
}
if (SESSION_TTL_OFF_VALUES.has(normalized)) {
if (SESSION_DURATION_OFF_VALUES.has(normalized)) {
return 0;
}
if (/^\d+(?:\.\d+)?$/.test(normalized)) {
const hours = Number(normalized);
if (!Number.isFinite(hours) || hours < 0) {
throw new Error("invalid ttl");
throw new Error("invalid duration");
}
return Math.round(hours * 60 * 60 * 1000);
}
@@ -93,7 +100,6 @@ function parseSessionTtlMs(raw: string): number {
function formatSessionExpiry(expiresAt: number) {
return new Date(expiresAt).toISOString();
}
async function applyAbortTarget(params: {
abortTarget: ReturnType<typeof resolveAbortTarget>;
sessionStore?: Record<string, SessionEntry>;
@@ -315,7 +321,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
const rest = normalized.slice(SESSION_COMMAND_PREFIX.length).trim();
const tokens = rest.split(/\s+/).filter(Boolean);
const action = tokens[0]?.toLowerCase();
if (action !== "ttl") {
if (action !== SESSION_ACTION_IDLE && action !== SESSION_ACTION_MAX_AGE) {
return {
shouldContinue: false,
reply: { text: resolveSessionCommandUsage() },
@@ -325,7 +331,9 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
if (!isDiscordSurface(params)) {
return {
shouldContinue: false,
reply: { text: "⚠️ /session ttl is currently available for Discord thread-bound sessions." },
reply: {
text: "⚠️ /session idle and /session max-age are currently available for Discord thread-bound sessions.",
},
};
}
@@ -334,7 +342,9 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
if (!threadId) {
return {
shouldContinue: false,
reply: { text: "⚠️ /session ttl must be run inside a focused Discord thread." },
reply: {
text: "⚠️ /session idle and /session max-age must be run inside a focused Discord thread.",
},
};
}
@@ -355,20 +365,59 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
};
}
const ttlArgRaw = tokens.slice(1).join("");
if (!ttlArgRaw) {
const expiresAt = binding.expiresAt;
if (typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > Date.now()) {
const idleTimeoutMs = resolveThreadBindingIdleTimeoutMs({
record: binding,
defaultIdleTimeoutMs: threadBindings.getIdleTimeoutMs(),
});
const idleExpiresAt = resolveThreadBindingInactivityExpiresAt({
record: binding,
defaultIdleTimeoutMs: threadBindings.getIdleTimeoutMs(),
});
const maxAgeMs = resolveThreadBindingMaxAgeMs({
record: binding,
defaultMaxAgeMs: threadBindings.getMaxAgeMs(),
});
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
record: binding,
defaultMaxAgeMs: threadBindings.getMaxAgeMs(),
});
const durationArgRaw = tokens.slice(1).join("");
if (!durationArgRaw) {
if (action === SESSION_ACTION_IDLE) {
if (
typeof idleExpiresAt === "number" &&
Number.isFinite(idleExpiresAt) &&
idleExpiresAt > Date.now()
) {
return {
shouldContinue: false,
reply: {
text: ` Idle timeout active (${formatThreadBindingDurationLabel(idleTimeoutMs)}, next auto-unfocus at ${formatSessionExpiry(idleExpiresAt)}).`,
},
};
}
return {
shouldContinue: false,
reply: { text: " Idle timeout is currently disabled for this focused session." },
};
}
if (
typeof maxAgeExpiresAt === "number" &&
Number.isFinite(maxAgeExpiresAt) &&
maxAgeExpiresAt > Date.now()
) {
return {
shouldContinue: false,
reply: {
text: ` Session TTL active (${formatThreadBindingTtlLabel(expiresAt - Date.now())}, auto-unfocus at ${formatSessionExpiry(expiresAt)}).`,
text: ` Max age active (${formatThreadBindingDurationLabel(maxAgeMs)}, hard auto-unfocus at ${formatSessionExpiry(maxAgeExpiresAt)}).`,
},
};
}
return {
shouldContinue: false,
reply: { text: " Session TTL is currently disabled for this focused session." },
reply: { text: " Max age is currently disabled for this focused session." },
};
}
@@ -376,13 +425,15 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
if (binding.boundBy && binding.boundBy !== "system" && senderId && senderId !== binding.boundBy) {
return {
shouldContinue: false,
reply: { text: `⚠️ Only ${binding.boundBy} can update session TTL for this thread.` },
reply: {
text: `⚠️ Only ${binding.boundBy} can update session lifecycle settings for this thread.`,
},
};
}
let ttlMs: number;
let durationMs: number;
try {
ttlMs = parseSessionTtlMs(ttlArgRaw);
durationMs = parseSessionDurationMs(durationArgRaw);
} catch {
return {
shouldContinue: false,
@@ -390,40 +441,68 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
};
}
const updatedBindings = setThreadBindingTtlBySessionKey({
targetSessionKey: binding.targetSessionKey,
accountId,
ttlMs,
});
const updatedBindings =
action === SESSION_ACTION_IDLE
? setThreadBindingIdleTimeoutBySessionKey({
targetSessionKey: binding.targetSessionKey,
accountId,
idleTimeoutMs: durationMs,
})
: setThreadBindingMaxAgeBySessionKey({
targetSessionKey: binding.targetSessionKey,
accountId,
maxAgeMs: durationMs,
});
if (updatedBindings.length === 0) {
return {
shouldContinue: false,
reply: { text: "⚠️ Failed to update session TTL for the current binding." },
};
}
if (ttlMs <= 0) {
return {
shouldContinue: false,
reply: {
text: `✅ Session TTL disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`,
text:
action === SESSION_ACTION_IDLE
? "⚠️ Failed to update idle timeout for the current binding."
: "⚠️ Failed to update max age for the current binding.",
},
};
}
const expiresAt = updatedBindings[0]?.expiresAt;
if (durationMs <= 0) {
return {
shouldContinue: false,
reply: {
text:
action === SESSION_ACTION_IDLE
? `✅ Idle timeout disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`
: `✅ Max age disabled for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"}.`,
},
};
}
const nextBinding = updatedBindings[0];
const nextExpiry =
action === SESSION_ACTION_IDLE
? resolveThreadBindingInactivityExpiresAt({
record: nextBinding,
defaultIdleTimeoutMs: threadBindings.getIdleTimeoutMs(),
})
: resolveThreadBindingMaxAgeExpiresAt({
record: nextBinding,
defaultMaxAgeMs: threadBindings.getMaxAgeMs(),
});
const expiryLabel =
typeof expiresAt === "number" && Number.isFinite(expiresAt)
? formatSessionExpiry(expiresAt)
typeof nextExpiry === "number" && Number.isFinite(nextExpiry)
? formatSessionExpiry(nextExpiry)
: "n/a";
return {
shouldContinue: false,
reply: {
text: `✅ Session TTL set to ${formatThreadBindingTtlLabel(ttlMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (auto-unfocus at ${expiryLabel}).`,
text:
action === SESSION_ACTION_IDLE
? `✅ Idle timeout set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (next auto-unfocus at ${expiryLabel}).`
: `✅ Max age set to ${formatThreadBindingDurationLabel(durationMs)} for ${updatedBindings.length} binding${updatedBindings.length === 1 ? "" : "s"} (hard auto-unfocus at ${expiryLabel}).`,
},
};
};
export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;

View File

@@ -74,7 +74,8 @@ function createFakeThreadBindingManager(initialBindings: FakeBinding[] = []) {
);
const manager = {
getSessionTtlMs: vi.fn(() => 24 * 60 * 60 * 1000),
getIdleTimeoutMs: vi.fn(() => 24 * 60 * 60 * 1000),
getMaxAgeMs: vi.fn(() => 0),
getByThreadId: vi.fn((threadId: string) => byThread.get(threadId)),
listBySessionKey: vi.fn((targetSessionKey: string) =>
[...byThread.values()].filter((binding) => binding.targetSessionKey === targetSessionKey),
@@ -189,7 +190,7 @@ describe("/focus, /unfocus, /agents", () => {
targetKind: "acp",
targetSessionKey: "agent:codex-acp:session-1",
introText:
"🤖 codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.",
"🤖 codex-acp session active (idle auto-unfocus after 24h inactivity). Messages here go directly to this session.",
}),
);
});

View File

@@ -75,7 +75,8 @@ export async function handleSubagentsFocusAction(
introText: resolveThreadBindingIntroText({
agentId: focusTarget.agentId,
label,
sessionTtlMs: threadBindings.getSessionTtlMs(),
idleTimeoutMs: threadBindings.getIdleTimeoutMs(),
maxAgeMs: threadBindings.getMaxAgeMs(),
}),
});

View File

@@ -372,7 +372,8 @@ export function buildSubagentsHelp() {
"- /focus <subagent-label|session-key|session-id|session-label>",
"- /unfocus",
"- /agents",
"- /session ttl <duration|off>",
"- /session idle <duration|off>",
"- /session max-age <duration|off>",
"- /kill <id|#|all>",
"- /steer <id|#> <message>",
"- /tell <id|#> <message>",

View File

@@ -143,7 +143,8 @@ const TARGET_KEYS = [
"session.agentToAgent.maxPingPongTurns",
"session.threadBindings",
"session.threadBindings.enabled",
"session.threadBindings.ttlHours",
"session.threadBindings.idleHours",
"session.threadBindings.maxAgeHours",
"session.maintenance",
"session.maintenance.mode",
"session.maintenance.pruneAfter",

View File

@@ -970,8 +970,10 @@ export const FIELD_HELP: Record<string, string> = {
"Shared defaults for thread-bound session routing behavior across providers that support thread focus workflows. Configure global defaults here and override per channel only when behavior differs.",
"session.threadBindings.enabled":
"Global master switch for thread-bound session routing features and focused thread delivery behavior. Keep enabled for modern thread workflows unless you need to disable thread binding globally.",
"session.threadBindings.ttlHours":
"Default auto-unfocus TTL in hours for thread-bound sessions across providers/channels (0 disables). Keep 24h-like values for practical focus windows unless your team needs longer-lived thread binding.",
"session.threadBindings.idleHours":
"Default inactivity window in hours for thread-bound sessions across providers/channels (0 disables idle auto-unfocus). Default: 24.",
"session.threadBindings.maxAgeHours":
"Optional hard max age in hours for thread-bound sessions across providers/channels (0 disables hard cap). Default: 0.",
"session.maintenance":
"Automatic session-store maintenance controls for pruning age, entry caps, and file rotation behavior. Start in warn mode to observe impact, then enforce once thresholds are tuned.",
"session.maintenance.mode":
@@ -1311,8 +1313,10 @@ export const FIELD_HELP: Record<string, string> = {
"channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).",
"channels.discord.threadBindings.enabled":
"Enable Discord thread binding features (/focus, bound-thread routing/delivery, and thread-bound subagent sessions). Overrides session.threadBindings.enabled when set.",
"channels.discord.threadBindings.ttlHours":
"Auto-unfocus TTL in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable (default: 24). Overrides session.threadBindings.ttlHours when set.",
"channels.discord.threadBindings.idleHours":
"Inactivity window in hours for Discord thread-bound sessions (/focus and spawned thread sessions). Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set.",
"channels.discord.threadBindings.maxAgeHours":
"Optional hard max age in hours for Discord thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.",
"channels.discord.threadBindings.spawnSubagentSessions":
"Allow subagent spawns with thread=true to auto-create and bind Discord threads (default: false; opt-in). Set true to enable thread-bound subagent spawns for this account/channel.",
"channels.discord.ui.components.accentColor":

View File

@@ -455,7 +455,8 @@ export const FIELD_LABELS: Record<string, string> = {
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
"session.threadBindings": "Session Thread Bindings",
"session.threadBindings.enabled": "Thread Binding Enabled",
"session.threadBindings.ttlHours": "Thread Binding TTL (hours)",
"session.threadBindings.idleHours": "Thread Binding Idle Timeout (hours)",
"session.threadBindings.maxAgeHours": "Thread Binding Max Age (hours)",
"session.maintenance": "Session Maintenance",
"session.maintenance.mode": "Session Maintenance Mode",
"session.maintenance.pruneAfter": "Session Prune After",
@@ -643,7 +644,8 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.discord.retry.jitter": "Discord Retry Jitter",
"channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message",
"channels.discord.threadBindings.enabled": "Discord Thread Binding Enabled",
"channels.discord.threadBindings.ttlHours": "Discord Thread Binding TTL (hours)",
"channels.discord.threadBindings.idleHours": "Discord Thread Binding Idle Timeout (hours)",
"channels.discord.threadBindings.maxAgeHours": "Discord Thread Binding Max Age (hours)",
"channels.discord.threadBindings.spawnSubagentSessions": "Discord Thread-Bound Subagent Spawn",
"channels.discord.ui.components.accentColor": "Discord Component Accent Color",
"channels.discord.intents.presence": "Discord Presence Intent",

View File

@@ -91,10 +91,15 @@ export type SessionThreadBindingsConfig = {
*/
enabled?: boolean;
/**
* Auto-unfocus TTL for thread-bound sessions (hours).
* Set to 0 to disable. Default: 24.
* Inactivity window for thread-bound sessions (hours).
* Session auto-unfocuses after this amount of idle time. Set to 0 to disable. Default: 24.
*/
ttlHours?: number;
idleHours?: number;
/**
* Optional hard max age for thread-bound sessions (hours).
* Session auto-unfocuses once this age is reached even if active. Set to 0 to disable. Default: 0.
*/
maxAgeHours?: number;
};
export type SessionConfig = {

View File

@@ -150,10 +150,15 @@ export type DiscordThreadBindingsConfig = {
*/
enabled?: boolean;
/**
* Auto-unfocus TTL for thread-bound sessions in hours.
* Set to 0 to disable TTL. Default: 24.
* Inactivity window for thread-bound sessions in hours.
* Session auto-unfocuses after this amount of idle time. Set to 0 to disable. Default: 24.
*/
ttlHours?: number;
idleHours?: number;
/**
* Optional hard max age for thread-bound sessions in hours.
* Session auto-unfocuses once this age is reached even if active. Set to 0 to disable. Default: 0.
*/
maxAgeHours?: number;
/**
* Allow `sessions_spawn({ thread: true })` to auto-create + bind Discord
* threads for subagent sessions. Default: false (opt-in).

View File

@@ -400,7 +400,8 @@ export const DiscordAccountSchema = z
threadBindings: z
.object({
enabled: z.boolean().optional(),
ttlHours: z.number().nonnegative().optional(),
idleHours: z.number().nonnegative().optional(),
maxAgeHours: z.number().nonnegative().optional(),
spawnSubagentSessions: z.boolean().optional(),
})
.strict()

View File

@@ -63,7 +63,8 @@ export const SessionSchema = z
threadBindings: z
.object({
enabled: z.boolean().optional(),
ttlHours: z.number().nonnegative().optional(),
idleHours: z.number().nonnegative().optional(),
maxAgeHours: z.number().nonnegative().optional(),
})
.strict()
.optional(),

View File

@@ -106,9 +106,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
mediaList.push(...forwardedMediaList);
const text = messageText;
if (!text) {
logVerbose(`discord: drop message ${message.id} (empty content)`);
logVerbose("discord: drop message " + message.id + " (empty content)");
return;
}
if (ctx.threadBinding?.threadId) {
threadBindings.touchThread({ threadId: ctx.threadBinding.threadId, persist: true });
}
const ackReaction = resolveAckReaction(cfg, route.agentId, {
channel: "discord",
accountId,

View File

@@ -179,7 +179,8 @@ function createBoundThreadBindingManager(params: {
}): ThreadBindingManager {
return {
accountId: params.accountId,
getSessionTtlMs: () => 24 * 60 * 60 * 1000,
getIdleTimeoutMs: () => 24 * 60 * 60 * 1000,
getMaxAgeMs: () => 0,
getByThreadId: (threadId: string) =>
threadId === params.threadId
? {
@@ -191,11 +192,15 @@ function createBoundThreadBindingManager(params: {
agentId: params.agentId,
boundBy: "system",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
}
: undefined,
getBySessionKey: () => undefined,
listBySessionKey: () => [],
listBindings: () => [],
touchThread: () => null,
bindTarget: async () => null,
unbindThread: () => null,
unbindBySessionKey: () => [],

View File

@@ -70,7 +70,7 @@ import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js";
import { resolveDiscordRestFetch } from "./rest-fetch.js";
import { createNoopThreadBindingManager, createThreadBindingManager } from "./thread-bindings.js";
import { formatThreadBindingTtlLabel } from "./thread-bindings.messages.js";
import { formatThreadBindingDurationLabel } from "./thread-bindings.messages.js";
export type MonitorDiscordOpts = {
token?: string;
@@ -102,9 +102,10 @@ function summarizeGuilds(entries?: Record<string, unknown>) {
return `${sample.join(", ")}${suffix}`;
}
const DEFAULT_THREAD_BINDING_TTL_HOURS = 24;
const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24;
const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0;
function normalizeThreadBindingTtlHours(raw: unknown): number | undefined {
function normalizeThreadBindingHours(raw: unknown): number | undefined {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return undefined;
}
@@ -114,15 +115,26 @@ function normalizeThreadBindingTtlHours(raw: unknown): number | undefined {
return raw;
}
function resolveThreadBindingSessionTtlMs(params: {
channelTtlHoursRaw: unknown;
sessionTtlHoursRaw: unknown;
function resolveThreadBindingIdleTimeoutMs(params: {
channelIdleHoursRaw: unknown;
sessionIdleHoursRaw: unknown;
}): number {
const ttlHours =
normalizeThreadBindingTtlHours(params.channelTtlHoursRaw) ??
normalizeThreadBindingTtlHours(params.sessionTtlHoursRaw) ??
DEFAULT_THREAD_BINDING_TTL_HOURS;
return Math.floor(ttlHours * 60 * 60 * 1000);
const idleHours =
normalizeThreadBindingHours(params.channelIdleHoursRaw) ??
normalizeThreadBindingHours(params.sessionIdleHoursRaw) ??
DEFAULT_THREAD_BINDING_IDLE_HOURS;
return Math.floor(idleHours * 60 * 60 * 1000);
}
function resolveThreadBindingMaxAgeMs(params: {
channelMaxAgeHoursRaw: unknown;
sessionMaxAgeHoursRaw: unknown;
}): number {
const maxAgeHours =
normalizeThreadBindingHours(params.channelMaxAgeHoursRaw) ??
normalizeThreadBindingHours(params.sessionMaxAgeHoursRaw) ??
DEFAULT_THREAD_BINDING_MAX_AGE_HOURS;
return Math.floor(maxAgeHours * 60 * 60 * 1000);
}
function normalizeThreadBindingsEnabled(raw: unknown): boolean | undefined {
@@ -143,8 +155,8 @@ function resolveThreadBindingsEnabled(params: {
);
}
function formatThreadBindingSessionTtlLabel(ttlMs: number): string {
const label = formatThreadBindingTtlLabel(ttlMs);
function formatThreadBindingDurationForConfigLabel(durationMs: number): string {
const label = formatThreadBindingDurationLabel(durationMs);
return label === "disabled" ? "off" : label;
}
@@ -278,10 +290,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off";
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicy = discordCfg.dmPolicy ?? dmConfig?.policy ?? "pairing";
const threadBindingSessionTtlMs = resolveThreadBindingSessionTtlMs({
channelTtlHoursRaw:
discordAccountThreadBindings?.ttlHours ?? discordRootThreadBindings?.ttlHours,
sessionTtlHoursRaw: cfg.session?.threadBindings?.ttlHours,
const threadBindingIdleTimeoutMs = resolveThreadBindingIdleTimeoutMs({
channelIdleHoursRaw:
discordAccountThreadBindings?.idleHours ?? discordRootThreadBindings?.idleHours,
sessionIdleHoursRaw: cfg.session?.threadBindings?.idleHours,
});
const threadBindingMaxAgeMs = resolveThreadBindingMaxAgeMs({
channelMaxAgeHoursRaw:
discordAccountThreadBindings?.maxAgeHours ?? discordRootThreadBindings?.maxAgeHours,
sessionMaxAgeHoursRaw: cfg.session?.threadBindings?.maxAgeHours,
});
const threadBindingsEnabled = resolveThreadBindingsEnabled({
channelEnabledRaw: discordAccountThreadBindings?.enabled ?? discordRootThreadBindings?.enabled,
@@ -321,7 +338,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
if (shouldLogVerbose()) {
logVerbose(
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"} threadBindings=${threadBindingsEnabled ? "on" : "off"} threadSessionTtl=${formatThreadBindingSessionTtlLabel(threadBindingSessionTtlMs)}`,
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"} threadBindings=${threadBindingsEnabled ? "on" : "off"} threadIdleTimeout=${formatThreadBindingDurationForConfigLabel(threadBindingIdleTimeoutMs)} threadMaxAge=${formatThreadBindingDurationForConfigLabel(threadBindingMaxAgeMs)}`,
);
}
@@ -360,7 +377,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
? createThreadBindingManager({
accountId: account.accountId,
token,
sessionTtlMs: threadBindingSessionTtlMs,
idleTimeoutMs: threadBindingIdleTimeoutMs,
maxAgeMs: threadBindingMaxAgeMs,
})
: createNoopThreadBindingManager(account.accountId);
let lifecycleStarted = false;

View File

@@ -193,6 +193,31 @@ describe("deliverDiscordReply", () => {
expect(sendMessageDiscordMock).not.toHaveBeenCalled();
});
it("touches bound-thread activity after outbound delivery", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const threadBindings = await createBoundThreadBindings();
vi.setSystemTime(new Date("2026-02-20T00:02:00.000Z"));
await deliverDiscordReply({
replies: [{ text: "Activity ping" }],
target: "channel:thread-1",
token: "token",
runtime,
textLimit: 2000,
sessionKey: "agent:main:subagent:child",
threadBindings,
});
expect(threadBindings.getByThreadId("thread-1")?.lastActivityAt).toBe(
new Date("2026-02-20T00:02:00.000Z").getTime(),
);
} finally {
vi.useRealTimers();
}
});
it("falls back to bot send when webhook delivery fails", async () => {
const threadBindings = await createBoundThreadBindings();
sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited"));

View File

@@ -161,6 +161,7 @@ export async function deliverDiscordReply(params: {
target: params.target,
});
const persona = resolveBindingPersona(binding);
let deliveredAny = false;
for (const payload of params.replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const rawText = payload.text ?? "";
@@ -195,6 +196,7 @@ export async function deliverDiscordReply(params: {
username: persona.username,
avatarUrl: persona.avatarUrl,
});
deliveredAny = true;
}
continue;
}
@@ -213,6 +215,7 @@ export async function deliverDiscordReply(params: {
accountId: params.accountId,
replyTo,
});
deliveredAny = true;
// Voice messages cannot include text; send remaining text separately if present.
await sendDiscordChunkWithFallback({
target: params.target,
@@ -245,6 +248,7 @@ export async function deliverDiscordReply(params: {
accountId: params.accountId,
replyTo,
});
deliveredAny = true;
await sendAdditionalDiscordMedia({
target: params.target,
token: params.token,
@@ -254,4 +258,8 @@ export async function deliverDiscordReply(params: {
resolveReplyTo,
});
}
if (binding && deliveredAny) {
params.threadBindings?.touchThread({ threadId: binding.threadId, persist: true });
}
}

View File

@@ -49,12 +49,15 @@ const {
__testing,
autoBindSpawnedDiscordSubagent,
createThreadBindingManager,
resolveThreadBindingInactivityExpiresAt,
resolveThreadBindingIntroText,
setThreadBindingTtlBySessionKey,
resolveThreadBindingMaxAgeExpiresAt,
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
unbindThreadBindingsBySessionKey,
} = await import("./thread-bindings.js");
describe("thread binding ttl", () => {
describe("thread binding lifecycle", () => {
beforeEach(() => {
__testing.resetThreadBindingsForTests();
hoisted.sendMessageDiscord.mockClear();
@@ -71,7 +74,8 @@ describe("thread binding ttl", () => {
accountId: "default",
persist: false,
enableSweeper: true,
sessionTtlMs: 24 * 60 * 60 * 1000,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
const bindDefaultThreadTarget = async (
@@ -88,23 +92,26 @@ describe("thread binding ttl", () => {
});
};
it("includes ttl in intro text", () => {
it("includes idle and max-age details in intro text", () => {
const intro = resolveThreadBindingIntroText({
agentId: "main",
label: "worker",
sessionTtlMs: 24 * 60 * 60 * 1000,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 48 * 60 * 60 * 1000,
});
expect(intro).toContain("auto-unfocus in 24h");
expect(intro).toContain("idle auto-unfocus after 24h inactivity");
expect(intro).toContain("max age 48h");
});
it("auto-unfocuses expired bindings and sends a ttl-expired message", async () => {
it("auto-unfocuses idle-expired bindings and sends inactivity message", async () => {
vi.useFakeTimers();
try {
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: true,
sessionTtlMs: 60_000,
idleTimeoutMs: 60_000,
maxAgeMs: 0,
});
const binding = await manager.bindTarget({
@@ -128,7 +135,41 @@ describe("thread binding ttl", () => {
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1);
const farewell = hoisted.sendMessageDiscord.mock.calls[0]?.[1] as string | undefined;
expect(farewell).toContain("Session ended automatically after 1m");
expect(farewell).toContain("after 1m of inactivity");
} finally {
vi.useRealTimers();
}
});
it("auto-unfocuses max-age-expired bindings and sends max-age message", async () => {
vi.useFakeTimers();
try {
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: true,
idleTimeoutMs: 0,
maxAgeMs: 60_000,
});
const binding = await manager.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
webhookId: "wh-1",
webhookToken: "tok-1",
});
expect(binding).not.toBeNull();
hoisted.sendMessageDiscord.mockClear();
await vi.advanceTimersByTimeAsync(120_000);
expect(manager.getByThreadId("thread-1")).toBeUndefined();
expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1);
const farewell = hoisted.sendMessageDiscord.mock.calls[0]?.[1] as string | undefined;
expect(farewell).toContain("max age of 1m");
} finally {
vi.useRealTimers();
}
@@ -171,7 +212,7 @@ describe("thread binding ttl", () => {
}
});
it("updates ttl by target session key", async () => {
it("updates idle timeout by target session key", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-02-20T23:00:00.000Z"));
@@ -179,7 +220,8 @@ describe("thread binding ttl", () => {
accountId: "default",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
await manager.bindTarget({
@@ -191,33 +233,80 @@ describe("thread binding ttl", () => {
webhookId: "wh-1",
webhookToken: "tok-1",
});
const boundAt = manager.getByThreadId("thread-1")?.boundAt;
vi.setSystemTime(new Date("2026-02-20T23:15:00.000Z"));
const updated = setThreadBindingTtlBySessionKey({
const updated = setThreadBindingIdleTimeoutBySessionKey({
accountId: "default",
targetSessionKey: "agent:main:subagent:child",
ttlMs: 2 * 60 * 60 * 1000,
idleTimeoutMs: 2 * 60 * 60 * 1000,
});
expect(updated).toHaveLength(1);
expect(updated[0]?.boundAt).toBe(new Date("2026-02-20T23:15:00.000Z").getTime());
expect(updated[0]?.expiresAt).toBe(new Date("2026-02-21T01:15:00.000Z").getTime());
expect(manager.getByThreadId("thread-1")?.expiresAt).toBe(
new Date("2026-02-21T01:15:00.000Z").getTime(),
);
expect(updated[0]?.lastActivityAt).toBe(new Date("2026-02-20T23:15:00.000Z").getTime());
expect(updated[0]?.boundAt).toBe(boundAt);
expect(
resolveThreadBindingInactivityExpiresAt({
record: updated[0],
defaultIdleTimeoutMs: manager.getIdleTimeoutMs(),
}),
).toBe(new Date("2026-02-21T01:15:00.000Z").getTime());
} finally {
vi.useRealTimers();
}
});
it("keeps binding when ttl is disabled per session key", async () => {
it("updates max age by target session key", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-02-20T10:00:00.000Z"));
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
await manager.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
});
vi.setSystemTime(new Date("2026-02-20T10:30:00.000Z"));
const updated = setThreadBindingMaxAgeBySessionKey({
accountId: "default",
targetSessionKey: "agent:main:subagent:child",
maxAgeMs: 3 * 60 * 60 * 1000,
});
expect(updated).toHaveLength(1);
expect(updated[0]?.boundAt).toBe(new Date("2026-02-20T10:30:00.000Z").getTime());
expect(updated[0]?.lastActivityAt).toBe(new Date("2026-02-20T10:30:00.000Z").getTime());
expect(
resolveThreadBindingMaxAgeExpiresAt({
record: updated[0],
defaultMaxAgeMs: manager.getMaxAgeMs(),
}),
).toBe(new Date("2026-02-20T13:30:00.000Z").getTime());
} finally {
vi.useRealTimers();
}
});
it("keeps binding when idle timeout is disabled per session key", async () => {
vi.useFakeTimers();
try {
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: true,
sessionTtlMs: 60_000,
idleTimeoutMs: 60_000,
maxAgeMs: 0,
});
await manager.bindTarget({
@@ -230,19 +319,55 @@ describe("thread binding ttl", () => {
webhookToken: "tok-1",
});
const updated = setThreadBindingTtlBySessionKey({
const updated = setThreadBindingIdleTimeoutBySessionKey({
accountId: "default",
targetSessionKey: "agent:main:subagent:child",
ttlMs: 0,
idleTimeoutMs: 0,
});
expect(updated).toHaveLength(1);
expect(updated[0]?.expiresAt).toBe(0);
hoisted.sendWebhookMessageDiscord.mockClear();
expect(updated[0]?.idleTimeoutMs).toBe(0);
await vi.advanceTimersByTimeAsync(240_000);
expect(manager.getByThreadId("thread-1")).toBeDefined();
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("refreshes inactivity window when thread activity is touched", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const manager = createThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
idleTimeoutMs: 60_000,
maxAgeMs: 0,
});
await manager.bindTarget({
threadId: "thread-1",
channelId: "parent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
});
vi.setSystemTime(new Date("2026-02-20T00:00:30.000Z"));
const touched = manager.touchThread({ threadId: "thread-1", persist: false });
expect(touched).not.toBeNull();
const record = manager.getByThreadId("thread-1");
expect(record).toBeDefined();
expect(record?.lastActivityAt).toBe(new Date("2026-02-20T00:00:30.000Z").getTime());
expect(
resolveThreadBindingInactivityExpiresAt({
record: record!,
defaultIdleTimeoutMs: manager.getIdleTimeoutMs(),
}),
).toBe(new Date("2026-02-20T00:01:30.000Z").getTime());
} finally {
vi.useRealTimers();
}
@@ -253,7 +378,8 @@ describe("thread binding ttl", () => {
accountId: "default",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
const first = await manager.bindTarget({
@@ -289,7 +415,8 @@ describe("thread binding ttl", () => {
accountId: "default",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
await manager.bindTarget({
@@ -329,7 +456,8 @@ describe("thread binding ttl", () => {
accountId: "default",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
hoisted.restGet.mockClear();
@@ -365,7 +493,8 @@ describe("thread binding ttl", () => {
token: "runtime-token",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
hoisted.createDiscordRestClient.mockClear();
@@ -402,14 +531,16 @@ describe("thread binding ttl", () => {
token: "token-old",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
const manager = createThreadBindingManager({
accountId: "runtime",
token: "token-new",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
hoisted.createThreadDiscord.mockClear();
@@ -441,13 +572,15 @@ describe("thread binding ttl", () => {
accountId: "a",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
const b = createThreadBindingManager({
accountId: "b",
persist: false,
enableSweeper: false,
sessionTtlMs: 24 * 60 * 60 * 1000,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
const aBinding = await a.bindTarget({
@@ -503,7 +636,9 @@ describe("thread binding ttl", () => {
agentId: "main",
boundBy: "system",
boundAt: now,
expiresAt: now + 60_000,
lastActivityAt: now,
idleTimeoutMs: 60_000,
maxAgeMs: 0,
},
},
},

View File

@@ -11,7 +11,6 @@ import {
MANAGERS_BY_ACCOUNT_ID,
ensureBindingsLoaded,
getThreadBindingToken,
normalizeThreadBindingTtlMs,
normalizeThreadId,
rememberRecentUnboundWebhookEcho,
removeBindingRecord,
@@ -22,6 +21,13 @@ import {
} from "./thread-bindings.state.js";
import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.js";
function normalizeNonNegativeMs(raw: number): number {
if (!Number.isFinite(raw)) {
return 0;
}
return Math.max(0, Math.floor(raw));
}
function resolveBindingIdsForTargetSession(params: {
targetSessionKey: string;
accountId?: string;
@@ -131,7 +137,8 @@ export async function autoBindSpawnedDiscordSubagent(params: {
introText: resolveThreadBindingIntroText({
agentId: params.agentId,
label: params.label,
sessionTtlMs: manager.getSessionTtlMs(),
idleTimeoutMs: manager.getIdleTimeoutMs(),
maxAgeMs: manager.getMaxAgeMs(),
}),
});
}
@@ -181,18 +188,17 @@ export function unbindThreadBindingsBySessionKey(params: {
return removed;
}
export function setThreadBindingTtlBySessionKey(params: {
export function setThreadBindingIdleTimeoutBySessionKey(params: {
targetSessionKey: string;
accountId?: string;
ttlMs: number;
idleTimeoutMs: number;
}): ThreadBindingRecord[] {
const ids = resolveBindingIdsForTargetSession(params);
if (ids.length === 0) {
return [];
}
const ttlMs = normalizeThreadBindingTtlMs(params.ttlMs);
const idleTimeoutMs = normalizeNonNegativeMs(params.idleTimeoutMs);
const now = Date.now();
const expiresAt = ttlMs > 0 ? now + ttlMs : 0;
const updated: ThreadBindingRecord[] = [];
for (const bindingKey of ids) {
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
@@ -201,8 +207,40 @@ export function setThreadBindingTtlBySessionKey(params: {
}
const nextRecord: ThreadBindingRecord = {
...existing,
boundAt: now,
expiresAt,
idleTimeoutMs,
lastActivityAt: now,
};
setBindingRecord(nextRecord);
updated.push(nextRecord);
}
if (updated.length > 0 && shouldPersistBindingMutations()) {
saveBindingsToDisk({ force: true });
}
return updated;
}
export function setThreadBindingMaxAgeBySessionKey(params: {
targetSessionKey: string;
accountId?: string;
maxAgeMs: number;
}): ThreadBindingRecord[] {
const ids = resolveBindingIdsForTargetSession(params);
if (ids.length === 0) {
return [];
}
const maxAgeMs = normalizeNonNegativeMs(params.maxAgeMs);
const now = Date.now();
const updated: ThreadBindingRecord[] = [];
for (const bindingKey of ids) {
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
if (!existing) {
continue;
}
const nextRecord: ThreadBindingRecord = {
...existing,
maxAgeMs,
boundAt: now,
lastActivityAt: now,
};
setBindingRecord(nextRecord);
updated.push(nextRecord);

View File

@@ -31,13 +31,16 @@ import {
ensureBindingsLoaded,
rememberThreadBindingToken,
normalizeTargetKind,
normalizeThreadBindingTtlMs,
normalizeThreadBindingDurationMs,
normalizeThreadId,
rememberRecentUnboundWebhookEcho,
removeBindingRecord,
resolveBindingIdsForSession,
resolveBindingRecordKey,
resolveThreadBindingExpiresAt,
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingInactivityExpiresAt,
resolveThreadBindingMaxAgeExpiresAt,
resolveThreadBindingMaxAgeMs,
resolveThreadBindingsPath,
saveBindingsToDisk,
setBindingRecord,
@@ -45,7 +48,8 @@ import {
resetThreadBindingsForTests,
} from "./thread-bindings.state.js";
import {
DEFAULT_THREAD_BINDING_TTL_MS,
DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS,
DEFAULT_THREAD_BINDING_MAX_AGE_MS,
THREAD_BINDINGS_SWEEP_INTERVAL_MS,
type ThreadBindingManager,
type ThreadBindingRecord,
@@ -62,15 +66,36 @@ function unregisterManager(accountId: string, manager: ThreadBindingManager) {
}
}
function resolveEffectiveBindingExpiresAt(params: {
record: ThreadBindingRecord;
defaultIdleTimeoutMs: number;
defaultMaxAgeMs: number;
}): number | undefined {
const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({
record: params.record,
defaultIdleTimeoutMs: params.defaultIdleTimeoutMs,
});
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
record: params.record,
defaultMaxAgeMs: params.defaultMaxAgeMs,
});
if (inactivityExpiresAt != null && maxAgeExpiresAt != null) {
return Math.min(inactivityExpiresAt, maxAgeExpiresAt);
}
return inactivityExpiresAt ?? maxAgeExpiresAt;
}
function createNoopManager(accountIdRaw?: string): ThreadBindingManager {
const accountId = normalizeAccountId(accountIdRaw);
return {
accountId,
getSessionTtlMs: () => DEFAULT_THREAD_BINDING_TTL_MS,
getIdleTimeoutMs: () => DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS,
getMaxAgeMs: () => DEFAULT_THREAD_BINDING_MAX_AGE_MS,
getByThreadId: () => undefined,
getBySessionKey: () => undefined,
listBySessionKey: () => [],
listBindings: () => [],
touchThread: () => null,
bindTarget: async () => null,
unbindThread: () => null,
unbindBySessionKey: () => [],
@@ -86,7 +111,10 @@ function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" {
return raw === "subagent" ? "subagent" : "acp";
}
function toSessionBindingRecord(record: ThreadBindingRecord): SessionBindingRecord {
function toSessionBindingRecord(
record: ThreadBindingRecord,
defaults: { idleTimeoutMs: number; maxAgeMs: number },
): SessionBindingRecord {
const bindingId =
resolveBindingRecordKey({
accountId: record.accountId,
@@ -104,13 +132,26 @@ function toSessionBindingRecord(record: ThreadBindingRecord): SessionBindingReco
},
status: "active",
boundAt: record.boundAt,
expiresAt: record.expiresAt,
expiresAt: resolveEffectiveBindingExpiresAt({
record,
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
defaultMaxAgeMs: defaults.maxAgeMs,
}),
metadata: {
agentId: record.agentId,
label: record.label,
webhookId: record.webhookId,
webhookToken: record.webhookToken,
boundBy: record.boundBy,
lastActivityAt: record.lastActivityAt,
idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({
record,
defaultIdleTimeoutMs: defaults.idleTimeoutMs,
}),
maxAgeMs: resolveThreadBindingMaxAgeMs({
record,
defaultMaxAgeMs: defaults.maxAgeMs,
}),
},
};
}
@@ -137,7 +178,8 @@ export function createThreadBindingManager(
token?: string;
persist?: boolean;
enableSweeper?: boolean;
sessionTtlMs?: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
} = {},
): ThreadBindingManager {
ensureBindingsLoaded();
@@ -152,14 +194,22 @@ export function createThreadBindingManager(
const persist = params.persist ?? shouldDefaultPersist();
PERSIST_BY_ACCOUNT_ID.set(accountId, persist);
const sessionTtlMs = normalizeThreadBindingTtlMs(params.sessionTtlMs);
const idleTimeoutMs = normalizeThreadBindingDurationMs(
params.idleTimeoutMs,
DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS,
);
const maxAgeMs = normalizeThreadBindingDurationMs(
params.maxAgeMs,
DEFAULT_THREAD_BINDING_MAX_AGE_MS,
);
const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token;
let sweepTimer: NodeJS.Timeout | null = null;
const manager: ThreadBindingManager = {
accountId,
getSessionTtlMs: () => sessionTtlMs,
getIdleTimeoutMs: () => idleTimeoutMs,
getMaxAgeMs: () => maxAgeMs,
getByThreadId: (threadId) => {
const key = resolveBindingRecordKey({
accountId,
@@ -189,6 +239,33 @@ export function createThreadBindingManager(
},
listBindings: () =>
[...BINDINGS_BY_THREAD_ID.values()].filter((entry) => entry.accountId === accountId),
touchThread: (touchParams) => {
const key = resolveBindingRecordKey({
accountId,
threadId: touchParams.threadId,
});
if (!key) {
return null;
}
const existing = BINDINGS_BY_THREAD_ID.get(key);
if (!existing || existing.accountId !== accountId) {
return null;
}
const now = Date.now();
const at =
typeof touchParams.at === "number" && Number.isFinite(touchParams.at)
? Math.max(0, Math.floor(touchParams.at))
: now;
const nextRecord: ThreadBindingRecord = {
...existing,
lastActivityAt: Math.max(existing.lastActivityAt || 0, at),
};
setBindingRecord(nextRecord);
if (touchParams.persist ?? persist) {
saveBindingsToDisk();
}
return nextRecord;
},
bindTarget: async (bindParams) => {
let threadId = normalizeThreadId(bindParams.threadId);
let channelId = bindParams.channelId?.trim() || "";
@@ -250,7 +327,7 @@ export function createThreadBindingManager(
webhookToken = createdWebhook.webhookToken ?? "";
}
const boundAt = Date.now();
const now = Date.now();
const record: ThreadBindingRecord = {
accountId,
channelId,
@@ -262,8 +339,10 @@ export function createThreadBindingManager(
webhookId: webhookId || undefined,
webhookToken: webhookToken || undefined,
boundBy: bindParams.boundBy?.trim() || "system",
boundAt,
expiresAt: sessionTtlMs > 0 ? boundAt + sessionTtlMs : undefined,
boundAt: now,
lastActivityAt: now,
idleTimeoutMs,
maxAgeMs,
};
setBindingRecord(record);
@@ -301,7 +380,14 @@ export function createThreadBindingManager(
const farewell = resolveThreadBindingFarewellText({
reason: unbindParams.reason,
farewellText: unbindParams.farewellText,
sessionTtlMs,
idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({
record: removed,
defaultIdleTimeoutMs: idleTimeoutMs,
}),
maxAgeMs: resolveThreadBindingMaxAgeMs({
record: removed,
defaultMaxAgeMs: maxAgeMs,
}),
});
// Use bot send path for farewell messages so unbound threads don't process
// webhook echoes as fresh inbound turns when allowBots is enabled.
@@ -367,19 +453,42 @@ export function createThreadBindingManager(
return;
}
for (const binding of bindings) {
const expiresAt = resolveThreadBindingExpiresAt({
const now = Date.now();
const inactivityExpiresAt = resolveThreadBindingInactivityExpiresAt({
record: binding,
sessionTtlMs,
defaultIdleTimeoutMs: idleTimeoutMs,
});
if (expiresAt != null && Date.now() >= expiresAt) {
const ttlFromBinding = Math.max(0, expiresAt - binding.boundAt);
const maxAgeExpiresAt = resolveThreadBindingMaxAgeExpiresAt({
record: binding,
defaultMaxAgeMs: maxAgeMs,
});
const expirationCandidates: Array<{
reason: "idle-expired" | "max-age-expired";
at: number;
}> = [];
if (inactivityExpiresAt != null && now >= inactivityExpiresAt) {
expirationCandidates.push({ reason: "idle-expired", at: inactivityExpiresAt });
}
if (maxAgeExpiresAt != null && now >= maxAgeExpiresAt) {
expirationCandidates.push({ reason: "max-age-expired", at: maxAgeExpiresAt });
}
if (expirationCandidates.length > 0) {
expirationCandidates.sort((a, b) => a.at - b.at);
const reason = expirationCandidates[0]?.reason ?? "idle-expired";
manager.unbindThread({
threadId: binding.threadId,
reason: "ttl-expired",
reason,
sendFarewell: true,
farewellText: resolveThreadBindingFarewellText({
reason: "ttl-expired",
sessionTtlMs: ttlFromBinding,
reason,
idleTimeoutMs: resolveThreadBindingIdleTimeoutMs({
record: binding,
defaultIdleTimeoutMs: idleTimeoutMs,
}),
maxAgeMs: resolveThreadBindingMaxAgeMs({
record: binding,
defaultMaxAgeMs: maxAgeMs,
}),
}),
});
continue;
@@ -458,19 +567,30 @@ export function createThreadBindingManager(
boundBy,
introText,
});
return bound ? toSessionBindingRecord(bound) : null;
return bound
? toSessionBindingRecord(bound, {
idleTimeoutMs,
maxAgeMs,
})
: null;
},
listBySession: (targetSessionKey) =>
manager.listBySessionKey(targetSessionKey).map(toSessionBindingRecord),
manager
.listBySessionKey(targetSessionKey)
.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })),
resolveByConversation: (ref) => {
if (ref.channel !== "discord") {
return null;
}
const binding = manager.getByThreadId(ref.conversationId);
return binding ? toSessionBindingRecord(binding) : null;
return binding ? toSessionBindingRecord(binding, { idleTimeoutMs, maxAgeMs }) : null;
},
touch: () => {
// Thread bindings are activity-touched by inbound/outbound message flows.
touch: (bindingId, at) => {
const threadId = resolveThreadIdFromBindingId({ accountId, bindingId });
if (!threadId) {
return;
}
manager.touchThread({ threadId, at, persist: true });
},
unbind: async (input) => {
if (input.targetSessionKey?.trim()) {
@@ -478,7 +598,7 @@ export function createThreadBindingManager(
targetSessionKey: input.targetSessionKey,
reason: input.reason,
});
return removed.map(toSessionBindingRecord);
return removed.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs }));
}
const threadId = resolveThreadIdFromBindingId({
accountId,
@@ -491,7 +611,7 @@ export function createThreadBindingManager(
threadId,
reason: input.reason,
});
return removed ? [toSessionBindingRecord(removed)] : [];
return removed ? [toSessionBindingRecord(removed, { idleTimeoutMs, maxAgeMs })] : [];
},
});

View File

@@ -1,24 +1,24 @@
import { DEFAULT_FAREWELL_TEXT, type ThreadBindingRecord } from "./thread-bindings.types.js";
function normalizeThreadBindingMessageTtlMs(raw: unknown): number {
function normalizeDurationMs(raw: unknown): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return 0;
}
const ttlMs = Math.floor(raw);
if (ttlMs < 0) {
const durationMs = Math.floor(raw);
if (durationMs < 0) {
return 0;
}
return ttlMs;
return durationMs;
}
export function formatThreadBindingTtlLabel(ttlMs: number): string {
if (ttlMs <= 0) {
export function formatThreadBindingDurationLabel(durationMs: number): string {
if (durationMs <= 0) {
return "disabled";
}
if (ttlMs < 60_000) {
if (durationMs < 60_000) {
return "<1m";
}
const totalMinutes = Math.floor(ttlMs / 60_000);
const totalMinutes = Math.floor(durationMs / 60_000);
if (totalMinutes % 60 === 0) {
return `${Math.floor(totalMinutes / 60)}h`;
}
@@ -38,29 +38,42 @@ export function resolveThreadBindingThreadName(params: {
export function resolveThreadBindingIntroText(params: {
agentId?: string;
label?: string;
sessionTtlMs?: number;
idleTimeoutMs?: number;
maxAgeMs?: number;
}): string {
const label = params.label?.trim();
const base = label || params.agentId?.trim() || "agent";
const normalized = base.replace(/\s+/g, " ").trim().slice(0, 100) || "agent";
const ttlMs = normalizeThreadBindingMessageTtlMs(params.sessionTtlMs);
if (ttlMs > 0) {
return `🤖 ${normalized} session active (auto-unfocus in ${formatThreadBindingTtlLabel(ttlMs)}). Messages here go directly to this session.`;
const idleTimeoutMs = normalizeDurationMs(params.idleTimeoutMs);
const maxAgeMs = normalizeDurationMs(params.maxAgeMs);
let lifecycleHint = "";
if (idleTimeoutMs > 0 && maxAgeMs > 0) {
lifecycleHint = ` (idle auto-unfocus after ${formatThreadBindingDurationLabel(idleTimeoutMs)} inactivity; max age ${formatThreadBindingDurationLabel(maxAgeMs)})`;
} else if (idleTimeoutMs > 0) {
lifecycleHint = ` (idle auto-unfocus after ${formatThreadBindingDurationLabel(idleTimeoutMs)} inactivity)`;
} else if (maxAgeMs > 0) {
lifecycleHint = ` (max age ${formatThreadBindingDurationLabel(maxAgeMs)})`;
}
return `🤖 ${normalized} session active. Messages here go directly to this session.`;
return `🤖 ${normalized} session active${lifecycleHint}. Messages here go directly to this session.`;
}
export function resolveThreadBindingFarewellText(params: {
reason?: string;
farewellText?: string;
sessionTtlMs: number;
idleTimeoutMs: number;
maxAgeMs: number;
}): string {
const custom = params.farewellText?.trim();
if (custom) {
return custom;
}
if (params.reason === "ttl-expired") {
return `Session ended automatically after ${formatThreadBindingTtlLabel(params.sessionTtlMs)}. Messages here will no longer be routed.`;
if (params.reason === "idle-expired") {
return `Session unfocused after ${formatThreadBindingDurationLabel(params.idleTimeoutMs)} of inactivity. Messages here will no longer be routed.`;
}
if (params.reason === "max-age-expired") {
return `Session unfocused after reaching max age of ${formatThreadBindingDurationLabel(params.maxAgeMs)}. Messages here will no longer be routed.`;
}
return DEFAULT_FAREWELL_TEXT;
}

View File

@@ -4,7 +4,8 @@ import { resolveStateDir } from "../../config/paths.js";
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import {
DEFAULT_THREAD_BINDING_TTL_MS,
DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS,
DEFAULT_THREAD_BINDING_MAX_AGE_MS,
RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS,
THREAD_BINDINGS_VERSION,
type PersistedThreadBindingRecord,
@@ -164,9 +165,17 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin
typeof value.boundAt === "number" && Number.isFinite(value.boundAt)
? Math.floor(value.boundAt)
: Date.now();
const expiresAt =
typeof value.expiresAt === "number" && Number.isFinite(value.expiresAt)
? Math.max(0, Math.floor(value.expiresAt))
const lastActivityAt =
typeof value.lastActivityAt === "number" && Number.isFinite(value.lastActivityAt)
? Math.max(0, Math.floor(value.lastActivityAt))
: boundAt;
const idleTimeoutMs =
typeof value.idleTimeoutMs === "number" && Number.isFinite(value.idleTimeoutMs)
? Math.max(0, Math.floor(value.idleTimeoutMs))
: undefined;
const maxAgeMs =
typeof value.maxAgeMs === "number" && Number.isFinite(value.maxAgeMs)
? Math.max(0, Math.floor(value.maxAgeMs))
: undefined;
return {
accountId,
@@ -180,41 +189,79 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin
webhookToken,
boundBy,
boundAt,
expiresAt,
lastActivityAt,
idleTimeoutMs,
maxAgeMs,
};
}
export function normalizeThreadBindingTtlMs(raw: unknown): number {
export function normalizeThreadBindingDurationMs(raw: unknown, defaultsTo: number): number {
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return DEFAULT_THREAD_BINDING_TTL_MS;
return defaultsTo;
}
const ttlMs = Math.floor(raw);
if (ttlMs < 0) {
return DEFAULT_THREAD_BINDING_TTL_MS;
const durationMs = Math.floor(raw);
if (durationMs < 0) {
return defaultsTo;
}
return ttlMs;
return durationMs;
}
export function resolveThreadBindingExpiresAt(params: {
record: Pick<ThreadBindingRecord, "boundAt" | "expiresAt">;
sessionTtlMs: number;
}): number | undefined {
if (typeof params.record.expiresAt === "number" && Number.isFinite(params.record.expiresAt)) {
const explicitExpiresAt = Math.floor(params.record.expiresAt);
if (explicitExpiresAt <= 0) {
// 0 is an explicit per-binding TTL disable sentinel.
return undefined;
}
return explicitExpiresAt;
export function resolveThreadBindingIdleTimeoutMs(params: {
record: Pick<ThreadBindingRecord, "idleTimeoutMs">;
defaultIdleTimeoutMs: number;
}): number {
const explicit = params.record.idleTimeoutMs;
if (typeof explicit === "number" && Number.isFinite(explicit)) {
return Math.max(0, Math.floor(explicit));
}
if (params.sessionTtlMs <= 0) {
return Math.max(0, Math.floor(params.defaultIdleTimeoutMs));
}
export function resolveThreadBindingMaxAgeMs(params: {
record: Pick<ThreadBindingRecord, "maxAgeMs">;
defaultMaxAgeMs: number;
}): number {
const explicit = params.record.maxAgeMs;
if (typeof explicit === "number" && Number.isFinite(explicit)) {
return Math.max(0, Math.floor(explicit));
}
return Math.max(0, Math.floor(params.defaultMaxAgeMs));
}
export function resolveThreadBindingInactivityExpiresAt(params: {
record: Pick<ThreadBindingRecord, "lastActivityAt" | "idleTimeoutMs">;
defaultIdleTimeoutMs: number;
}): number | undefined {
const idleTimeoutMs = resolveThreadBindingIdleTimeoutMs({
record: params.record,
defaultIdleTimeoutMs: params.defaultIdleTimeoutMs,
});
if (idleTimeoutMs <= 0) {
return undefined;
}
const lastActivityAt = Math.floor(params.record.lastActivityAt);
if (!Number.isFinite(lastActivityAt) || lastActivityAt <= 0) {
return undefined;
}
return lastActivityAt + idleTimeoutMs;
}
export function resolveThreadBindingMaxAgeExpiresAt(params: {
record: Pick<ThreadBindingRecord, "boundAt" | "maxAgeMs">;
defaultMaxAgeMs: number;
}): number | undefined {
const maxAgeMs = resolveThreadBindingMaxAgeMs({
record: params.record,
defaultMaxAgeMs: params.defaultMaxAgeMs,
});
if (maxAgeMs <= 0) {
return undefined;
}
const boundAt = Math.floor(params.record.boundAt);
if (!Number.isFinite(boundAt) || boundAt <= 0) {
return undefined;
}
return boundAt + params.sessionTtlMs;
return boundAt + maxAgeMs;
}
function linkSessionBinding(targetSessionKey: string, bindingKey: string) {
@@ -429,6 +476,13 @@ export function resolveBindingIdsForSession(params: {
return out;
}
export function resolveDefaultThreadBindingDurations() {
return {
defaultIdleTimeoutMs: DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS,
defaultMaxAgeMs: DEFAULT_THREAD_BINDING_MAX_AGE_MS,
};
}
export function resetThreadBindingsForTests() {
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
manager.stop();

View File

@@ -5,18 +5,25 @@ export type {
} from "./thread-bindings.types.js";
export {
formatThreadBindingTtlLabel,
formatThreadBindingDurationLabel,
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "./thread-bindings.messages.js";
export { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.state.js";
export {
isRecentlyUnboundThreadWebhookMessage,
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingInactivityExpiresAt,
resolveThreadBindingMaxAgeExpiresAt,
resolveThreadBindingMaxAgeMs,
} from "./thread-bindings.state.js";
export {
autoBindSpawnedDiscordSubagent,
listThreadBindingsBySessionKey,
listThreadBindingsForAccount,
setThreadBindingTtlBySessionKey,
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
unbindThreadBindingsBySessionKey,
} from "./thread-bindings.lifecycle.js";

View File

@@ -12,7 +12,11 @@ export type ThreadBindingRecord = {
webhookToken?: string;
boundBy: string;
boundAt: number;
expiresAt?: number;
lastActivityAt: number;
/** Inactivity timeout window in milliseconds (0 disables inactivity auto-unfocus). */
idleTimeoutMs?: number;
/** Hard max-age window in milliseconds from bind time (0 disables hard cap). */
maxAgeMs?: number;
};
export type PersistedThreadBindingRecord = ThreadBindingRecord & {
@@ -26,11 +30,17 @@ export type PersistedThreadBindingsPayload = {
export type ThreadBindingManager = {
accountId: string;
getSessionTtlMs: () => number;
getIdleTimeoutMs: () => number;
getMaxAgeMs: () => number;
getByThreadId: (threadId: string) => ThreadBindingRecord | undefined;
getBySessionKey: (targetSessionKey: string) => ThreadBindingRecord | undefined;
listBySessionKey: (targetSessionKey: string) => ThreadBindingRecord[];
listBindings: () => ThreadBindingRecord[];
touchThread: (params: {
threadId: string;
at?: number;
persist?: boolean;
}) => ThreadBindingRecord | null;
bindTarget: (params: {
threadId?: string | number;
channelId?: string;
@@ -63,7 +73,8 @@ export type ThreadBindingManager = {
export const THREAD_BINDINGS_VERSION = 1 as const;
export const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 120_000;
export const DEFAULT_THREAD_BINDING_TTL_MS = 24 * 60 * 60 * 1000; // 24h
export const DEFAULT_FAREWELL_TEXT = "Session ended. Messages here will no longer be routed.";
export const DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24h
export const DEFAULT_THREAD_BINDING_MAX_AGE_MS = 0; // disabled
export const DEFAULT_FAREWELL_TEXT = "Thread unfocused. Messages here will no longer be routed.";
export const DISCORD_UNKNOWN_CHANNEL_ERROR_CODE = 10_003;
export const RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS = 30_000;