Files
moltbot/src/discord/monitor/thread-bindings.lifecycle.ts
Onur 8178ea472d feat: thread-bound subagents on Discord (#21805)
* docs: thread-bound subagents plan

* docs: add exact thread-bound subagent implementation touchpoints

* Docs: prioritize auto thread-bound subagent flow

* Docs: add ACP harness thread-binding extensions

* Discord: add thread-bound session routing and auto-bind spawn flow

* Subagents: add focus commands and ACP/session binding lifecycle hooks

* Tests: cover thread bindings, focus commands, and ACP unbind hooks

* Docs: add plugin-hook appendix for thread-bound subagents

* Plugins: add subagent lifecycle hook events

* Core: emit subagent lifecycle hooks and decouple Discord bindings

* Discord: handle subagent bind lifecycle via plugin hooks

* Subagents: unify completion finalizer and split registry modules

* Add subagent lifecycle events module

* Hooks: fix subagent ended context key

* Discord: share thread bindings across ESM and Jiti

* Subagents: add persistent sessions_spawn mode for thread-bound sessions

* Subagents: clarify thread intro and persistent completion copy

* test(subagents): stabilize sessions_spawn lifecycle cleanup assertions

* Discord: add thread-bound session TTL with auto-unfocus

* Subagents: fail session spawns when thread bind fails

* Subagents: cover thread session failure cleanup paths

* Session: add thread binding TTL config and /session ttl controls

* Tests: align discord reaction expectations

* Agent: persist sessionFile for keyed subagent sessions

* Discord: normalize imports after conflict resolution

* Sessions: centralize sessionFile resolve/persist helper

* Discord: harden thread-bound subagent session routing

* Rebase: resolve upstream/main conflicts

* Subagents: move thread binding into hooks and split bindings modules

* Docs: add channel-agnostic subagent routing hook plan

* Agents: decouple subagent routing from Discord

* Discord: refactor thread-bound subagent flows

* Subagents: prevent duplicate end hooks and orphaned failed sessions

* Refactor: split subagent command and provider phases

* Subagents: honor hook delivery target overrides

* Discord: add thread binding kill switches and refresh plan doc

* Discord: fix thread bind channel resolution

* Routing: centralize account id normalization

* Discord: clean up thread bindings on startup failures

* Discord: add startup cleanup regression tests

* Docs: add long-term thread-bound subagent architecture

* Docs: split session binding plan and dedupe thread-bound doc

* Subagents: add channel-agnostic session binding routing

* Subagents: stabilize announce completion routing tests

* Subagents: cover multi-bound completion routing

* Subagents: suppress lifecycle hooks on failed thread bind

* tests: fix discord provider mock typing regressions

* docs/protocol: sync slash command aliases and delete param models

* fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc)

---------

Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00

226 lines
6.2 KiB
TypeScript

import { normalizeAccountId } from "../../routing/session-key.js";
import { parseDiscordTarget } from "../targets.js";
import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js";
import { getThreadBindingManager } from "./thread-bindings.manager.js";
import {
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "./thread-bindings.messages.js";
import {
BINDINGS_BY_THREAD_ID,
MANAGERS_BY_ACCOUNT_ID,
ensureBindingsLoaded,
getThreadBindingToken,
normalizeThreadBindingTtlMs,
normalizeThreadId,
rememberRecentUnboundWebhookEcho,
removeBindingRecord,
resolveBindingIdsForSession,
saveBindingsToDisk,
setBindingRecord,
shouldPersistBindingMutations,
} from "./thread-bindings.state.js";
import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.js";
export function listThreadBindingsForAccount(accountId?: string): ThreadBindingRecord[] {
const manager = getThreadBindingManager(accountId);
if (!manager) {
return [];
}
return manager.listBindings();
}
export function listThreadBindingsBySessionKey(params: {
targetSessionKey: string;
accountId?: string;
targetKind?: ThreadBindingTargetKind;
}): ThreadBindingRecord[] {
ensureBindingsLoaded();
const targetSessionKey = params.targetSessionKey.trim();
if (!targetSessionKey) {
return [];
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
const ids = resolveBindingIdsForSession({
targetSessionKey,
accountId,
targetKind: params.targetKind,
});
return ids
.map((bindingKey) => BINDINGS_BY_THREAD_ID.get(bindingKey))
.filter((entry): entry is ThreadBindingRecord => Boolean(entry));
}
export async function autoBindSpawnedDiscordSubagent(params: {
accountId?: string;
channel?: string;
to?: string;
threadId?: string | number;
childSessionKey: string;
agentId: string;
label?: string;
boundBy?: string;
}): Promise<ThreadBindingRecord | null> {
const channel = params.channel?.trim().toLowerCase();
if (channel !== "discord") {
return null;
}
const manager = getThreadBindingManager(params.accountId);
if (!manager) {
return null;
}
const managerToken = getThreadBindingToken(manager.accountId);
const requesterThreadId = normalizeThreadId(params.threadId);
let channelId = "";
if (requesterThreadId) {
const existing = manager.getByThreadId(requesterThreadId);
if (existing?.channelId?.trim()) {
channelId = existing.channelId.trim();
} else {
channelId =
(await resolveChannelIdForBinding({
accountId: manager.accountId,
token: managerToken,
threadId: requesterThreadId,
})) ?? "";
}
}
if (!channelId) {
const to = params.to?.trim() || "";
if (!to) {
return null;
}
try {
const target = parseDiscordTarget(to, { defaultKind: "channel" });
if (!target || target.kind !== "channel") {
return null;
}
channelId =
(await resolveChannelIdForBinding({
accountId: manager.accountId,
token: managerToken,
threadId: target.id,
})) ?? "";
} catch {
return null;
}
}
return await manager.bindTarget({
threadId: undefined,
channelId,
createThread: true,
threadName: resolveThreadBindingThreadName({
agentId: params.agentId,
label: params.label,
}),
targetKind: "subagent",
targetSessionKey: params.childSessionKey,
agentId: params.agentId,
label: params.label,
boundBy: params.boundBy ?? "system",
introText: resolveThreadBindingIntroText({
agentId: params.agentId,
label: params.label,
sessionTtlMs: manager.getSessionTtlMs(),
}),
});
}
export function unbindThreadBindingsBySessionKey(params: {
targetSessionKey: string;
accountId?: string;
targetKind?: ThreadBindingTargetKind;
reason?: string;
sendFarewell?: boolean;
farewellText?: string;
}): ThreadBindingRecord[] {
ensureBindingsLoaded();
const targetSessionKey = params.targetSessionKey.trim();
if (!targetSessionKey) {
return [];
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
const ids = resolveBindingIdsForSession({
targetSessionKey,
accountId,
targetKind: params.targetKind,
});
if (ids.length === 0) {
return [];
}
const removed: ThreadBindingRecord[] = [];
for (const bindingKey of ids) {
const record = BINDINGS_BY_THREAD_ID.get(bindingKey);
if (!record) {
continue;
}
const manager = MANAGERS_BY_ACCOUNT_ID.get(record.accountId);
if (manager) {
const unbound = manager.unbindThread({
threadId: record.threadId,
reason: params.reason,
sendFarewell: params.sendFarewell,
farewellText: params.farewellText,
});
if (unbound) {
removed.push(unbound);
}
continue;
}
const unbound = removeBindingRecord(bindingKey);
if (unbound) {
rememberRecentUnboundWebhookEcho(unbound);
removed.push(unbound);
}
}
if (removed.length > 0 && shouldPersistBindingMutations()) {
saveBindingsToDisk({ force: true });
}
return removed;
}
export function setThreadBindingTtlBySessionKey(params: {
targetSessionKey: string;
accountId?: string;
ttlMs: number;
}): ThreadBindingRecord[] {
ensureBindingsLoaded();
const targetSessionKey = params.targetSessionKey.trim();
if (!targetSessionKey) {
return [];
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
const ids = resolveBindingIdsForSession({
targetSessionKey,
accountId,
});
if (ids.length === 0) {
return [];
}
const ttlMs = normalizeThreadBindingTtlMs(params.ttlMs);
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);
if (!existing) {
continue;
}
const nextRecord: ThreadBindingRecord = {
...existing,
boundAt: now,
expiresAt,
};
setBindingRecord(nextRecord);
updated.push(nextRecord);
}
if (updated.length > 0 && shouldPersistBindingMutations()) {
saveBindingsToDisk({ force: true });
}
return updated;
}