mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
* 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>
226 lines
6.2 KiB
TypeScript
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;
|
|
}
|