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>
This commit is contained in:
Onur
2026-02-21 16:14:55 +01:00
committed by GitHub
parent 166068dfbe
commit 8178ea472d
114 changed files with 12214 additions and 1659 deletions

View File

@@ -23,6 +23,7 @@ import {
handleAbortTrigger,
handleActivationCommand,
handleRestartCommand,
handleSessionCommand,
handleSendPolicyCommand,
handleStopCommand,
handleUsageCommand,
@@ -47,6 +48,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
handleActivationCommand,
handleSendPolicyCommand,
handleUsageCommand,
handleSessionCommand,
handleRestartCommand,
handleTtsCommands,
handleHelpCommand,

View File

@@ -0,0 +1,147 @@
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.mockReset();
hoisted.setThreadBindingTtlBySessionKeyMock.mockReset();
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,7 +1,13 @@
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import { isRestartEnabled } from "../../config/commands.js";
import type { SessionEntry } from "../../config/sessions.js";
import { updateSessionStore } from "../../config/sessions.js";
import {
formatThreadBindingTtlLabel,
getThreadBindingManager,
setThreadBindingTtlBySessionKey,
} from "../../discord/monitor/thread-bindings.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { scheduleGatewaySigusr1Restart, triggerOpenClawRestart } from "../../infra/restart.js";
@@ -41,6 +47,53 @@ function resolveAbortTarget(params: {
return { entry: undefined, key: targetSessionKey, sessionId: undefined };
}
const SESSION_COMMAND_PREFIX = "/session";
const SESSION_TTL_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]);
function isDiscordSurface(params: Parameters<CommandHandler>[0]): boolean {
const channel =
params.ctx.OriginatingChannel ??
params.command.channel ??
params.ctx.Surface ??
params.ctx.Provider;
return (
String(channel ?? "")
.trim()
.toLowerCase() === "discord"
);
}
function resolveDiscordAccountId(params: Parameters<CommandHandler>[0]): string {
const accountId = typeof params.ctx.AccountId === "string" ? params.ctx.AccountId.trim() : "";
return accountId || "default";
}
function resolveSessionCommandUsage() {
return "Usage: /session ttl <duration|off> (example: /session ttl 24h)";
}
function parseSessionTtlMs(raw: string): number {
const normalized = raw.trim().toLowerCase();
if (!normalized) {
throw new Error("missing ttl");
}
if (SESSION_TTL_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");
}
return Math.round(hours * 60 * 60 * 1000);
}
return parseDurationMs(normalized, { defaultUnit: "h" });
}
function formatSessionExpiry(expiresAt: number) {
return new Date(expiresAt).toISOString();
}
async function applyAbortTarget(params: {
abortTarget: ReturnType<typeof resolveAbortTarget>;
sessionStore?: Record<string, SessionEntry>;
@@ -244,6 +297,133 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
};
};
export const handleSessionCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const normalized = params.command.commandBodyNormalized;
if (!/^\/session(?:\s|$)/.test(normalized)) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /session from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
const rest = normalized.slice(SESSION_COMMAND_PREFIX.length).trim();
const tokens = rest.split(/\s+/).filter(Boolean);
const action = tokens[0]?.toLowerCase();
if (action !== "ttl") {
return {
shouldContinue: false,
reply: { text: resolveSessionCommandUsage() },
};
}
if (!isDiscordSurface(params)) {
return {
shouldContinue: false,
reply: { text: "⚠️ /session ttl is currently available for Discord thread-bound sessions." },
};
}
const threadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
if (!threadId) {
return {
shouldContinue: false,
reply: { text: "⚠️ /session ttl must be run inside a focused Discord thread." },
};
}
const accountId = resolveDiscordAccountId(params);
const threadBindings = getThreadBindingManager(accountId);
if (!threadBindings) {
return {
shouldContinue: false,
reply: { text: "⚠️ Discord thread bindings are unavailable for this account." },
};
}
const binding = threadBindings.getByThreadId(threadId);
if (!binding) {
return {
shouldContinue: false,
reply: { text: " This thread is not currently focused." },
};
}
const ttlArgRaw = tokens.slice(1).join("");
if (!ttlArgRaw) {
const expiresAt = binding.expiresAt;
if (typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > Date.now()) {
return {
shouldContinue: false,
reply: {
text: ` Session TTL active (${formatThreadBindingTtlLabel(expiresAt - Date.now())}, auto-unfocus at ${formatSessionExpiry(expiresAt)}).`,
},
};
}
return {
shouldContinue: false,
reply: { text: " Session TTL is currently disabled for this focused session." },
};
}
const senderId = params.command.senderId?.trim() || "";
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.` },
};
}
let ttlMs: number;
try {
ttlMs = parseSessionTtlMs(ttlArgRaw);
} catch {
return {
shouldContinue: false,
reply: { text: resolveSessionCommandUsage() },
};
}
const updatedBindings = setThreadBindingTtlBySessionKey({
targetSessionKey: binding.targetSessionKey,
accountId,
ttlMs,
});
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"}.`,
},
};
}
const expiresAt = updatedBindings[0]?.expiresAt;
const expiryLabel =
typeof expiresAt === "number" && Number.isFinite(expiresAt)
? formatSessionExpiry(expiresAt)
: "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}).`,
},
};
};
export const handleRestartCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;

View File

@@ -0,0 +1,331 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
addSubagentRunForTests,
resetSubagentRegistryForTests,
} from "../../agents/subagent-registry.js";
import type { OpenClawConfig } from "../../config/config.js";
const hoisted = vi.hoisted(() => {
const callGatewayMock = vi.fn();
const getThreadBindingManagerMock = vi.fn();
const resolveThreadBindingThreadNameMock = vi.fn(() => "🤖 codex");
return {
callGatewayMock,
getThreadBindingManagerMock,
resolveThreadBindingThreadNameMock,
};
});
vi.mock("../../gateway/call.js", () => ({
callGateway: hoisted.callGatewayMock,
}));
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,
resolveThreadBindingThreadName: hoisted.resolveThreadBindingThreadNameMock,
};
});
vi.mock("../../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/config.js")>();
return {
...actual,
loadConfig: () => ({}),
};
});
// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent.
vi.mock("../../discord/monitor/gateway-plugin.js", () => ({
createDiscordGatewayPlugin: () => ({}),
}));
const { handleSubagentsCommand } = await import("./commands-subagents.js");
const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js");
type FakeBinding = {
accountId: string;
channelId: string;
threadId: string;
targetKind: "subagent" | "acp";
targetSessionKey: string;
agentId: string;
label?: string;
webhookId?: string;
webhookToken?: string;
boundBy: string;
boundAt: number;
};
function createFakeThreadBindingManager(initialBindings: FakeBinding[] = []) {
const byThread = new Map<string, FakeBinding>(
initialBindings.map((binding) => [binding.threadId, binding]),
);
const manager = {
getSessionTtlMs: vi.fn(() => 24 * 60 * 60 * 1000),
getByThreadId: vi.fn((threadId: string) => byThread.get(threadId)),
listBySessionKey: vi.fn((targetSessionKey: string) =>
[...byThread.values()].filter((binding) => binding.targetSessionKey === targetSessionKey),
),
listBindings: vi.fn(() => [...byThread.values()]),
bindTarget: vi.fn(async (params: Record<string, unknown>) => {
const threadId =
typeof params.threadId === "string" && params.threadId.trim()
? params.threadId.trim()
: "thread-created";
const targetSessionKey =
typeof params.targetSessionKey === "string" ? params.targetSessionKey.trim() : "";
const agentId =
typeof params.agentId === "string" && params.agentId.trim()
? params.agentId.trim()
: "main";
const binding: FakeBinding = {
accountId: "default",
channelId:
typeof params.channelId === "string" && params.channelId.trim()
? params.channelId.trim()
: "parent-1",
threadId,
targetKind:
params.targetKind === "subagent" || params.targetKind === "acp"
? params.targetKind
: "acp",
targetSessionKey,
agentId,
label: typeof params.label === "string" ? params.label : undefined,
boundBy: typeof params.boundBy === "string" ? params.boundBy : "system",
boundAt: Date.now(),
};
byThread.set(threadId, binding);
return binding;
}),
unbindThread: vi.fn((params: { threadId: string }) => {
const binding = byThread.get(params.threadId) ?? null;
if (binding) {
byThread.delete(params.threadId);
}
return binding;
}),
};
return { manager, byThread };
}
const baseCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
function createDiscordCommandParams(commandBody: string) {
const params = buildCommandTestParams(commandBody, baseCfg, {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
OriginatingTo: "channel:parent-1",
AccountId: "default",
MessageThreadId: "thread-1",
});
params.command.senderId = "user-1";
return params;
}
describe("/focus, /unfocus, /agents", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
hoisted.callGatewayMock.mockReset();
hoisted.getThreadBindingManagerMock.mockReset();
hoisted.resolveThreadBindingThreadNameMock.mockReset().mockReturnValue("🤖 codex");
});
it("/focus resolves ACP sessions and binds the current Discord thread", async () => {
const fake = createFakeThreadBindingManager();
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
hoisted.callGatewayMock.mockImplementation(async (request: unknown) => {
const method = (request as { method?: string }).method;
if (method === "sessions.resolve") {
return { key: "agent:codex-acp:session-1" };
}
return {};
});
const params = createDiscordCommandParams("/focus codex-acp");
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("bound this thread");
expect(result?.reply?.text).toContain("(acp)");
expect(fake.manager.bindTarget).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "thread-1",
createThread: false,
targetKind: "acp",
targetSessionKey: "agent:codex-acp:session-1",
introText:
"🤖 codex-acp session active (auto-unfocus in 24h). Messages here go directly to this session.",
}),
);
});
it("/unfocus removes an active thread binding for the binding owner", async () => {
const fake = createFakeThreadBindingManager([
{
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "child",
boundBy: "user-1",
boundAt: Date.now(),
},
]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
const params = createDiscordCommandParams("/unfocus");
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("Thread unfocused");
expect(fake.manager.unbindThread).toHaveBeenCalledWith(
expect.objectContaining({
threadId: "thread-1",
reason: "manual",
}),
);
});
it("/focus rejects rebinding when the thread is focused by another user", async () => {
const fake = createFakeThreadBindingManager([
{
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child",
agentId: "main",
label: "child",
boundBy: "user-2",
boundAt: Date.now(),
},
]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
hoisted.callGatewayMock.mockImplementation(async (request: unknown) => {
const method = (request as { method?: string }).method;
if (method === "sessions.resolve") {
return { key: "agent:codex-acp:session-1" };
}
return {};
});
const params = createDiscordCommandParams("/focus codex-acp");
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("Only user-2 can refocus this thread.");
expect(fake.manager.bindTarget).not.toHaveBeenCalled();
});
it("/agents includes bound persistent sessions and requester-scoped ACP bindings", async () => {
addSubagentRunForTests({
runId: "run-1",
childSessionKey: "agent:main:subagent:child-1",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "test task",
cleanup: "keep",
label: "child-1",
createdAt: Date.now(),
});
const fake = createFakeThreadBindingManager([
{
accountId: "default",
channelId: "parent-1",
threadId: "thread-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child-1",
agentId: "main",
label: "child-1",
boundBy: "user-1",
boundAt: Date.now(),
},
{
accountId: "default",
channelId: "parent-1",
threadId: "thread-2",
targetKind: "acp",
targetSessionKey: "agent:main:main",
agentId: "codex-acp",
label: "main-session",
boundBy: "user-1",
boundAt: Date.now(),
},
{
accountId: "default",
channelId: "parent-1",
threadId: "thread-3",
targetKind: "acp",
targetSessionKey: "agent:codex-acp:session-2",
agentId: "codex-acp",
label: "codex-acp",
boundBy: "user-1",
boundAt: Date.now(),
},
]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
const params = createDiscordCommandParams("/agents");
const result = await handleSubagentsCommand(params, true);
const text = result?.reply?.text ?? "";
expect(text).toContain("agents:");
expect(text).toContain("thread:thread-1");
expect(text).toContain("acp/session bindings:");
expect(text).toContain("session:agent:main:main");
expect(text).not.toContain("session:agent:codex-acp:session-2");
});
it("/agents keeps finished session-mode runs visible while their thread binding remains", async () => {
addSubagentRunForTests({
runId: "run-session-1",
childSessionKey: "agent:main:subagent:persistent-1",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "persistent task",
cleanup: "keep",
label: "persistent-1",
spawnMode: "session",
createdAt: Date.now(),
endedAt: Date.now(),
});
const fake = createFakeThreadBindingManager([
{
accountId: "default",
channelId: "parent-1",
threadId: "thread-persistent-1",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:persistent-1",
agentId: "main",
label: "persistent-1",
boundBy: "user-1",
boundAt: Date.now(),
},
]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
const params = createDiscordCommandParams("/agents");
const result = await handleSubagentsCommand(params, true);
const text = result?.reply?.text ?? "";
expect(text).toContain("agents:");
expect(text).toContain("persistent-1");
expect(text).toContain("thread:thread-persistent-1");
});
it("/focus is discord-only", async () => {
const params = buildCommandTestParams("/focus codex-acp", baseCfg);
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("only available on Discord");
});
});

View File

@@ -11,6 +11,7 @@ const hoisted = vi.hoisted(() => {
vi.mock("../../agents/subagent-spawn.js", () => ({
spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args),
SUBAGENT_SPAWN_MODES: ["run", "session"],
}));
vi.mock("../../gateway/call.js", () => ({
@@ -93,6 +94,7 @@ describe("/subagents spawn command", () => {
const [spawnParams, spawnCtx] = spawnSubagentDirectMock.mock.calls[0];
expect(spawnParams.task).toBe("do the thing");
expect(spawnParams.agentId).toBe("beta");
expect(spawnParams.mode).toBe("run");
expect(spawnParams.cleanup).toBe("keep");
expect(spawnParams.expectsCompletionMessage).toBe(true);
expect(spawnCtx.agentSessionKey).toBeDefined();

View File

@@ -1,255 +1,38 @@
import crypto from "node:crypto";
import { AGENT_LANE_SUBAGENT } from "../../agents/lanes.js";
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
import {
clearSubagentRunSteerRestart,
listSubagentRunsForRequester,
markSubagentRunTerminated,
markSubagentRunForSteerRestart,
replaceSubagentRunAfterSteer,
} from "../../agents/subagent-registry.js";
import { spawnSubagentDirect } from "../../agents/subagent-spawn.js";
import {
extractAssistantText,
resolveInternalSessionKey,
resolveMainSessionAlias,
sanitizeTextContent,
stripToolMessages,
} from "../../agents/tools/sessions-helpers.js";
import {
type SessionEntry,
loadSessionStore,
resolveStorePath,
updateSessionStore,
} from "../../config/sessions.js";
import { callGateway } from "../../gateway/call.js";
import { listSubagentRunsForRequester } from "../../agents/subagent-registry.js";
import { logVerbose } from "../../globals.js";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { parseAgentSessionKey } from "../../routing/session-key.js";
import { extractTextFromChatContent } from "../../shared/chat-content.js";
import { handleSubagentsAgentsAction } from "./commands-subagents/action-agents.js";
import { handleSubagentsFocusAction } from "./commands-subagents/action-focus.js";
import { handleSubagentsHelpAction } from "./commands-subagents/action-help.js";
import { handleSubagentsInfoAction } from "./commands-subagents/action-info.js";
import { handleSubagentsKillAction } from "./commands-subagents/action-kill.js";
import { handleSubagentsListAction } from "./commands-subagents/action-list.js";
import { handleSubagentsLogAction } from "./commands-subagents/action-log.js";
import { handleSubagentsSendAction } from "./commands-subagents/action-send.js";
import { handleSubagentsSpawnAction } from "./commands-subagents/action-spawn.js";
import { handleSubagentsUnfocusAction } from "./commands-subagents/action-unfocus.js";
import {
formatDurationCompact,
formatTokenUsageDisplay,
truncateLine,
} from "../../shared/subagents-format.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
import { stopSubagentsForRequester } from "./abort.js";
type SubagentsCommandContext,
extractMessageText,
resolveHandledPrefix,
resolveRequesterSessionKey,
resolveSubagentsAction,
stopWithText,
} from "./commands-subagents/shared.js";
import type { CommandHandler } from "./commands-types.js";
import { clearSessionQueues } from "./queue.js";
import {
formatRunLabel,
formatRunStatus,
resolveSubagentTargetFromRuns,
type SubagentTargetResolution,
sortSubagentRuns,
} from "./subagents-utils.js";
const COMMAND = "/subagents";
const COMMAND_KILL = "/kill";
const COMMAND_STEER = "/steer";
const COMMAND_TELL = "/tell";
const ACTIONS = new Set(["list", "kill", "log", "send", "steer", "info", "spawn", "help"]);
const RECENT_WINDOW_MINUTES = 30;
const SUBAGENT_TASK_PREVIEW_MAX = 110;
const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000;
function compactLine(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function formatTaskPreview(value: string) {
return truncateLine(compactLine(value), SUBAGENT_TASK_PREVIEW_MAX);
}
function resolveModelDisplay(
entry?: {
model?: unknown;
modelProvider?: unknown;
modelOverride?: unknown;
providerOverride?: unknown;
},
fallbackModel?: string,
) {
const model = typeof entry?.model === "string" ? entry.model.trim() : "";
const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : "";
let combined = model.includes("/") ? model : model && provider ? `${provider}/${model}` : model;
if (!combined) {
// Fall back to override fields which are populated at spawn time,
// before the first run completes and writes model/modelProvider.
const overrideModel =
typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : "";
const overrideProvider =
typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : "";
combined = overrideModel.includes("/")
? overrideModel
: overrideModel && overrideProvider
? `${overrideProvider}/${overrideModel}`
: overrideModel;
}
if (!combined) {
combined = fallbackModel?.trim() || "";
}
if (!combined) {
return "model n/a";
}
const slash = combined.lastIndexOf("/");
if (slash >= 0 && slash < combined.length - 1) {
return combined.slice(slash + 1);
}
return combined;
}
function resolveDisplayStatus(entry: SubagentRunRecord) {
const status = formatRunStatus(entry);
return status === "error" ? "failed" : status;
}
function formatSubagentListLine(params: {
entry: SubagentRunRecord;
index: number;
runtimeMs: number;
sessionEntry?: SessionEntry;
}) {
const usageText = formatTokenUsageDisplay(params.sessionEntry);
const label = truncateLine(formatRunLabel(params.entry, { maxLength: 48 }), 48);
const task = formatTaskPreview(params.entry.task);
const runtime = formatDurationCompact(params.runtimeMs);
const status = resolveDisplayStatus(params.entry);
return `${params.index}. ${label} (${resolveModelDisplay(params.sessionEntry, params.entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
}
function formatTimestamp(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
return new Date(valueMs).toISOString();
}
function formatTimestampWithAge(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`;
}
function resolveRequesterSessionKey(
params: Parameters<CommandHandler>[0],
opts?: { preferCommandTarget?: boolean },
): string | undefined {
const commandTarget = params.ctx.CommandTargetSessionKey?.trim();
const commandSession = params.sessionKey?.trim();
const raw = opts?.preferCommandTarget
? commandTarget || commandSession
: commandSession || commandTarget;
if (!raw) {
return undefined;
}
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
return resolveInternalSessionKey({ key: raw, alias, mainKey });
}
function resolveSubagentTarget(
runs: SubagentRunRecord[],
token: string | undefined,
): SubagentTargetResolution {
return resolveSubagentTargetFromRuns({
runs,
token,
recentWindowMinutes: RECENT_WINDOW_MINUTES,
label: (entry) => formatRunLabel(entry),
errors: {
missingTarget: "Missing subagent id.",
invalidIndex: (value) => `Invalid subagent index: ${value}`,
unknownSession: (value) => `Unknown subagent session: ${value}`,
ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`,
ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`,
ambiguousRunIdPrefix: (value) => `Ambiguous run id prefix: ${value}`,
unknownTarget: (value) => `Unknown subagent id: ${value}`,
},
});
}
function buildSubagentsHelp() {
return [
"Subagents",
"Usage:",
"- /subagents list",
"- /subagents kill <id|#|all>",
"- /subagents log <id|#> [limit] [tools]",
"- /subagents info <id|#>",
"- /subagents send <id|#> <message>",
"- /subagents steer <id|#> <message>",
"- /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
"- /kill <id|#|all>",
"- /steer <id|#> <message>",
"- /tell <id|#> <message>",
"",
"Ids: use the list index (#), runId/session prefix, label, or full session key.",
].join("\n");
}
type ChatMessage = {
role?: unknown;
content?: unknown;
};
export function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
const role = typeof message.role === "string" ? message.role : "";
const shouldSanitize = role === "assistant";
const text = extractTextFromChatContent(message.content, {
sanitizeText: shouldSanitize ? sanitizeTextContent : undefined,
});
return text ? { role, text } : null;
}
function formatLogLines(messages: ChatMessage[]) {
const lines: string[] = [];
for (const msg of messages) {
const extracted = extractMessageText(msg);
if (!extracted) {
continue;
}
const label = extracted.role === "assistant" ? "Assistant" : "User";
lines.push(`${label}: ${extracted.text}`);
}
return lines;
}
type SessionStoreCache = Map<string, Record<string, SessionEntry>>;
function loadSubagentSessionEntry(
params: Parameters<CommandHandler>[0],
childKey: string,
storeCache?: SessionStoreCache,
) {
const parsed = parseAgentSessionKey(childKey);
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: parsed?.agentId });
let store = storeCache?.get(storePath);
if (!store) {
store = loadSessionStore(storePath);
storeCache?.set(storePath, store);
}
return { storePath, store, entry: store[childKey] };
}
export { extractMessageText };
export const handleSubagentsCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) {
return null;
}
const normalized = params.command.commandBodyNormalized;
const handledPrefix = normalized.startsWith(COMMAND)
? COMMAND
: normalized.startsWith(COMMAND_KILL)
? COMMAND_KILL
: normalized.startsWith(COMMAND_STEER)
? COMMAND_STEER
: normalized.startsWith(COMMAND_TELL)
? COMMAND_TELL
: null;
const handledPrefix = resolveHandledPrefix(normalized);
if (!handledPrefix) {
return null;
}
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring ${handledPrefix} from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
@@ -259,438 +42,50 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo
const rest = normalized.slice(handledPrefix.length).trim();
const restTokens = rest.split(/\s+/).filter(Boolean);
let action = "list";
if (handledPrefix === COMMAND) {
const [actionRaw] = restTokens;
action = actionRaw?.toLowerCase() || "list";
if (!ACTIONS.has(action)) {
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
}
restTokens.splice(0, 1);
} else if (handledPrefix === COMMAND_KILL) {
action = "kill";
} else {
action = "steer";
const action = resolveSubagentsAction({ handledPrefix, restTokens });
if (!action) {
return handleSubagentsHelpAction();
}
const requesterKey = resolveRequesterSessionKey(params, {
preferCommandTarget: action === "spawn",
});
if (!requesterKey) {
return { shouldContinue: false, reply: { text: "⚠️ Missing session key." } };
}
const runs = listSubagentRunsForRequester(requesterKey);
if (action === "help") {
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
return stopWithText("⚠️ Missing session key.");
}
if (action === "list") {
const sorted = sortSubagentRuns(runs);
const now = Date.now();
const recentCutoff = now - RECENT_WINDOW_MINUTES * 60_000;
const storeCache: SessionStoreCache = new Map();
let index = 1;
const mapRuns = (
entries: SubagentRunRecord[],
runtimeMs: (entry: SubagentRunRecord) => number,
) =>
entries.map((entry) => {
const { entry: sessionEntry } = loadSubagentSessionEntry(
params,
entry.childSessionKey,
storeCache,
);
const line = formatSubagentListLine({
entry,
index,
runtimeMs: runtimeMs(entry),
sessionEntry,
});
index += 1;
return line;
});
const activeEntries = sorted.filter((entry) => !entry.endedAt);
const activeLines = mapRuns(
activeEntries,
(entry) => now - (entry.startedAt ?? entry.createdAt),
);
const recentEntries = sorted.filter(
(entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff,
);
const recentLines = mapRuns(
recentEntries,
(entry) => (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt),
);
const ctx: SubagentsCommandContext = {
params,
handledPrefix,
requesterKey,
runs: listSubagentRunsForRequester(requesterKey),
restTokens,
};
const lines = ["active subagents:", "-----"];
if (activeLines.length === 0) {
lines.push("(none)");
} else {
lines.push(activeLines.join("\n"));
}
lines.push("", `recent subagents (last ${RECENT_WINDOW_MINUTES}m):`, "-----");
if (recentLines.length === 0) {
lines.push("(none)");
} else {
lines.push(recentLines.join("\n"));
}
return { shouldContinue: false, reply: { text: lines.join("\n") } };
switch (action) {
case "help":
return handleSubagentsHelpAction();
case "agents":
return handleSubagentsAgentsAction(ctx);
case "focus":
return await handleSubagentsFocusAction(ctx);
case "unfocus":
return handleSubagentsUnfocusAction(ctx);
case "list":
return handleSubagentsListAction(ctx);
case "kill":
return await handleSubagentsKillAction(ctx);
case "info":
return handleSubagentsInfoAction(ctx);
case "log":
return await handleSubagentsLogAction(ctx);
case "send":
return await handleSubagentsSendAction(ctx, false);
case "steer":
return await handleSubagentsSendAction(ctx, true);
case "spawn":
return await handleSubagentsSpawnAction(ctx);
default:
return handleSubagentsHelpAction();
}
if (action === "kill") {
const target = restTokens[0];
if (!target) {
return {
shouldContinue: false,
reply: {
text:
handledPrefix === COMMAND
? "Usage: /subagents kill <id|#|all>"
: "Usage: /kill <id|#|all>",
},
};
}
if (target === "all" || target === "*") {
stopSubagentsForRequester({
cfg: params.cfg,
requesterSessionKey: requesterKey,
});
return { shouldContinue: false };
}
const resolved = resolveSubagentTarget(runs, target);
if (!resolved.entry) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
};
}
if (resolved.entry.endedAt) {
return {
shouldContinue: false,
reply: { text: `${formatRunLabel(resolved.entry)} is already finished.` },
};
}
const childKey = resolved.entry.childSessionKey;
const { storePath, store, entry } = loadSubagentSessionEntry(params, childKey);
const sessionId = entry?.sessionId;
if (sessionId) {
abortEmbeddedPiRun(sessionId);
}
const cleared = clearSessionQueues([childKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
if (entry) {
entry.abortedLastRun = true;
entry.updatedAt = Date.now();
store[childKey] = entry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[childKey] = entry;
});
}
markSubagentRunTerminated({
runId: resolved.entry.runId,
childSessionKey: childKey,
reason: "killed",
});
// Cascade: also stop any sub-sub-agents spawned by this child.
stopSubagentsForRequester({
cfg: params.cfg,
requesterSessionKey: childKey,
});
return { shouldContinue: false };
}
if (action === "info") {
const target = restTokens[0];
if (!target) {
return { shouldContinue: false, reply: { text: " Usage: /subagents info <id|#>" } };
}
const resolved = resolveSubagentTarget(runs, target);
if (!resolved.entry) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
};
}
const run = resolved.entry;
const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey);
const runtime =
run.startedAt && Number.isFinite(run.startedAt)
? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a")
: "n/a";
const outcome = run.outcome
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
: "n/a";
const lines = [
" Subagent info",
`Status: ${resolveDisplayStatus(run)}`,
`Label: ${formatRunLabel(run)}`,
`Task: ${run.task}`,
`Run: ${run.runId}`,
`Session: ${run.childSessionKey}`,
`SessionId: ${sessionEntry?.sessionId ?? "n/a"}`,
`Transcript: ${sessionEntry?.sessionFile ?? "n/a"}`,
`Runtime: ${runtime}`,
`Created: ${formatTimestampWithAge(run.createdAt)}`,
`Started: ${formatTimestampWithAge(run.startedAt)}`,
`Ended: ${formatTimestampWithAge(run.endedAt)}`,
`Cleanup: ${run.cleanup}`,
run.archiveAtMs ? `Archive: ${formatTimestampWithAge(run.archiveAtMs)}` : undefined,
run.cleanupHandled ? "Cleanup handled: yes" : undefined,
`Outcome: ${outcome}`,
].filter(Boolean);
return { shouldContinue: false, reply: { text: lines.join("\n") } };
}
if (action === "log") {
const target = restTokens[0];
if (!target) {
return { shouldContinue: false, reply: { text: "📜 Usage: /subagents log <id|#> [limit]" } };
}
const includeTools = restTokens.some((token) => token.toLowerCase() === "tools");
const limitToken = restTokens.find((token) => /^\d+$/.test(token));
const limit = limitToken ? Math.min(200, Math.max(1, Number.parseInt(limitToken, 10))) : 20;
const resolved = resolveSubagentTarget(runs, target);
if (!resolved.entry) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
};
}
const history = await callGateway<{ messages: Array<unknown> }>({
method: "chat.history",
params: { sessionKey: resolved.entry.childSessionKey, limit },
});
const rawMessages = Array.isArray(history?.messages) ? history.messages : [];
const filtered = includeTools ? rawMessages : stripToolMessages(rawMessages);
const lines = formatLogLines(filtered as ChatMessage[]);
const header = `📜 Subagent log: ${formatRunLabel(resolved.entry)}`;
if (lines.length === 0) {
return { shouldContinue: false, reply: { text: `${header}\n(no messages)` } };
}
return { shouldContinue: false, reply: { text: [header, ...lines].join("\n") } };
}
if (action === "send" || action === "steer") {
const steerRequested = action === "steer";
const target = restTokens[0];
const message = restTokens.slice(1).join(" ").trim();
if (!target || !message) {
return {
shouldContinue: false,
reply: {
text: steerRequested
? handledPrefix === COMMAND
? "Usage: /subagents steer <id|#> <message>"
: `Usage: ${handledPrefix} <id|#> <message>`
: "Usage: /subagents send <id|#> <message>",
},
};
}
const resolved = resolveSubagentTarget(runs, target);
if (!resolved.entry) {
return {
shouldContinue: false,
reply: { text: `⚠️ ${resolved.error ?? "Unknown subagent."}` },
};
}
if (steerRequested && resolved.entry.endedAt) {
return {
shouldContinue: false,
reply: { text: `${formatRunLabel(resolved.entry)} is already finished.` },
};
}
const { entry: targetSessionEntry } = loadSubagentSessionEntry(
params,
resolved.entry.childSessionKey,
);
const targetSessionId =
typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim()
? targetSessionEntry.sessionId.trim()
: undefined;
if (steerRequested) {
// Suppress stale announce before interrupting the in-flight run.
markSubagentRunForSteerRestart(resolved.entry.runId);
// Force an immediate interruption and make steer the next run.
if (targetSessionId) {
abortEmbeddedPiRun(targetSessionId);
}
const cleared = clearSessionQueues([resolved.entry.childSessionKey, targetSessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
// Best effort: wait for the interrupted run to settle so the steer
// message is appended on the existing conversation state.
try {
await callGateway({
method: "agent.wait",
params: {
runId: resolved.entry.runId,
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
},
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
});
} catch {
// Continue even if wait fails; steer should still be attempted.
}
}
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
try {
const response = await callGateway<{ runId: string }>({
method: "agent",
params: {
message,
sessionKey: resolved.entry.childSessionKey,
sessionId: targetSessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_SUBAGENT,
timeout: 0,
},
timeoutMs: 10_000,
});
const responseRunId = typeof response?.runId === "string" ? response.runId : undefined;
if (responseRunId) {
runId = responseRunId;
}
} catch (err) {
if (steerRequested) {
// Replacement launch failed; restore announce behavior for the
// original run so completion is not silently suppressed.
clearSubagentRunSteerRestart(resolved.entry.runId);
}
const messageText =
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
return { shouldContinue: false, reply: { text: `send failed: ${messageText}` } };
}
if (steerRequested) {
replaceSubagentRunAfterSteer({
previousRunId: resolved.entry.runId,
nextRunId: runId,
fallback: resolved.entry,
runTimeoutSeconds: resolved.entry.runTimeoutSeconds ?? 0,
});
return {
shouldContinue: false,
reply: {
text: `steered ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`,
},
};
}
const waitMs = 30_000;
const wait = await callGateway<{ status?: string; error?: string }>({
method: "agent.wait",
params: { runId, timeoutMs: waitMs },
timeoutMs: waitMs + 2000,
});
if (wait?.status === "timeout") {
return {
shouldContinue: false,
reply: { text: `⏳ Subagent still running (run ${runId.slice(0, 8)}).` },
};
}
if (wait?.status === "error") {
const waitError = typeof wait.error === "string" ? wait.error : "unknown error";
return {
shouldContinue: false,
reply: {
text: `⚠️ Subagent error: ${waitError} (run ${runId.slice(0, 8)}).`,
},
};
}
const history = await callGateway<{ messages: Array<unknown> }>({
method: "chat.history",
params: { sessionKey: resolved.entry.childSessionKey, limit: 50 },
});
const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const replyText = last ? extractAssistantText(last) : undefined;
return {
shouldContinue: false,
reply: {
text:
replyText ?? `✅ Sent to ${formatRunLabel(resolved.entry)} (run ${runId.slice(0, 8)}).`,
},
};
}
if (action === "spawn") {
const agentId = restTokens[0];
// Parse remaining tokens: task text with optional --model and --thinking flags.
const taskParts: string[] = [];
let model: string | undefined;
let thinking: string | undefined;
for (let i = 1; i < restTokens.length; i++) {
if (restTokens[i] === "--model" && i + 1 < restTokens.length) {
i += 1;
model = restTokens[i];
} else if (restTokens[i] === "--thinking" && i + 1 < restTokens.length) {
i += 1;
thinking = restTokens[i];
} else {
taskParts.push(restTokens[i]);
}
}
const task = taskParts.join(" ").trim();
if (!agentId || !task) {
return {
shouldContinue: false,
reply: {
text: "Usage: /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
},
};
}
const commandTo = typeof params.command.to === "string" ? params.command.to.trim() : "";
const originatingTo =
typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : "";
const fallbackTo = typeof params.ctx.To === "string" ? params.ctx.To.trim() : "";
// OriginatingTo reflects the active conversation target and is safer than
// command.to for cross-surface command dispatch.
const normalizedTo = originatingTo || commandTo || fallbackTo || undefined;
const result = await spawnSubagentDirect(
{ task, agentId, model, thinking, cleanup: "keep", expectsCompletionMessage: true },
{
agentSessionKey: requesterKey,
agentChannel: params.ctx.OriginatingChannel ?? params.command.channel,
agentAccountId: params.ctx.AccountId,
agentTo: normalizedTo,
agentThreadId: params.ctx.MessageThreadId,
agentGroupId: params.sessionEntry?.groupId ?? null,
agentGroupChannel: params.sessionEntry?.groupChannel ?? null,
agentGroupSpace: params.sessionEntry?.space ?? null,
},
);
if (result.status === "accepted") {
return {
shouldContinue: false,
reply: {
text: `Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).`,
},
};
}
return {
shouldContinue: false,
reply: { text: `Spawn failed: ${result.error ?? result.status}` },
};
}
return { shouldContinue: false, reply: { text: buildSubagentsHelp() } };
};

View File

@@ -0,0 +1,55 @@
import { getThreadBindingManager } from "../../../discord/monitor/thread-bindings.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { formatRunLabel, sortSubagentRuns } from "../subagents-utils.js";
import {
type SubagentsCommandContext,
isDiscordSurface,
resolveDiscordAccountId,
stopWithText,
} from "./shared.js";
export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): CommandHandlerResult {
const { params, requesterKey, runs } = ctx;
const isDiscord = isDiscordSurface(params);
const accountId = isDiscord ? resolveDiscordAccountId(params) : undefined;
const threadBindings = accountId ? getThreadBindingManager(accountId) : null;
const visibleRuns = sortSubagentRuns(runs).filter((entry) => {
if (!entry.endedAt) {
return true;
}
return Boolean(threadBindings?.listBySessionKey(entry.childSessionKey)[0]);
});
const lines = ["agents:", "-----"];
if (visibleRuns.length === 0) {
lines.push("(none)");
} else {
let index = 1;
for (const entry of visibleRuns) {
const threadBinding = threadBindings?.listBySessionKey(entry.childSessionKey)[0];
const bindingText = threadBinding
? `thread:${threadBinding.threadId}`
: isDiscord
? "unbound"
: "bindings available on discord";
lines.push(`${index}. ${formatRunLabel(entry)} (${bindingText})`);
index += 1;
}
}
if (threadBindings) {
const acpBindings = threadBindings
.listBindings()
.filter((entry) => entry.targetKind === "acp" && entry.targetSessionKey === requesterKey);
if (acpBindings.length > 0) {
lines.push("", "acp/session bindings:", "-----");
for (const binding of acpBindings) {
lines.push(
`- ${binding.label ?? binding.targetSessionKey} (thread:${binding.threadId}, session:${binding.targetSessionKey})`,
);
}
}
}
return stopWithText(lines.join("\n"));
}

View File

@@ -0,0 +1,90 @@
import {
getThreadBindingManager,
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
} from "../../../discord/monitor/thread-bindings.js";
import type { CommandHandlerResult } from "../commands-types.js";
import {
type SubagentsCommandContext,
isDiscordSurface,
resolveDiscordAccountId,
resolveDiscordChannelIdForFocus,
resolveFocusTargetSession,
stopWithText,
} from "./shared.js";
export async function handleSubagentsFocusAction(
ctx: SubagentsCommandContext,
): Promise<CommandHandlerResult> {
const { params, runs, restTokens } = ctx;
if (!isDiscordSurface(params)) {
return stopWithText("⚠️ /focus is only available on Discord.");
}
const token = restTokens.join(" ").trim();
if (!token) {
return stopWithText("Usage: /focus <subagent-label|session-key|session-id|session-label>");
}
const accountId = resolveDiscordAccountId(params);
const threadBindings = getThreadBindingManager(accountId);
if (!threadBindings) {
return stopWithText("⚠️ Discord thread bindings are unavailable for this account.");
}
const focusTarget = await resolveFocusTargetSession({ runs, token });
if (!focusTarget) {
return stopWithText(`⚠️ Unable to resolve focus target: ${token}`);
}
const currentThreadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
const parentChannelId = currentThreadId ? undefined : resolveDiscordChannelIdForFocus(params);
if (!currentThreadId && !parentChannelId) {
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
}
const senderId = params.command.senderId?.trim() || "";
if (currentThreadId) {
const existingBinding = threadBindings.getByThreadId(currentThreadId);
if (
existingBinding &&
existingBinding.boundBy &&
existingBinding.boundBy !== "system" &&
senderId &&
senderId !== existingBinding.boundBy
) {
return stopWithText(`⚠️ Only ${existingBinding.boundBy} can refocus this thread.`);
}
}
const label = focusTarget.label || token;
const binding = await threadBindings.bindTarget({
threadId: currentThreadId || undefined,
channelId: parentChannelId,
createThread: !currentThreadId,
threadName: resolveThreadBindingThreadName({
agentId: focusTarget.agentId,
label,
}),
targetKind: focusTarget.targetKind,
targetSessionKey: focusTarget.targetSessionKey,
agentId: focusTarget.agentId,
label,
boundBy: senderId || "unknown",
introText: resolveThreadBindingIntroText({
agentId: focusTarget.agentId,
label,
sessionTtlMs: threadBindings.getSessionTtlMs(),
}),
});
if (!binding) {
return stopWithText("⚠️ Failed to bind a Discord thread to the target session.");
}
const actionText = currentThreadId
? `bound this thread to ${binding.targetSessionKey}`
: `created thread ${binding.threadId} and bound it to ${binding.targetSessionKey}`;
return stopWithText(`${actionText} (${binding.targetKind}).`);
}

View File

@@ -0,0 +1,6 @@
import type { CommandHandlerResult } from "../commands-types.js";
import { buildSubagentsHelp, stopWithText } from "./shared.js";
export function handleSubagentsHelpAction(): CommandHandlerResult {
return stopWithText(buildSubagentsHelp());
}

View File

@@ -0,0 +1,59 @@
import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
import { formatDurationCompact } from "../../../shared/subagents-format.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { formatRunLabel } from "../subagents-utils.js";
import {
type SubagentsCommandContext,
formatTimestampWithAge,
loadSubagentSessionEntry,
resolveDisplayStatus,
resolveSubagentEntryForToken,
stopWithText,
} from "./shared.js";
export function handleSubagentsInfoAction(ctx: SubagentsCommandContext): CommandHandlerResult {
const { params, runs, restTokens } = ctx;
const target = restTokens[0];
if (!target) {
return stopWithText(" Usage: /subagents info <id|#>");
}
const targetResolution = resolveSubagentEntryForToken(runs, target);
if ("reply" in targetResolution) {
return targetResolution.reply;
}
const run = targetResolution.entry;
const { entry: sessionEntry } = loadSubagentSessionEntry(params, run.childSessionKey, {
loadSessionStore,
resolveStorePath,
});
const runtime =
run.startedAt && Number.isFinite(run.startedAt)
? (formatDurationCompact((run.endedAt ?? Date.now()) - run.startedAt) ?? "n/a")
: "n/a";
const outcome = run.outcome
? `${run.outcome.status}${run.outcome.error ? ` (${run.outcome.error})` : ""}`
: "n/a";
const lines = [
" Subagent info",
`Status: ${resolveDisplayStatus(run)}`,
`Label: ${formatRunLabel(run)}`,
`Task: ${run.task}`,
`Run: ${run.runId}`,
`Session: ${run.childSessionKey}`,
`SessionId: ${sessionEntry?.sessionId ?? "n/a"}`,
`Transcript: ${sessionEntry?.sessionFile ?? "n/a"}`,
`Runtime: ${runtime}`,
`Created: ${formatTimestampWithAge(run.createdAt)}`,
`Started: ${formatTimestampWithAge(run.startedAt)}`,
`Ended: ${formatTimestampWithAge(run.endedAt)}`,
`Cleanup: ${run.cleanup}`,
run.archiveAtMs ? `Archive: ${formatTimestampWithAge(run.archiveAtMs)}` : undefined,
run.cleanupHandled ? "Cleanup handled: yes" : undefined,
`Outcome: ${outcome}`,
].filter(Boolean);
return stopWithText(lines.join("\n"));
}

View File

@@ -0,0 +1,86 @@
import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js";
import { markSubagentRunTerminated } from "../../../agents/subagent-registry.js";
import {
loadSessionStore,
resolveStorePath,
updateSessionStore,
} from "../../../config/sessions.js";
import { logVerbose } from "../../../globals.js";
import { stopSubagentsForRequester } from "../abort.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { clearSessionQueues } from "../queue.js";
import { formatRunLabel } from "../subagents-utils.js";
import {
type SubagentsCommandContext,
COMMAND,
loadSubagentSessionEntry,
resolveSubagentEntryForToken,
stopWithText,
} from "./shared.js";
export async function handleSubagentsKillAction(
ctx: SubagentsCommandContext,
): Promise<CommandHandlerResult> {
const { params, handledPrefix, requesterKey, runs, restTokens } = ctx;
const target = restTokens[0];
if (!target) {
return stopWithText(
handledPrefix === COMMAND ? "Usage: /subagents kill <id|#|all>" : "Usage: /kill <id|#|all>",
);
}
if (target === "all" || target === "*") {
stopSubagentsForRequester({
cfg: params.cfg,
requesterSessionKey: requesterKey,
});
return { shouldContinue: false };
}
const targetResolution = resolveSubagentEntryForToken(runs, target);
if ("reply" in targetResolution) {
return targetResolution.reply;
}
if (targetResolution.entry.endedAt) {
return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`);
}
const childKey = targetResolution.entry.childSessionKey;
const { storePath, store, entry } = loadSubagentSessionEntry(params, childKey, {
loadSessionStore,
resolveStorePath,
});
const sessionId = entry?.sessionId;
if (sessionId) {
abortEmbeddedPiRun(sessionId);
}
const cleared = clearSessionQueues([childKey, sessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents kill: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
if (entry) {
entry.abortedLastRun = true;
entry.updatedAt = Date.now();
store[childKey] = entry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[childKey] = entry;
});
}
markSubagentRunTerminated({
runId: targetResolution.entry.runId,
childSessionKey: childKey,
reason: "killed",
});
stopSubagentsForRequester({
cfg: params.cfg,
requesterSessionKey: childKey,
});
return { shouldContinue: false };
}

View File

@@ -0,0 +1,66 @@
import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { sortSubagentRuns } from "../subagents-utils.js";
import {
type SessionStoreCache,
type SubagentsCommandContext,
RECENT_WINDOW_MINUTES,
formatSubagentListLine,
loadSubagentSessionEntry,
stopWithText,
} from "./shared.js";
export function handleSubagentsListAction(ctx: SubagentsCommandContext): CommandHandlerResult {
const { params, runs } = ctx;
const sorted = sortSubagentRuns(runs);
const now = Date.now();
const recentCutoff = now - RECENT_WINDOW_MINUTES * 60_000;
const storeCache: SessionStoreCache = new Map();
let index = 1;
const mapRuns = (entries: typeof runs, runtimeMs: (entry: (typeof runs)[number]) => number) =>
entries.map((entry) => {
const { entry: sessionEntry } = loadSubagentSessionEntry(
params,
entry.childSessionKey,
{
loadSessionStore,
resolveStorePath,
},
storeCache,
);
const line = formatSubagentListLine({
entry,
index,
runtimeMs: runtimeMs(entry),
sessionEntry,
});
index += 1;
return line;
});
const activeEntries = sorted.filter((entry) => !entry.endedAt);
const activeLines = mapRuns(activeEntries, (entry) => now - (entry.startedAt ?? entry.createdAt));
const recentEntries = sorted.filter(
(entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff,
);
const recentLines = mapRuns(
recentEntries,
(entry) => (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt),
);
const lines = ["active subagents:", "-----"];
if (activeLines.length === 0) {
lines.push("(none)");
} else {
lines.push(activeLines.join("\n"));
}
lines.push("", `recent subagents (last ${RECENT_WINDOW_MINUTES}m):`, "-----");
if (recentLines.length === 0) {
lines.push("(none)");
} else {
lines.push(recentLines.join("\n"));
}
return stopWithText(lines.join("\n"));
}

View File

@@ -0,0 +1,43 @@
import { callGateway } from "../../../gateway/call.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { formatRunLabel } from "../subagents-utils.js";
import {
type ChatMessage,
type SubagentsCommandContext,
formatLogLines,
resolveSubagentEntryForToken,
stopWithText,
stripToolMessages,
} from "./shared.js";
export async function handleSubagentsLogAction(
ctx: SubagentsCommandContext,
): Promise<CommandHandlerResult> {
const { runs, restTokens } = ctx;
const target = restTokens[0];
if (!target) {
return stopWithText("📜 Usage: /subagents log <id|#> [limit]");
}
const includeTools = restTokens.some((token) => token.toLowerCase() === "tools");
const limitToken = restTokens.find((token) => /^\d+$/.test(token));
const limit = limitToken ? Math.min(200, Math.max(1, Number.parseInt(limitToken, 10))) : 20;
const targetResolution = resolveSubagentEntryForToken(runs, target);
if ("reply" in targetResolution) {
return targetResolution.reply;
}
const history = await callGateway<{ messages: Array<unknown> }>({
method: "chat.history",
params: { sessionKey: targetResolution.entry.childSessionKey, limit },
});
const rawMessages = Array.isArray(history?.messages) ? history.messages : [];
const filtered = includeTools ? rawMessages : stripToolMessages(rawMessages);
const lines = formatLogLines(filtered as ChatMessage[]);
const header = `📜 Subagent log: ${formatRunLabel(targetResolution.entry)}`;
if (lines.length === 0) {
return stopWithText(`${header}\n(no messages)`);
}
return stopWithText([header, ...lines].join("\n"));
}

View File

@@ -0,0 +1,159 @@
import crypto from "node:crypto";
import { AGENT_LANE_SUBAGENT } from "../../../agents/lanes.js";
import { abortEmbeddedPiRun } from "../../../agents/pi-embedded.js";
import {
clearSubagentRunSteerRestart,
replaceSubagentRunAfterSteer,
markSubagentRunForSteerRestart,
} from "../../../agents/subagent-registry.js";
import { loadSessionStore, resolveStorePath } from "../../../config/sessions.js";
import { callGateway } from "../../../gateway/call.js";
import { logVerbose } from "../../../globals.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../../../utils/message-channel.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { clearSessionQueues } from "../queue.js";
import { formatRunLabel } from "../subagents-utils.js";
import {
type SubagentsCommandContext,
COMMAND,
STEER_ABORT_SETTLE_TIMEOUT_MS,
extractAssistantText,
loadSubagentSessionEntry,
resolveSubagentEntryForToken,
stopWithText,
stripToolMessages,
} from "./shared.js";
export async function handleSubagentsSendAction(
ctx: SubagentsCommandContext,
steerRequested: boolean,
): Promise<CommandHandlerResult> {
const { params, handledPrefix, runs, restTokens } = ctx;
const target = restTokens[0];
const message = restTokens.slice(1).join(" ").trim();
if (!target || !message) {
return stopWithText(
steerRequested
? handledPrefix === COMMAND
? "Usage: /subagents steer <id|#> <message>"
: `Usage: ${handledPrefix} <id|#> <message>`
: "Usage: /subagents send <id|#> <message>",
);
}
const targetResolution = resolveSubagentEntryForToken(runs, target);
if ("reply" in targetResolution) {
return targetResolution.reply;
}
if (steerRequested && targetResolution.entry.endedAt) {
return stopWithText(`${formatRunLabel(targetResolution.entry)} is already finished.`);
}
const { entry: targetSessionEntry } = loadSubagentSessionEntry(
params,
targetResolution.entry.childSessionKey,
{
loadSessionStore,
resolveStorePath,
},
);
const targetSessionId =
typeof targetSessionEntry?.sessionId === "string" && targetSessionEntry.sessionId.trim()
? targetSessionEntry.sessionId.trim()
: undefined;
if (steerRequested) {
markSubagentRunForSteerRestart(targetResolution.entry.runId);
if (targetSessionId) {
abortEmbeddedPiRun(targetSessionId);
}
const cleared = clearSessionQueues([targetResolution.entry.childSessionKey, targetSessionId]);
if (cleared.followupCleared > 0 || cleared.laneCleared > 0) {
logVerbose(
`subagents steer: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`,
);
}
try {
await callGateway({
method: "agent.wait",
params: {
runId: targetResolution.entry.runId,
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS,
},
timeoutMs: STEER_ABORT_SETTLE_TIMEOUT_MS + 2_000,
});
} catch {
// Continue even if wait fails; steer should still be attempted.
}
}
const idempotencyKey = crypto.randomUUID();
let runId: string = idempotencyKey;
try {
const response = await callGateway<{ runId: string }>({
method: "agent",
params: {
message,
sessionKey: targetResolution.entry.childSessionKey,
sessionId: targetSessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
lane: AGENT_LANE_SUBAGENT,
timeout: 0,
},
timeoutMs: 10_000,
});
const responseRunId = typeof response?.runId === "string" ? response.runId : undefined;
if (responseRunId) {
runId = responseRunId;
}
} catch (err) {
if (steerRequested) {
clearSubagentRunSteerRestart(targetResolution.entry.runId);
}
const messageText =
err instanceof Error ? err.message : typeof err === "string" ? err : "error";
return stopWithText(`send failed: ${messageText}`);
}
if (steerRequested) {
replaceSubagentRunAfterSteer({
previousRunId: targetResolution.entry.runId,
nextRunId: runId,
fallback: targetResolution.entry,
runTimeoutSeconds: targetResolution.entry.runTimeoutSeconds ?? 0,
});
return stopWithText(
`steered ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`,
);
}
const waitMs = 30_000;
const wait = await callGateway<{ status?: string; error?: string }>({
method: "agent.wait",
params: { runId, timeoutMs: waitMs },
timeoutMs: waitMs + 2000,
});
if (wait?.status === "timeout") {
return stopWithText(`⏳ Subagent still running (run ${runId.slice(0, 8)}).`);
}
if (wait?.status === "error") {
const waitError = typeof wait.error === "string" ? wait.error : "unknown error";
return stopWithText(`⚠️ Subagent error: ${waitError} (run ${runId.slice(0, 8)}).`);
}
const history = await callGateway<{ messages: Array<unknown> }>({
method: "chat.history",
params: { sessionKey: targetResolution.entry.childSessionKey, limit: 50 },
});
const filtered = stripToolMessages(Array.isArray(history?.messages) ? history.messages : []);
const last = filtered.length > 0 ? filtered[filtered.length - 1] : undefined;
const replyText = last ? extractAssistantText(last) : undefined;
return stopWithText(
replyText ?? `✅ Sent to ${formatRunLabel(targetResolution.entry)} (run ${runId.slice(0, 8)}).`,
);
}

View File

@@ -0,0 +1,65 @@
import { spawnSubagentDirect } from "../../../agents/subagent-spawn.js";
import type { CommandHandlerResult } from "../commands-types.js";
import { type SubagentsCommandContext, stopWithText } from "./shared.js";
export async function handleSubagentsSpawnAction(
ctx: SubagentsCommandContext,
): Promise<CommandHandlerResult> {
const { params, requesterKey, restTokens } = ctx;
const agentId = restTokens[0];
const taskParts: string[] = [];
let model: string | undefined;
let thinking: string | undefined;
for (let i = 1; i < restTokens.length; i++) {
if (restTokens[i] === "--model" && i + 1 < restTokens.length) {
i += 1;
model = restTokens[i];
} else if (restTokens[i] === "--thinking" && i + 1 < restTokens.length) {
i += 1;
thinking = restTokens[i];
} else {
taskParts.push(restTokens[i]);
}
}
const task = taskParts.join(" ").trim();
if (!agentId || !task) {
return stopWithText(
"Usage: /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
);
}
const commandTo = typeof params.command.to === "string" ? params.command.to.trim() : "";
const originatingTo =
typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : "";
const fallbackTo = typeof params.ctx.To === "string" ? params.ctx.To.trim() : "";
const normalizedTo = originatingTo || commandTo || fallbackTo || undefined;
const result = await spawnSubagentDirect(
{
task,
agentId,
model,
thinking,
mode: "run",
cleanup: "keep",
expectsCompletionMessage: true,
},
{
agentSessionKey: requesterKey,
agentChannel: params.ctx.OriginatingChannel ?? params.command.channel,
agentAccountId: params.ctx.AccountId,
agentTo: normalizedTo,
agentThreadId: params.ctx.MessageThreadId,
agentGroupId: params.sessionEntry?.groupId ?? null,
agentGroupChannel: params.sessionEntry?.groupChannel ?? null,
agentGroupSpace: params.sessionEntry?.space ?? null,
},
);
if (result.status === "accepted") {
return stopWithText(
`Spawned subagent ${agentId} (session ${result.childSessionKey}, run ${result.runId?.slice(0, 8)}).`,
);
}
return stopWithText(`Spawn failed: ${result.error ?? result.status}`);
}

View File

@@ -0,0 +1,42 @@
import { getThreadBindingManager } from "../../../discord/monitor/thread-bindings.js";
import type { CommandHandlerResult } from "../commands-types.js";
import {
type SubagentsCommandContext,
isDiscordSurface,
resolveDiscordAccountId,
stopWithText,
} from "./shared.js";
export function handleSubagentsUnfocusAction(ctx: SubagentsCommandContext): CommandHandlerResult {
const { params } = ctx;
if (!isDiscordSurface(params)) {
return stopWithText("⚠️ /unfocus is only available on Discord.");
}
const threadId = params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId) : "";
if (!threadId.trim()) {
return stopWithText("⚠️ /unfocus must be run inside a Discord thread.");
}
const threadBindings = getThreadBindingManager(resolveDiscordAccountId(params));
if (!threadBindings) {
return stopWithText("⚠️ Discord thread bindings are unavailable for this account.");
}
const binding = threadBindings.getByThreadId(threadId);
if (!binding) {
return stopWithText(" This thread is not currently focused.");
}
const senderId = params.command.senderId?.trim() || "";
if (binding.boundBy && binding.boundBy !== "system" && senderId && senderId !== binding.boundBy) {
return stopWithText(`⚠️ Only ${binding.boundBy} can unfocus this thread.`);
}
threadBindings.unbindThread({
threadId,
reason: "manual",
sendFarewell: true,
});
return stopWithText("✅ Thread unfocused.");
}

View File

@@ -0,0 +1,432 @@
import type { SubagentRunRecord } from "../../../agents/subagent-registry.js";
import {
extractAssistantText,
resolveInternalSessionKey,
resolveMainSessionAlias,
sanitizeTextContent,
stripToolMessages,
} from "../../../agents/tools/sessions-helpers.js";
import type {
SessionEntry,
loadSessionStore as loadSessionStoreFn,
resolveStorePath as resolveStorePathFn,
} from "../../../config/sessions.js";
import { parseDiscordTarget } from "../../../discord/targets.js";
import { callGateway } from "../../../gateway/call.js";
import { formatTimeAgo } from "../../../infra/format-time/format-relative.ts";
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import { extractTextFromChatContent } from "../../../shared/chat-content.js";
import {
formatDurationCompact,
formatTokenUsageDisplay,
truncateLine,
} from "../../../shared/subagents-format.js";
import type { CommandHandler, CommandHandlerResult } from "../commands-types.js";
import {
formatRunLabel,
formatRunStatus,
resolveSubagentTargetFromRuns,
type SubagentTargetResolution,
} from "../subagents-utils.js";
export { extractAssistantText, stripToolMessages };
export const COMMAND = "/subagents";
export const COMMAND_KILL = "/kill";
export const COMMAND_STEER = "/steer";
export const COMMAND_TELL = "/tell";
export const COMMAND_FOCUS = "/focus";
export const COMMAND_UNFOCUS = "/unfocus";
export const COMMAND_AGENTS = "/agents";
export const ACTIONS = new Set([
"list",
"kill",
"log",
"send",
"steer",
"info",
"spawn",
"focus",
"unfocus",
"agents",
"help",
]);
export const RECENT_WINDOW_MINUTES = 30;
const SUBAGENT_TASK_PREVIEW_MAX = 110;
export const STEER_ABORT_SETTLE_TIMEOUT_MS = 5_000;
const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function compactLine(value: string) {
return value.replace(/\s+/g, " ").trim();
}
function formatTaskPreview(value: string) {
return truncateLine(compactLine(value), SUBAGENT_TASK_PREVIEW_MAX);
}
function resolveModelDisplay(
entry?: {
model?: unknown;
modelProvider?: unknown;
modelOverride?: unknown;
providerOverride?: unknown;
},
fallbackModel?: string,
) {
const model = typeof entry?.model === "string" ? entry.model.trim() : "";
const provider = typeof entry?.modelProvider === "string" ? entry.modelProvider.trim() : "";
let combined = model.includes("/") ? model : model && provider ? `${provider}/${model}` : model;
if (!combined) {
const overrideModel =
typeof entry?.modelOverride === "string" ? entry.modelOverride.trim() : "";
const overrideProvider =
typeof entry?.providerOverride === "string" ? entry.providerOverride.trim() : "";
combined = overrideModel.includes("/")
? overrideModel
: overrideModel && overrideProvider
? `${overrideProvider}/${overrideModel}`
: overrideModel;
}
if (!combined) {
combined = fallbackModel?.trim() || "";
}
if (!combined) {
return "model n/a";
}
const slash = combined.lastIndexOf("/");
if (slash >= 0 && slash < combined.length - 1) {
return combined.slice(slash + 1);
}
return combined;
}
export function resolveDisplayStatus(entry: SubagentRunRecord) {
const status = formatRunStatus(entry);
return status === "error" ? "failed" : status;
}
export function formatSubagentListLine(params: {
entry: SubagentRunRecord;
index: number;
runtimeMs: number;
sessionEntry?: SessionEntry;
}) {
const usageText = formatTokenUsageDisplay(params.sessionEntry);
const label = truncateLine(formatRunLabel(params.entry, { maxLength: 48 }), 48);
const task = formatTaskPreview(params.entry.task);
const runtime = formatDurationCompact(params.runtimeMs);
const status = resolveDisplayStatus(params.entry);
return `${params.index}. ${label} (${resolveModelDisplay(params.sessionEntry, params.entry.model)}, ${runtime}${usageText ? `, ${usageText}` : ""}) ${status}${task.toLowerCase() !== label.toLowerCase() ? ` - ${task}` : ""}`;
}
function formatTimestamp(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
return new Date(valueMs).toISOString();
}
export function formatTimestampWithAge(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
return "n/a";
}
return `${formatTimestamp(valueMs)} (${formatTimeAgo(Date.now() - valueMs, { fallback: "n/a" })})`;
}
export type SubagentsAction =
| "list"
| "kill"
| "log"
| "send"
| "steer"
| "info"
| "spawn"
| "focus"
| "unfocus"
| "agents"
| "help";
export type SubagentsCommandParams = Parameters<CommandHandler>[0];
export type SubagentsCommandContext = {
params: SubagentsCommandParams;
handledPrefix: string;
requesterKey: string;
runs: SubagentRunRecord[];
restTokens: string[];
};
export function stopWithText(text: string): CommandHandlerResult {
return { shouldContinue: false, reply: { text } };
}
export function stopWithUnknownTargetError(error?: string): CommandHandlerResult {
return stopWithText(`⚠️ ${error ?? "Unknown subagent."}`);
}
export function resolveSubagentTarget(
runs: SubagentRunRecord[],
token: string | undefined,
): SubagentTargetResolution {
return resolveSubagentTargetFromRuns({
runs,
token,
recentWindowMinutes: RECENT_WINDOW_MINUTES,
label: (entry) => formatRunLabel(entry),
errors: {
missingTarget: "Missing subagent id.",
invalidIndex: (value) => `Invalid subagent index: ${value}`,
unknownSession: (value) => `Unknown subagent session: ${value}`,
ambiguousLabel: (value) => `Ambiguous subagent label: ${value}`,
ambiguousLabelPrefix: (value) => `Ambiguous subagent label prefix: ${value}`,
ambiguousRunIdPrefix: (value) => `Ambiguous run id prefix: ${value}`,
unknownTarget: (value) => `Unknown subagent id: ${value}`,
},
});
}
export function resolveSubagentEntryForToken(
runs: SubagentRunRecord[],
token: string | undefined,
): { entry: SubagentRunRecord } | { reply: CommandHandlerResult } {
const resolved = resolveSubagentTarget(runs, token);
if (!resolved.entry) {
return { reply: stopWithUnknownTargetError(resolved.error) };
}
return { entry: resolved.entry };
}
export function resolveRequesterSessionKey(
params: SubagentsCommandParams,
opts?: { preferCommandTarget?: boolean },
): string | undefined {
const commandTarget = params.ctx.CommandTargetSessionKey?.trim();
const commandSession = params.sessionKey?.trim();
const raw = opts?.preferCommandTarget
? commandTarget || commandSession
: commandSession || commandTarget;
if (!raw) {
return undefined;
}
const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
return resolveInternalSessionKey({ key: raw, alias, mainKey });
}
export function resolveHandledPrefix(normalized: string): string | null {
return normalized.startsWith(COMMAND)
? COMMAND
: normalized.startsWith(COMMAND_KILL)
? COMMAND_KILL
: normalized.startsWith(COMMAND_STEER)
? COMMAND_STEER
: normalized.startsWith(COMMAND_TELL)
? COMMAND_TELL
: normalized.startsWith(COMMAND_FOCUS)
? COMMAND_FOCUS
: normalized.startsWith(COMMAND_UNFOCUS)
? COMMAND_UNFOCUS
: normalized.startsWith(COMMAND_AGENTS)
? COMMAND_AGENTS
: null;
}
export function resolveSubagentsAction(params: {
handledPrefix: string;
restTokens: string[];
}): SubagentsAction | null {
if (params.handledPrefix === COMMAND) {
const [actionRaw] = params.restTokens;
const action = (actionRaw?.toLowerCase() || "list") as SubagentsAction;
if (!ACTIONS.has(action)) {
return null;
}
params.restTokens.splice(0, 1);
return action;
}
if (params.handledPrefix === COMMAND_KILL) {
return "kill";
}
if (params.handledPrefix === COMMAND_FOCUS) {
return "focus";
}
if (params.handledPrefix === COMMAND_UNFOCUS) {
return "unfocus";
}
if (params.handledPrefix === COMMAND_AGENTS) {
return "agents";
}
return "steer";
}
export type FocusTargetResolution = {
targetKind: "subagent" | "acp";
targetSessionKey: string;
agentId: string;
label?: string;
};
export function isDiscordSurface(params: SubagentsCommandParams): boolean {
const channel =
params.ctx.OriginatingChannel ??
params.command.channel ??
params.ctx.Surface ??
params.ctx.Provider;
return (
String(channel ?? "")
.trim()
.toLowerCase() === "discord"
);
}
export function resolveDiscordAccountId(params: SubagentsCommandParams): string {
const accountId = typeof params.ctx.AccountId === "string" ? params.ctx.AccountId.trim() : "";
return accountId || "default";
}
export function resolveDiscordChannelIdForFocus(
params: SubagentsCommandParams,
): string | undefined {
const toCandidates = [
typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : "",
typeof params.command.to === "string" ? params.command.to.trim() : "",
typeof params.ctx.To === "string" ? params.ctx.To.trim() : "",
].filter(Boolean);
for (const candidate of toCandidates) {
try {
const target = parseDiscordTarget(candidate, { defaultKind: "channel" });
if (target?.kind === "channel" && target.id) {
return target.id;
}
} catch {
// Ignore parse failures and try the next candidate.
}
}
return undefined;
}
export async function resolveFocusTargetSession(params: {
runs: SubagentRunRecord[];
token: string;
}): Promise<FocusTargetResolution | null> {
const subagentMatch = resolveSubagentTarget(params.runs, params.token);
if (subagentMatch.entry) {
const key = subagentMatch.entry.childSessionKey;
const parsed = parseAgentSessionKey(key);
return {
targetKind: "subagent",
targetSessionKey: key,
agentId: parsed?.agentId ?? "main",
label: formatRunLabel(subagentMatch.entry),
};
}
const token = params.token.trim();
if (!token) {
return null;
}
const attempts: Array<Record<string, string>> = [];
attempts.push({ key: token });
if (SESSION_ID_RE.test(token)) {
attempts.push({ sessionId: token });
}
attempts.push({ label: token });
for (const attempt of attempts) {
try {
const resolved = await callGateway<{ key?: string }>({
method: "sessions.resolve",
params: attempt,
});
const key = typeof resolved?.key === "string" ? resolved.key.trim() : "";
if (!key) {
continue;
}
const parsed = parseAgentSessionKey(key);
return {
targetKind: key.includes(":subagent:") ? "subagent" : "acp",
targetSessionKey: key,
agentId: parsed?.agentId ?? "main",
label: token,
};
} catch {
// Try the next resolution strategy.
}
}
return null;
}
export function buildSubagentsHelp() {
return [
"Subagents",
"Usage:",
"- /subagents list",
"- /subagents kill <id|#|all>",
"- /subagents log <id|#> [limit] [tools]",
"- /subagents info <id|#>",
"- /subagents send <id|#> <message>",
"- /subagents steer <id|#> <message>",
"- /subagents spawn <agentId> <task> [--model <model>] [--thinking <level>]",
"- /focus <subagent-label|session-key|session-id|session-label>",
"- /unfocus",
"- /agents",
"- /session ttl <duration|off>",
"- /kill <id|#|all>",
"- /steer <id|#> <message>",
"- /tell <id|#> <message>",
"",
"Ids: use the list index (#), runId/session prefix, label, or full session key.",
].join("\n");
}
export type ChatMessage = {
role?: unknown;
content?: unknown;
};
export function extractMessageText(message: ChatMessage): { role: string; text: string } | null {
const role = typeof message.role === "string" ? message.role : "";
const shouldSanitize = role === "assistant";
const text = extractTextFromChatContent(message.content, {
sanitizeText: shouldSanitize ? sanitizeTextContent : undefined,
});
return text ? { role, text } : null;
}
export function formatLogLines(messages: ChatMessage[]) {
const lines: string[] = [];
for (const msg of messages) {
const extracted = extractMessageText(msg);
if (!extracted) {
continue;
}
const label = extracted.role === "assistant" ? "Assistant" : "User";
lines.push(`${label}: ${extracted.text}`);
}
return lines;
}
export type SessionStoreCache = Map<string, Record<string, SessionEntry>>;
export function loadSubagentSessionEntry(
params: SubagentsCommandParams,
childKey: string,
loaders: {
loadSessionStore: typeof loadSessionStoreFn;
resolveStorePath: typeof resolveStorePathFn;
},
storeCache?: SessionStoreCache,
) {
const parsed = parseAgentSessionKey(childKey);
const storePath = loaders.resolveStorePath(params.cfg.session?.store, {
agentId: parsed?.agentId,
});
let store = storeCache?.get(storePath);
if (!store) {
store = loaders.loadSessionStore(storePath);
storeCache?.set(storePath, store);
}
return { storePath, store, entry: store[childKey] };
}

View File

@@ -2,6 +2,7 @@ import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js";
import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js";
import type { ReplyToMode } from "../../config/types.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
import { normalizeOptionalAccountId } from "../../routing/account-id.js";
import type { OriginatingChannelType } from "../templating.js";
import type { ReplyPayload } from "../types.js";
import { extractReplyToTag } from "./reply-tags.js";
@@ -120,11 +121,6 @@ export function filterMessagingToolMediaDuplicates(params: {
});
}
function normalizeAccountId(value?: string): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
}
export function shouldSuppressMessagingToolReplies(params: {
messageProvider?: string;
messagingToolSentTargets?: MessagingToolSend[];
@@ -139,7 +135,7 @@ export function shouldSuppressMessagingToolReplies(params: {
if (!originTarget) {
return false;
}
const originAccount = normalizeAccountId(params.accountId);
const originAccount = normalizeOptionalAccountId(params.accountId);
const sentTargets = params.messagingToolSentTargets ?? [];
if (sentTargets.length === 0) {
return false;
@@ -155,7 +151,7 @@ export function shouldSuppressMessagingToolReplies(params: {
if (!targetKey) {
return false;
}
const targetAccount = normalizeAccountId(target.accountId);
const targetAccount = normalizeOptionalAccountId(target.accountId);
if (originAccount && targetAccount && originAccount !== targetAccount) {
return false;
}

View File

@@ -11,6 +11,7 @@ import {
evaluateSessionFreshness,
type GroupKeyResolution,
loadSessionStore,
resolveAndPersistSessionFile,
resolveChannelResetConfig,
resolveThreadFlag,
resolveSessionResetPolicy,
@@ -354,13 +355,21 @@ export async function initSessionState(params: {
console.warn(`[session-init] forked session created: file=${forked.sessionFile}`);
}
}
if (!sessionEntry.sessionFile) {
sessionEntry.sessionFile = resolveSessionTranscriptPath(
sessionEntry.sessionId,
agentId,
ctx.MessageThreadId,
);
}
const fallbackSessionFile = !sessionEntry.sessionFile
? resolveSessionTranscriptPath(sessionEntry.sessionId, agentId, ctx.MessageThreadId)
: undefined;
const resolvedSessionFile = await resolveAndPersistSessionFile({
sessionId: sessionEntry.sessionId,
sessionKey,
sessionStore,
storePath,
sessionEntry,
agentId,
sessionsDir: path.dirname(storePath),
fallbackSessionFile,
activeSessionKey: sessionKey,
});
sessionEntry = resolvedSessionFile.sessionEntry;
if (isNewSession) {
sessionEntry.compactionCount = 0;
sessionEntry.memoryFlushCompactionCount = undefined;