Discord: thread bindings idle + max-age lifecycle (#27845) (thanks @osolmaz)

* refactor discord thread bindings to idle and max-age lifecycle

* fix: migrate legacy thread binding expiry and reduce hot-path disk writes

* refactor: remove remaining thread-binding ttl legacy paths

* fix: harden thread-binding lifecycle persistence

* Discord: fix thread binding types in message/reply paths

* Infra: handle win32 unknown inode in file identity checks

* Infra: relax win32 guarded-open identity checks

* Config: migrate threadBindings ttlHours to idleHours

* Revert "Infra: relax win32 guarded-open identity checks"

This reverts commit de94126771.

* Revert "Infra: handle win32 unknown inode in file identity checks"

This reverts commit 96fc5ddfb3.

* Discord: re-read live binding state before sweep unbind

* fix: add changelog note for thread binding lifecycle update (#27845) (thanks @osolmaz)

---------

Co-authored-by: Onur Solmaz <onur@textcortex.com>
This commit is contained in:
Onur Solmaz
2026-02-27 10:02:39 +01:00
committed by GitHub
parent 0fb7add7d6
commit a7929abad8
45 changed files with 1656 additions and 402 deletions

View File

@@ -22,7 +22,8 @@ import {
import {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
resolveThreadBindingSessionTtlMsForChannel,
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
resolveThreadBindingSpawnPolicy,
} from "../../../channels/thread-bindings-policy.js";
import type { OpenClawConfig } from "../../../config/config.js";
@@ -196,7 +197,12 @@ async function bindSpawnedAcpSessionToThread(params: {
introText: resolveThreadBindingIntroText({
agentId: params.agentId,
label,
sessionTtlMs: resolveThreadBindingSessionTtlMsForChannel({
idleTimeoutMs: resolveThreadBindingIdleTimeoutMsForChannel({
cfg: commandParams.cfg,
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
}),
maxAgeMs: resolveThreadBindingMaxAgeMsForChannel({
cfg: commandParams.cfg,
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,

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

@@ -1,9 +1,14 @@
import { parseDurationMs } from "../../cli/parse-duration.js";
import { isRestartEnabled } from "../../config/commands.js";
import {
formatThreadBindingTtlLabel,
formatThreadBindingDurationLabel,
getThreadBindingManager,
setThreadBindingTtlBySessionKey,
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingInactivityExpiresAt,
resolveThreadBindingMaxAgeExpiresAt,
resolveThreadBindingMaxAgeMs,
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
} from "../../discord/monitor/thread-bindings.js";
import { logVerbose } from "../../globals.js";
import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js";
@@ -17,7 +22,9 @@ import { persistSessionEntry } from "./commands-session-store.js";
import type { CommandHandler } from "./commands-types.js";
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 =
@@ -38,21 +45,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);
}
@@ -246,7 +253,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() },
@@ -256,7 +263,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.",
},
};
}
@@ -265,7 +274,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.",
},
};
}
@@ -286,20 +297,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." },
};
}
@@ -307,13 +357,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,
@@ -321,40 +373,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

@@ -112,7 +112,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),
@@ -286,7 +287,7 @@ describe("/focus, /unfocus, /agents", () => {
targetSessionKey: "agent:codex-acp:session-1",
metadata: expect.objectContaining({
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

@@ -4,7 +4,8 @@ import {
} from "../../../acp/runtime/session-identifiers.js";
import { readAcpSessionEntry } from "../../../acp/runtime/session-meta.js";
import {
resolveDiscordThreadBindingSessionTtlMs,
resolveDiscordThreadBindingIdleTimeoutMs,
resolveDiscordThreadBindingMaxAgeMs,
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "../../../discord/monitor/thread-bindings.js";
@@ -109,7 +110,11 @@ export async function handleSubagentsFocusAction(
introText: resolveThreadBindingIntroText({
agentId: focusTarget.agentId,
label,
sessionTtlMs: resolveDiscordThreadBindingSessionTtlMs({
idleTimeoutMs: resolveDiscordThreadBindingIdleTimeoutMs({
cfg: params.cfg,
accountId,
}),
maxAgeMs: resolveDiscordThreadBindingMaxAgeMs({
cfg: params.cfg,
accountId,
}),

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>",