();
for (const seed of seedEntries) {
@@ -111,6 +167,7 @@ for (const seed of seedEntries) {
apiByLogin.set(key, user);
const existing = entriesByKey.get(key);
if (!existing) {
+ const fd = firstCommitByLogin.get(key) ?? "";
entriesByKey.set(key, {
key,
login: user.login,
@@ -118,6 +175,10 @@ for (const seed of seedEntries) {
html_url: user.html_url,
avatar_url: user.avatar_url,
lines: 0,
+ commits: 0,
+ prs: 0,
+ score: 0,
+ firstCommitDate: fd,
});
} else {
existing.display = existing.display || seed.display;
@@ -150,28 +211,40 @@ for (const item of contributors) {
const existing = entriesByKey.get(key);
if (!existing) {
- const lines = linesByLogin.get(key) ?? 0;
- const contributions = contributionsByLogin.get(key) ?? 0;
+ const loc = linesByLogin.get(key) ?? 0;
+ const commits = contributionsByLogin.get(key) ?? 0;
+ const prs = prsByLogin.get(key) ?? 0;
+ const fd = firstCommitByLogin.get(key) ?? "";
entriesByKey.set(key, {
key,
login: user.login,
display: pickDisplay(baseName, user.login),
html_url: user.html_url,
avatar_url: normalizeAvatar(user.avatar_url),
- lines: lines > 0 ? lines : contributions,
+ lines: loc > 0 ? loc : commits,
+ commits,
+ prs,
+ score: computeScore(loc, commits, prs, fd),
+ firstCommitDate: fd,
});
} else {
existing.login = user.login;
existing.display = pickDisplay(baseName, user.login, existing.display);
existing.html_url = user.html_url;
existing.avatar_url = normalizeAvatar(user.avatar_url);
- const lines = linesByLogin.get(key) ?? 0;
- const contributions = contributionsByLogin.get(key) ?? 0;
- existing.lines = Math.max(existing.lines, lines > 0 ? lines : contributions);
+ const loc = linesByLogin.get(key) ?? 0;
+ const commits = contributionsByLogin.get(key) ?? 0;
+ const prs = prsByLogin.get(key) ?? 0;
+ const fd = firstCommitByLogin.get(key) ?? existing.firstCommitDate;
+ existing.lines = Math.max(existing.lines, loc > 0 ? loc : commits);
+ existing.commits = Math.max(existing.commits, commits);
+ existing.prs = Math.max(existing.prs, prs);
+ existing.firstCommitDate = fd || existing.firstCommitDate;
+ existing.score = Math.max(existing.score, computeScore(loc, commits, prs, fd));
}
}
-for (const [login, lines] of linesByLogin.entries()) {
+for (const [login, loc] of linesByLogin.entries()) {
if (entriesByKey.has(login)) {
continue;
}
@@ -180,14 +253,20 @@ for (const [login, lines] of linesByLogin.entries()) {
user = fetchUser(login) || undefined;
}
if (user) {
- const contributions = contributionsByLogin.get(login) ?? 0;
+ const commits = contributionsByLogin.get(login) ?? 0;
+ const prs = prsByLogin.get(login) ?? 0;
+ const fd = firstCommitByLogin.get(login) ?? "";
entriesByKey.set(login, {
key: login,
login: user.login,
display: displayName[user.login.toLowerCase()] ?? user.login,
html_url: user.html_url,
avatar_url: normalizeAvatar(user.avatar_url),
- lines: lines > 0 ? lines : contributions,
+ lines: loc > 0 ? loc : commits,
+ commits,
+ prs,
+ score: computeScore(loc, commits, prs, fd),
+ firstCommitDate: fd,
});
}
}
@@ -195,22 +274,22 @@ for (const [login, lines] of linesByLogin.entries()) {
const entries = Array.from(entriesByKey.values());
entries.sort((a, b) => {
- if (b.lines !== a.lines) {
- return b.lines - a.lines;
+ if (b.score !== a.score) {
+ return b.score - a.score;
}
return a.display.localeCompare(b.display);
});
-const lines: string[] = [];
+const htmlLines: string[] = [];
for (let i = 0; i < entries.length; i += PER_LINE) {
const chunk = entries.slice(i, i + PER_LINE);
const parts = chunk.map((entry) => {
return `
`;
});
- lines.push(` ${parts.join(" ")}`);
+ htmlLines.push(` ${parts.join(" ")}`);
}
-const block = `${lines.join("\n")}\n`;
+const block = `${htmlLines.join("\n")}\n`;
const readme = readFileSync(readmePath, "utf8");
const start = readme.indexOf('');
const end = readme.indexOf("
", start);
@@ -223,6 +302,24 @@ const next = `${readme.slice(0, start)}\n${block}${readme.slice(
writeFileSync(readmePath, next);
console.log(`Updated README clawtributors: ${entries.length} entries`);
+console.log(`\nTop 25 by composite score: (commits*2 + PRs*10 + sqrt(LOC)) * tenure`);
+console.log(` tenure = 1.0 + (days_since_first_commit / repo_age)^2 * 0.5`);
+console.log(
+ `${"#".padStart(3)} ${"login".padEnd(24)} ${"score".padStart(8)} ${"tenure".padStart(7)} ${"commits".padStart(8)} ${"PRs".padStart(6)} ${"LOC".padStart(10)} first commit`,
+);
+console.log("-".repeat(85));
+for (const entry of entries.slice(0, 25)) {
+ const login = (entry.login ?? entry.key).slice(0, 24);
+ const fd = entry.firstCommitDate || "?";
+ const daysIn =
+ fd !== "?" ? Math.max(0, (now - new Date(fd.slice(0, 10)).getTime()) / 86_400_000) : 0;
+ const tr = Math.min(1, daysIn / repoAgeDays);
+ const tenure = 1.0 + tr * tr * 0.5;
+ console.log(
+ `${entries.indexOf(entry) + 1}`.padStart(3) +
+ ` ${login.padEnd(24)} ${entry.score.toFixed(0).padStart(8)} ${tenure.toFixed(2).padStart(6)}x ${String(entry.commits).padStart(8)} ${String(entry.prs).padStart(6)} ${String(entry.lines).padStart(10)} ${fd}`,
+ );
+}
function run(cmd: string): string {
return execSync(cmd, {
diff --git a/scripts/update-clawtributors.types.ts b/scripts/update-clawtributors.types.ts
index 98526bc8a41..631060d4655 100644
--- a/scripts/update-clawtributors.types.ts
+++ b/scripts/update-clawtributors.types.ts
@@ -29,4 +29,8 @@ export type Entry = {
html_url: string;
avatar_url: string;
lines: number;
+ commits: number;
+ prs: number;
+ score: number;
+ firstCommitDate: string;
};
diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts
index 8cc029b8e45..9b07fafc4da 100644
--- a/src/agents/openclaw-tools.sessions.test.ts
+++ b/src/agents/openclaw-tools.sessions.test.ts
@@ -876,6 +876,59 @@ describe("sessions tools", () => {
expect(details.text).toContain("recent (last 30m):");
});
+ it("subagents list keeps ended orchestrators active while descendants are pending", async () => {
+ resetSubagentRegistryForTests();
+ const now = Date.now();
+ addSubagentRunForTests({
+ runId: "run-orchestrator-ended",
+ childSessionKey: "agent:main:subagent:orchestrator-ended",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "orchestrate child workers",
+ cleanup: "keep",
+ createdAt: now - 5 * 60_000,
+ startedAt: now - 5 * 60_000,
+ endedAt: now - 4 * 60_000,
+ outcome: { status: "ok" },
+ });
+ addSubagentRunForTests({
+ runId: "run-orchestrator-child-active",
+ childSessionKey: "agent:main:subagent:orchestrator-ended:subagent:child",
+ requesterSessionKey: "agent:main:subagent:orchestrator-ended",
+ requesterDisplayKey: "subagent:orchestrator-ended",
+ task: "child worker still running",
+ cleanup: "keep",
+ createdAt: now - 60_000,
+ startedAt: now - 60_000,
+ });
+
+ const tool = createOpenClawTools({
+ agentSessionKey: "agent:main:main",
+ }).find((candidate) => candidate.name === "subagents");
+ expect(tool).toBeDefined();
+ if (!tool) {
+ throw new Error("missing subagents tool");
+ }
+
+ const result = await tool.execute("call-subagents-list-orchestrator", { action: "list" });
+ const details = result.details as {
+ status?: string;
+ active?: Array<{ runId?: string; status?: string }>;
+ recent?: Array<{ runId?: string }>;
+ };
+
+ expect(details.status).toBe("ok");
+ expect(details.active).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ runId: "run-orchestrator-ended",
+ status: "active",
+ }),
+ ]),
+ );
+ expect(details.recent?.find((entry) => entry.runId === "run-orchestrator-ended")).toBeFalsy();
+ });
+
it("subagents list usage separates io tokens from prompt/cache", async () => {
resetSubagentRegistryForTests();
const now = Date.now();
diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts
index dc0d492f02d..c82151e6515 100644
--- a/src/agents/subagent-announce.format.e2e.test.ts
+++ b/src/agents/subagent-announce.format.e2e.test.ts
@@ -34,6 +34,8 @@ const embeddedRunMock = {
const subagentRegistryMock = {
isSubagentSessionRunActive: vi.fn(() => true),
countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0),
+ countPendingDescendantRuns: vi.fn((_sessionKey: string) => 0),
+ countPendingDescendantRunsExcludingRun: vi.fn((_sessionKey: string, _runId: string) => 0),
resolveRequesterForChildSession: vi.fn((_sessionKey: string): RequesterResolution => null),
};
const subagentDeliveryTargetHookMock = vi.fn(
@@ -172,6 +174,16 @@ describe("subagent announce formatting", () => {
embeddedRunMock.waitForEmbeddedPiRunEnd.mockClear().mockResolvedValue(true);
subagentRegistryMock.isSubagentSessionRunActive.mockClear().mockReturnValue(true);
subagentRegistryMock.countActiveDescendantRuns.mockClear().mockReturnValue(0);
+ subagentRegistryMock.countPendingDescendantRuns
+ .mockClear()
+ .mockImplementation((sessionKey: string) =>
+ subagentRegistryMock.countActiveDescendantRuns(sessionKey),
+ );
+ subagentRegistryMock.countPendingDescendantRunsExcludingRun
+ .mockClear()
+ .mockImplementation((sessionKey: string, _runId: string) =>
+ subagentRegistryMock.countPendingDescendantRuns(sessionKey),
+ );
subagentRegistryMock.resolveRequesterForChildSession.mockClear().mockReturnValue(null);
hasSubagentDeliveryTargetHook = false;
hookRunnerMock.hasHooks.mockClear();
@@ -408,6 +420,45 @@ describe("subagent announce formatting", () => {
expect(msg).not.toContain("Convert the result above into your normal assistant voice");
});
+ it("keeps direct completion send when only the announcing run itself is pending", async () => {
+ sessionStore = {
+ "agent:main:subagent:test": {
+ sessionId: "child-session-self-pending",
+ },
+ "agent:main:main": {
+ sessionId: "requester-session-self-pending",
+ },
+ };
+ chatHistoryMock.mockResolvedValueOnce({
+ messages: [{ role: "assistant", content: [{ type: "text", text: "final answer: done" }] }],
+ });
+ subagentRegistryMock.countPendingDescendantRuns.mockImplementation((sessionKey: string) =>
+ sessionKey === "agent:main:main" ? 1 : 0,
+ );
+ subagentRegistryMock.countPendingDescendantRunsExcludingRun.mockImplementation(
+ (sessionKey: string, runId: string) =>
+ sessionKey === "agent:main:main" && runId === "run-direct-self-pending" ? 0 : 1,
+ );
+
+ const didAnnounce = await runSubagentAnnounceFlow({
+ childSessionKey: "agent:main:subagent:test",
+ childRunId: "run-direct-self-pending",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" },
+ ...defaultOutcomeAnnounce,
+ expectsCompletionMessage: true,
+ });
+
+ expect(didAnnounce).toBe(true);
+ expect(subagentRegistryMock.countPendingDescendantRunsExcludingRun).toHaveBeenCalledWith(
+ "agent:main:main",
+ "run-direct-self-pending",
+ );
+ expect(sendSpy).toHaveBeenCalledTimes(1);
+ expect(agentSpy).not.toHaveBeenCalled();
+ });
+
it("suppresses completion delivery when subagent reply is ANNOUNCE_SKIP", async () => {
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts
index 00f779c3314..996c34b0e6e 100644
--- a/src/agents/subagent-announce.timeout.test.ts
+++ b/src/agents/subagent-announce.timeout.test.ts
@@ -53,6 +53,7 @@ vi.mock("./pi-embedded.js", () => ({
vi.mock("./subagent-registry.js", () => ({
countActiveDescendantRuns: () => 0,
+ countPendingDescendantRuns: () => 0,
isSubagentSessionRunActive: () => true,
resolveRequesterForChildSession: () => null,
}));
diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts
index 5932594d301..3b45234ea12 100644
--- a/src/agents/subagent-announce.ts
+++ b/src/agents/subagent-announce.ts
@@ -728,6 +728,7 @@ async function sendSubagentAnnounceDirectly(params: {
completionRouteMode?: "bound" | "fallback" | "hook";
spawnMode?: SpawnSubagentMode;
directIdempotencyKey: string;
+ currentRunId?: string;
completionDirectOrigin?: DeliveryContext;
directOrigin?: DeliveryContext;
requesterIsSubagent: boolean;
@@ -770,19 +771,35 @@ async function sendSubagentAnnounceDirectly(params: {
(params.completionRouteMode === "bound" || params.completionRouteMode === "hook");
let shouldSendCompletionDirectly = true;
if (!forceBoundSessionDirectDelivery) {
- let activeDescendantRuns = 0;
+ let pendingDescendantRuns = 0;
try {
- const { countActiveDescendantRuns } = await import("./subagent-registry.js");
- activeDescendantRuns = Math.max(
- 0,
- countActiveDescendantRuns(canonicalRequesterSessionKey),
- );
+ const {
+ countPendingDescendantRuns,
+ countPendingDescendantRunsExcludingRun,
+ countActiveDescendantRuns,
+ } = await import("./subagent-registry.js");
+ if (params.currentRunId && typeof countPendingDescendantRunsExcludingRun === "function") {
+ pendingDescendantRuns = Math.max(
+ 0,
+ countPendingDescendantRunsExcludingRun(
+ canonicalRequesterSessionKey,
+ params.currentRunId,
+ ),
+ );
+ } else {
+ pendingDescendantRuns = Math.max(
+ 0,
+ typeof countPendingDescendantRuns === "function"
+ ? countPendingDescendantRuns(canonicalRequesterSessionKey)
+ : countActiveDescendantRuns(canonicalRequesterSessionKey),
+ );
+ }
} catch {
// Best-effort only; when unavailable keep historical direct-send behavior.
}
// Keep non-bound completion announcements coordinated via requester
- // session routing while sibling/descendant runs are still active.
- if (activeDescendantRuns > 0) {
+ // session routing while sibling or descendant runs are still pending.
+ if (pendingDescendantRuns > 0) {
shouldSendCompletionDirectly = false;
}
}
@@ -899,6 +916,7 @@ async function deliverSubagentAnnouncement(params: {
completionRouteMode?: "bound" | "fallback" | "hook";
spawnMode?: SpawnSubagentMode;
directIdempotencyKey: string;
+ currentRunId?: string;
signal?: AbortSignal;
}): Promise {
return await runSubagentAnnounceDispatch({
@@ -922,6 +940,7 @@ async function deliverSubagentAnnouncement(params: {
completionMessage: params.completionMessage,
internalEvents: params.internalEvents,
directIdempotencyKey: params.directIdempotencyKey,
+ currentRunId: params.currentRunId,
completionDirectOrigin: params.completionDirectOrigin,
completionRouteMode: params.completionRouteMode,
spawnMode: params.spawnMode,
@@ -1203,16 +1222,23 @@ export async function runSubagentAnnounceFlow(params: {
let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey);
- let activeChildDescendantRuns = 0;
+ let pendingChildDescendantRuns = 0;
try {
- const { countActiveDescendantRuns } = await import("./subagent-registry.js");
- activeChildDescendantRuns = Math.max(0, countActiveDescendantRuns(params.childSessionKey));
+ const { countPendingDescendantRuns, countActiveDescendantRuns } =
+ await import("./subagent-registry.js");
+ pendingChildDescendantRuns = Math.max(
+ 0,
+ typeof countPendingDescendantRuns === "function"
+ ? countPendingDescendantRuns(params.childSessionKey)
+ : countActiveDescendantRuns(params.childSessionKey),
+ );
} catch {
// Best-effort only; fall back to direct announce behavior when unavailable.
}
- if (activeChildDescendantRuns > 0) {
- // The finished run still has active descendant subagents. Defer announcing
- // this run until descendants settle so we avoid posting in-progress updates.
+ if (pendingChildDescendantRuns > 0) {
+ // The finished run still has pending descendant subagents (either active,
+ // or ended but still finishing their own announce and cleanup flow). Defer
+ // announcing this run until descendants fully settle.
shouldDeleteChildSession = false;
return false;
}
@@ -1383,6 +1409,7 @@ export async function runSubagentAnnounceFlow(params: {
completionRouteMode: completionResolution.routeMode,
spawnMode: params.spawnMode,
directIdempotencyKey,
+ currentRunId: params.childRunId,
signal: params.signal,
});
// Cron delivery state should only be marked as delivered when we have a
diff --git a/src/agents/subagent-registry-cleanup.test.ts b/src/agents/subagent-registry-cleanup.test.ts
new file mode 100644
index 00000000000..ed97add7162
--- /dev/null
+++ b/src/agents/subagent-registry-cleanup.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+import { resolveDeferredCleanupDecision } from "./subagent-registry-cleanup.js";
+import type { SubagentRunRecord } from "./subagent-registry.types.js";
+
+function makeEntry(overrides: Partial = {}): SubagentRunRecord {
+ return {
+ runId: "run-1",
+ childSessionKey: "agent:main:subagent:child",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "test",
+ cleanup: "keep",
+ createdAt: 0,
+ endedAt: 1_000,
+ ...overrides,
+ };
+}
+
+describe("resolveDeferredCleanupDecision", () => {
+ const now = 2_000;
+
+ it("defers completion-message cleanup while descendants are still pending", () => {
+ const decision = resolveDeferredCleanupDecision({
+ entry: makeEntry({ expectsCompletionMessage: true }),
+ now,
+ activeDescendantRuns: 2,
+ announceExpiryMs: 5 * 60_000,
+ announceCompletionHardExpiryMs: 30 * 60_000,
+ maxAnnounceRetryCount: 3,
+ deferDescendantDelayMs: 1_000,
+ resolveAnnounceRetryDelayMs: () => 2_000,
+ });
+
+ expect(decision).toEqual({ kind: "defer-descendants", delayMs: 1_000 });
+ });
+
+ it("hard-expires completion-message cleanup when descendants never settle", () => {
+ const decision = resolveDeferredCleanupDecision({
+ entry: makeEntry({ expectsCompletionMessage: true, endedAt: now - (30 * 60_000 + 1) }),
+ now,
+ activeDescendantRuns: 1,
+ announceExpiryMs: 5 * 60_000,
+ announceCompletionHardExpiryMs: 30 * 60_000,
+ maxAnnounceRetryCount: 3,
+ deferDescendantDelayMs: 1_000,
+ resolveAnnounceRetryDelayMs: () => 2_000,
+ });
+
+ expect(decision).toEqual({ kind: "give-up", reason: "expiry" });
+ });
+
+ it("keeps regular expiry behavior for non-completion flows", () => {
+ const decision = resolveDeferredCleanupDecision({
+ entry: makeEntry({ expectsCompletionMessage: false, endedAt: now - (5 * 60_000 + 1) }),
+ now,
+ activeDescendantRuns: 0,
+ announceExpiryMs: 5 * 60_000,
+ announceCompletionHardExpiryMs: 30 * 60_000,
+ maxAnnounceRetryCount: 3,
+ deferDescendantDelayMs: 1_000,
+ resolveAnnounceRetryDelayMs: () => 2_000,
+ });
+
+ expect(decision).toEqual({ kind: "give-up", reason: "expiry", retryCount: 1 });
+ });
+
+ it("uses retry backoff for completion-message flows once descendants are settled", () => {
+ const decision = resolveDeferredCleanupDecision({
+ entry: makeEntry({ expectsCompletionMessage: true, announceRetryCount: 1 }),
+ now,
+ activeDescendantRuns: 0,
+ announceExpiryMs: 5 * 60_000,
+ announceCompletionHardExpiryMs: 30 * 60_000,
+ maxAnnounceRetryCount: 3,
+ deferDescendantDelayMs: 1_000,
+ resolveAnnounceRetryDelayMs: (retryCount) => retryCount * 1_000,
+ });
+
+ expect(decision).toEqual({ kind: "retry", retryCount: 2, resumeDelayMs: 2_000 });
+ });
+});
diff --git a/src/agents/subagent-registry-cleanup.ts b/src/agents/subagent-registry-cleanup.ts
index 4e3f8f83300..716e6e2a72a 100644
--- a/src/agents/subagent-registry-cleanup.ts
+++ b/src/agents/subagent-registry-cleanup.ts
@@ -35,20 +35,27 @@ export function resolveDeferredCleanupDecision(params: {
now: number;
activeDescendantRuns: number;
announceExpiryMs: number;
+ announceCompletionHardExpiryMs: number;
maxAnnounceRetryCount: number;
deferDescendantDelayMs: number;
resolveAnnounceRetryDelayMs: (retryCount: number) => number;
}): DeferredCleanupDecision {
const endedAgo = resolveEndedAgoMs(params.entry, params.now);
- if (params.entry.expectsCompletionMessage === true && params.activeDescendantRuns > 0) {
- if (endedAgo > params.announceExpiryMs) {
+ const isCompletionMessageFlow = params.entry.expectsCompletionMessage === true;
+ const completionHardExpiryExceeded =
+ isCompletionMessageFlow && endedAgo > params.announceCompletionHardExpiryMs;
+ if (isCompletionMessageFlow && params.activeDescendantRuns > 0) {
+ if (completionHardExpiryExceeded) {
return { kind: "give-up", reason: "expiry" };
}
return { kind: "defer-descendants", delayMs: params.deferDescendantDelayMs };
}
const retryCount = (params.entry.announceRetryCount ?? 0) + 1;
- if (retryCount >= params.maxAnnounceRetryCount || endedAgo > params.announceExpiryMs) {
+ const expiryExceeded = isCompletionMessageFlow
+ ? completionHardExpiryExceeded
+ : endedAgo > params.announceExpiryMs;
+ if (retryCount >= params.maxAnnounceRetryCount || expiryExceeded) {
return {
kind: "give-up",
reason: retryCount >= params.maxAnnounceRetryCount ? "retry-limit" : "expiry",
diff --git a/src/agents/subagent-registry-queries.ts b/src/agents/subagent-registry-queries.ts
index 21727e8f01e..62fd743998b 100644
--- a/src/agents/subagent-registry-queries.ts
+++ b/src/agents/subagent-registry-queries.ts
@@ -113,6 +113,59 @@ export function countActiveDescendantRunsFromRuns(
return count;
}
+function countPendingDescendantRunsInternal(
+ runs: Map,
+ rootSessionKey: string,
+ excludeRunId?: string,
+): number {
+ const root = rootSessionKey.trim();
+ if (!root) {
+ return 0;
+ }
+ const excludedRunId = excludeRunId?.trim();
+ const pending = [root];
+ const visited = new Set([root]);
+ let count = 0;
+ for (let index = 0; index < pending.length; index += 1) {
+ const requester = pending[index];
+ if (!requester) {
+ continue;
+ }
+ for (const [runId, entry] of runs.entries()) {
+ if (entry.requesterSessionKey !== requester) {
+ continue;
+ }
+ const runEnded = typeof entry.endedAt === "number";
+ const cleanupCompleted = typeof entry.cleanupCompletedAt === "number";
+ if ((!runEnded || !cleanupCompleted) && runId !== excludedRunId) {
+ count += 1;
+ }
+ const childKey = entry.childSessionKey.trim();
+ if (!childKey || visited.has(childKey)) {
+ continue;
+ }
+ visited.add(childKey);
+ pending.push(childKey);
+ }
+ }
+ return count;
+}
+
+export function countPendingDescendantRunsFromRuns(
+ runs: Map,
+ rootSessionKey: string,
+): number {
+ return countPendingDescendantRunsInternal(runs, rootSessionKey);
+}
+
+export function countPendingDescendantRunsExcludingRunFromRuns(
+ runs: Map,
+ rootSessionKey: string,
+ excludeRunId: string,
+): number {
+ return countPendingDescendantRunsInternal(runs, rootSessionKey, excludeRunId);
+}
+
export function listDescendantRunsForRequesterFromRuns(
runs: Map,
rootSessionKey: string,
diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts
index 498b38aaedc..1ad4bf002b6 100644
--- a/src/agents/subagent-registry.announce-loop-guard.test.ts
+++ b/src/agents/subagent-registry.announce-loop-guard.test.ts
@@ -156,6 +156,41 @@ describe("announce loop guard (#18264)", () => {
expect(stored?.cleanupCompletedAt).toBeDefined();
});
+ test("expired completion-message entries are still resumed for announce", async () => {
+ announceFn.mockReset();
+ announceFn.mockResolvedValueOnce(true);
+ registry.resetSubagentRegistryForTests();
+
+ const now = Date.now();
+ const runId = "test-expired-completion-message";
+ loadSubagentRegistryFromDisk.mockReturnValue(
+ new Map([
+ [
+ runId,
+ {
+ runId,
+ childSessionKey: "agent:main:subagent:child-1",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "agent:main:main",
+ task: "completion announce after long descendants",
+ cleanup: "keep" as const,
+ createdAt: now - 20 * 60_000,
+ startedAt: now - 19 * 60_000,
+ endedAt: now - 10 * 60_000,
+ cleanupHandled: false,
+ expectsCompletionMessage: true,
+ },
+ ],
+ ]),
+ );
+
+ registry.initSubagentRegistry();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(announceFn).toHaveBeenCalledTimes(1);
+ });
+
test("announce rejection resets cleanupHandled so retries can resume", async () => {
announceFn.mockReset();
announceFn.mockRejectedValueOnce(new Error("announce failed"));
diff --git a/src/agents/subagent-registry.nested.e2e.test.ts b/src/agents/subagent-registry.nested.e2e.test.ts
index 9724d1bf780..7da5d951999 100644
--- a/src/agents/subagent-registry.nested.e2e.test.ts
+++ b/src/agents/subagent-registry.nested.e2e.test.ts
@@ -162,4 +162,88 @@ describe("subagent registry nested agent tracking", () => {
expect(countActiveDescendantRuns("agent:main:main")).toBe(1);
expect(countActiveDescendantRuns("agent:main:subagent:orch-ended")).toBe(1);
});
+
+ it("countPendingDescendantRuns includes ended descendants until cleanup completes", async () => {
+ const { addSubagentRunForTests, countPendingDescendantRuns } = subagentRegistry;
+
+ addSubagentRunForTests({
+ runId: "run-parent-ended-pending",
+ childSessionKey: "agent:main:subagent:orch-pending",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "orchestrate",
+ cleanup: "keep",
+ createdAt: 1,
+ startedAt: 1,
+ endedAt: 2,
+ cleanupHandled: false,
+ cleanupCompletedAt: undefined,
+ });
+ addSubagentRunForTests({
+ runId: "run-leaf-ended-pending",
+ childSessionKey: "agent:main:subagent:orch-pending:subagent:leaf",
+ requesterSessionKey: "agent:main:subagent:orch-pending",
+ requesterDisplayKey: "orch-pending",
+ task: "leaf",
+ cleanup: "keep",
+ createdAt: 1,
+ startedAt: 1,
+ endedAt: 2,
+ cleanupHandled: true,
+ cleanupCompletedAt: undefined,
+ });
+
+ expect(countPendingDescendantRuns("agent:main:main")).toBe(2);
+ expect(countPendingDescendantRuns("agent:main:subagent:orch-pending")).toBe(1);
+
+ addSubagentRunForTests({
+ runId: "run-leaf-completed",
+ childSessionKey: "agent:main:subagent:orch-pending:subagent:leaf-completed",
+ requesterSessionKey: "agent:main:subagent:orch-pending",
+ requesterDisplayKey: "orch-pending",
+ task: "leaf complete",
+ cleanup: "keep",
+ createdAt: 1,
+ startedAt: 1,
+ endedAt: 2,
+ cleanupHandled: true,
+ cleanupCompletedAt: 3,
+ });
+ expect(countPendingDescendantRuns("agent:main:subagent:orch-pending")).toBe(1);
+ });
+
+ it("countPendingDescendantRunsExcludingRun ignores only the active announce run", async () => {
+ const { addSubagentRunForTests, countPendingDescendantRunsExcludingRun } = subagentRegistry;
+
+ addSubagentRunForTests({
+ runId: "run-self",
+ childSessionKey: "agent:main:subagent:worker",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "self",
+ cleanup: "keep",
+ createdAt: 1,
+ startedAt: 1,
+ endedAt: 2,
+ cleanupHandled: false,
+ cleanupCompletedAt: undefined,
+ });
+
+ addSubagentRunForTests({
+ runId: "run-sibling",
+ childSessionKey: "agent:main:subagent:sibling",
+ requesterSessionKey: "agent:main:main",
+ requesterDisplayKey: "main",
+ task: "sibling",
+ cleanup: "keep",
+ createdAt: 1,
+ startedAt: 1,
+ endedAt: 2,
+ cleanupHandled: false,
+ cleanupCompletedAt: undefined,
+ });
+
+ expect(countPendingDescendantRunsExcludingRun("agent:main:main", "run-self")).toBe(1);
+ expect(countPendingDescendantRunsExcludingRun("agent:main:main", "run-sibling")).toBe(1);
+ });
});
diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts
index 6a7e86100c6..2f200535c4e 100644
--- a/src/agents/subagent-registry.steer-restart.test.ts
+++ b/src/agents/subagent-registry.steer-restart.test.ts
@@ -537,7 +537,7 @@ describe("subagent registry steer restarts", () => {
});
});
- it("emits subagent_ended when completion cleanup expires with active descendants", async () => {
+ it("keeps completion cleanup pending while descendants are still active", async () => {
announceSpy.mockResolvedValue(false);
mod.registerSubagentRun({
@@ -574,10 +574,11 @@ describe("subagent registry steer restarts", () => {
const event = call[0] as { runId?: string; reason?: string };
return event.runId === "run-parent-expiry" && event.reason === "subagent-complete";
});
- expect(parentHookCall).toBeDefined();
+ expect(parentHookCall).toBeUndefined();
const parent = mod
.listSubagentRunsForRequester("agent:main:main")
.find((entry) => entry.runId === "run-parent-expiry");
- expect(parent?.cleanupCompletedAt).toBeTypeOf("number");
+ expect(parent?.cleanupCompletedAt).toBeUndefined();
+ expect(parent?.cleanupHandled).toBe(false);
});
});
diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts
index eb8f6a287d5..900aa4752d9 100644
--- a/src/agents/subagent-registry.ts
+++ b/src/agents/subagent-registry.ts
@@ -32,6 +32,8 @@ import {
import {
countActiveDescendantRunsFromRuns,
countActiveRunsForSessionFromRuns,
+ countPendingDescendantRunsExcludingRunFromRuns,
+ countPendingDescendantRunsFromRuns,
findRunIdsByChildSessionKeyFromRuns,
listDescendantRunsForRequesterFromRuns,
listRunsForRequesterFromRuns,
@@ -63,10 +65,15 @@ const MAX_ANNOUNCE_RETRY_DELAY_MS = 8_000;
*/
const MAX_ANNOUNCE_RETRY_COUNT = 3;
/**
- * Announce entries older than this are force-expired even if delivery never
- * succeeded. Guards against stale registry entries surviving gateway restarts.
+ * Non-completion announce entries older than this are force-expired even if
+ * delivery never succeeded.
*/
const ANNOUNCE_EXPIRY_MS = 5 * 60_000; // 5 minutes
+/**
+ * Completion-message flows can wait for descendants to finish, but this hard
+ * cap prevents indefinite pending state when descendants never fully settle.
+ */
+const ANNOUNCE_COMPLETION_HARD_EXPIRY_MS = 30 * 60_000; // 30 minutes
type SubagentRunOrphanReason = "missing-session-entry" | "missing-session-id";
/**
* Embedded runs can emit transient lifecycle `error` events while provider/model
@@ -445,7 +452,11 @@ function resumeSubagentRun(runId: string) {
persistSubagentRuns();
return;
}
- if (typeof entry.endedAt === "number" && Date.now() - entry.endedAt > ANNOUNCE_EXPIRY_MS) {
+ if (
+ entry.expectsCompletionMessage !== true &&
+ typeof entry.endedAt === "number" &&
+ Date.now() - entry.endedAt > ANNOUNCE_EXPIRY_MS
+ ) {
logAnnounceGiveUp(entry, "expiry");
entry.cleanupCompletedAt = Date.now();
persistSubagentRuns();
@@ -462,6 +473,7 @@ function resumeSubagentRun(runId: string) {
) {
const waitMs = Math.max(1, earliestRetryAt - now);
setTimeout(() => {
+ resumedRuns.delete(runId);
resumeSubagentRun(runId);
}, waitMs).unref?.();
resumedRuns.add(runId);
@@ -709,8 +721,10 @@ async function finalizeSubagentCleanup(
const deferredDecision = resolveDeferredCleanupDecision({
entry,
now,
- activeDescendantRuns: Math.max(0, countActiveDescendantRuns(entry.childSessionKey)),
+ // Defer until descendants are fully settled, including post-end cleanup.
+ activeDescendantRuns: Math.max(0, countPendingDescendantRuns(entry.childSessionKey)),
announceExpiryMs: ANNOUNCE_EXPIRY_MS,
+ announceCompletionHardExpiryMs: ANNOUNCE_COMPLETION_HARD_EXPIRY_MS,
maxAnnounceRetryCount: MAX_ANNOUNCE_RETRY_COUNT,
deferDescendantDelayMs: MIN_ANNOUNCE_RETRY_DELAY_MS,
resolveAnnounceRetryDelayMs,
@@ -753,6 +767,7 @@ async function finalizeSubagentCleanup(
// Applies to both keep/delete cleanup modes so delete-runs are only removed
// after a successful announce (or terminal give-up).
entry.cleanupHandled = false;
+ // Clear the in-flight resume marker so the scheduled retry can run again.
resumedRuns.delete(runId);
persistSubagentRuns();
if (deferredDecision.resumeDelayMs == null) {
@@ -815,9 +830,10 @@ function retryDeferredCompletedAnnounces(excludeRunId?: string) {
if (suppressAnnounceForSteerRestart(entry)) {
continue;
}
- // Force-expire announces that have been pending too long (#18264).
+ // Force-expire stale non-completion announces; completion-message flows can
+ // stay pending while descendants run for a long time.
const endedAgo = now - (entry.endedAt ?? now);
- if (endedAgo > ANNOUNCE_EXPIRY_MS) {
+ if (entry.expectsCompletionMessage !== true && endedAgo > ANNOUNCE_EXPIRY_MS) {
logAnnounceGiveUp(entry, "expiry");
entry.cleanupCompletedAt = now;
persistSubagentRuns();
@@ -1214,6 +1230,24 @@ export function countActiveDescendantRuns(rootSessionKey: string): number {
);
}
+export function countPendingDescendantRuns(rootSessionKey: string): number {
+ return countPendingDescendantRunsFromRuns(
+ getSubagentRunsSnapshotForRead(subagentRuns),
+ rootSessionKey,
+ );
+}
+
+export function countPendingDescendantRunsExcludingRun(
+ rootSessionKey: string,
+ excludeRunId: string,
+): number {
+ return countPendingDescendantRunsExcludingRunFromRuns(
+ getSubagentRunsSnapshotForRead(subagentRuns),
+ rootSessionKey,
+ excludeRunId,
+ );
+}
+
export function listDescendantRunsForRequester(rootSessionKey: string): SubagentRunRecord[] {
return listDescendantRunsForRequesterFromRuns(
getSubagentRunsSnapshotForRead(subagentRuns),
diff --git a/src/agents/tools/subagents-tool.ts b/src/agents/tools/subagents-tool.ts
index 9b0b75ce857..bd52e597b28 100644
--- a/src/agents/tools/subagents-tool.ts
+++ b/src/agents/tools/subagents-tool.ts
@@ -31,6 +31,7 @@ import { optionalStringEnum } from "../schema/typebox.js";
import { getSubagentDepthFromSessionStore } from "../subagent-depth.js";
import {
clearSubagentRunSteerRestart,
+ countPendingDescendantRuns,
listSubagentRunsForRequester,
markSubagentRunTerminated,
markSubagentRunForSteerRestart,
@@ -70,7 +71,10 @@ type ResolvedRequesterKey = {
callerIsSubagent: boolean;
};
-function resolveRunStatus(entry: SubagentRunRecord) {
+function resolveRunStatus(entry: SubagentRunRecord, options?: { hasPendingDescendants?: boolean }) {
+ if (options?.hasPendingDescendants) {
+ return "active";
+ }
if (!entry.endedAt) {
return "running";
}
@@ -365,6 +369,16 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
const recentCutoff = now - recentMinutes * 60_000;
const cache = new Map>();
+ const pendingDescendantCache = new Map();
+ const hasPendingDescendants = (sessionKey: string) => {
+ if (pendingDescendantCache.has(sessionKey)) {
+ return pendingDescendantCache.get(sessionKey) === true;
+ }
+ const hasPending = countPendingDescendantRuns(sessionKey) > 0;
+ pendingDescendantCache.set(sessionKey, hasPending);
+ return hasPending;
+ };
+
let index = 1;
const buildListEntry = (entry: SubagentRunRecord, runtimeMs: number) => {
const sessionEntry = resolveSessionEntryForKey({
@@ -374,7 +388,9 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
}).entry;
const totalTokens = resolveTotalTokens(sessionEntry);
const usageText = formatTokenUsageDisplay(sessionEntry);
- const status = resolveRunStatus(entry);
+ const status = resolveRunStatus(entry, {
+ hasPendingDescendants: hasPendingDescendants(entry.childSessionKey),
+ });
const runtime = formatDurationCompact(runtimeMs);
const label = truncateLine(resolveSubagentLabel(entry), 48);
const task = truncateLine(entry.task.trim(), 72);
@@ -396,10 +412,15 @@ export function createSubagentsTool(opts?: { agentSessionKey?: string }): AnyAge
return { line, view: entry.endedAt ? { ...baseView, endedAt: entry.endedAt } : baseView };
};
const active = runs
- .filter((entry) => !entry.endedAt)
+ .filter((entry) => !entry.endedAt || hasPendingDescendants(entry.childSessionKey))
.map((entry) => buildListEntry(entry, now - (entry.startedAt ?? entry.createdAt)));
const recent = runs
- .filter((entry) => !!entry.endedAt && (entry.endedAt ?? 0) >= recentCutoff)
+ .filter(
+ (entry) =>
+ !!entry.endedAt &&
+ !hasPendingDescendants(entry.childSessionKey) &&
+ (entry.endedAt ?? 0) >= recentCutoff,
+ )
.map((entry) =>
buildListEntry(entry, (entry.endedAt ?? now) - (entry.startedAt ?? entry.createdAt)),
);
diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts
index 46971191dc1..8a9941008d7 100644
--- a/src/auto-reply/reply/inbound-meta.test.ts
+++ b/src/auto-reply/reply/inbound-meta.test.ts
@@ -18,6 +18,14 @@ function parseConversationInfoPayload(text: string): Record {
return JSON.parse(match[1]) as Record;
}
+function parseSenderInfoPayload(text: string): Record {
+ const match = text.match(/Sender \(untrusted metadata\):\n```json\n([\s\S]*?)\n```/);
+ if (!match?.[1]) {
+ throw new Error("missing sender info json block");
+ }
+ return JSON.parse(match[1]) as Record;
+}
+
describe("buildInboundMetaSystemPrompt", () => {
it("includes session-stable routing fields", () => {
const prompt = buildInboundMetaSystemPrompt({
@@ -147,6 +155,29 @@ describe("buildInboundUserContextPrefix", () => {
expect(conversationInfo["sender"]).toBe("+15551234567");
});
+ it("prefers SenderName in conversation info sender identity", () => {
+ const text = buildInboundUserContextPrefix({
+ ChatType: "group",
+ SenderName: " Tyler ",
+ SenderId: " +15551234567 ",
+ } as TemplateContext);
+
+ const conversationInfo = parseConversationInfoPayload(text);
+ expect(conversationInfo["sender"]).toBe("Tyler");
+ });
+
+ it("includes sender metadata block for direct chats", () => {
+ const text = buildInboundUserContextPrefix({
+ ChatType: "direct",
+ SenderName: "Tyler",
+ SenderId: "+15551234567",
+ } as TemplateContext);
+
+ const senderInfo = parseSenderInfoPayload(text);
+ expect(senderInfo["label"]).toBe("Tyler (+15551234567)");
+ expect(senderInfo["id"]).toBe("+15551234567");
+ });
+
it("includes formatted timestamp in conversation info when provided", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
@@ -187,7 +218,7 @@ describe("buildInboundUserContextPrefix", () => {
expect(conversationInfo["message_id"]).toBe("msg-123");
});
- it("includes message_id_full when it differs from message_id", () => {
+ it("prefers MessageSid when both MessageSid and MessageSidFull are present", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
MessageSid: "short-id",
@@ -196,18 +227,18 @@ describe("buildInboundUserContextPrefix", () => {
const conversationInfo = parseConversationInfoPayload(text);
expect(conversationInfo["message_id"]).toBe("short-id");
- expect(conversationInfo["message_id_full"]).toBe("full-provider-message-id");
+ expect(conversationInfo["message_id_full"]).toBeUndefined();
});
- it("omits message_id_full when it matches message_id", () => {
+ it("falls back to MessageSidFull when MessageSid is missing", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
- MessageSid: "same-id",
- MessageSidFull: "same-id",
+ MessageSid: " ",
+ MessageSidFull: "full-provider-message-id",
} as TemplateContext);
const conversationInfo = parseConversationInfoPayload(text);
- expect(conversationInfo["message_id"]).toBe("same-id");
+ expect(conversationInfo["message_id"]).toBe("full-provider-message-id");
expect(conversationInfo["message_id_full"]).toBeUndefined();
});
diff --git a/src/auto-reply/reply/inbound-meta.ts b/src/auto-reply/reply/inbound-meta.ts
index 99296ef3f67..eea956785ae 100644
--- a/src/auto-reply/reply/inbound-meta.ts
+++ b/src/auto-reply/reply/inbound-meta.ts
@@ -88,21 +88,20 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
const messageId = safeTrim(ctx.MessageSid);
const messageIdFull = safeTrim(ctx.MessageSidFull);
+ const resolvedMessageId = messageId ?? messageIdFull;
const timestampStr = formatConversationTimestamp(ctx.Timestamp);
const conversationInfo = {
- message_id: isDirect ? undefined : messageId,
- message_id_full: isDirect
- ? undefined
- : messageIdFull && messageIdFull !== messageId
- ? messageIdFull
- : undefined,
+ message_id: isDirect ? undefined : resolvedMessageId,
reply_to_id: isDirect ? undefined : safeTrim(ctx.ReplyToId),
sender_id: isDirect ? undefined : safeTrim(ctx.SenderId),
conversation_label: isDirect ? undefined : safeTrim(ctx.ConversationLabel),
sender: isDirect
? undefined
- : (safeTrim(ctx.SenderE164) ?? safeTrim(ctx.SenderId) ?? safeTrim(ctx.SenderUsername)),
+ : (safeTrim(ctx.SenderName) ??
+ safeTrim(ctx.SenderE164) ??
+ safeTrim(ctx.SenderId) ??
+ safeTrim(ctx.SenderUsername)),
timestamp: timestampStr,
group_subject: safeTrim(ctx.GroupSubject),
group_channel: safeTrim(ctx.GroupChannel),
@@ -131,20 +130,20 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
);
}
- const senderInfo = isDirect
- ? undefined
- : {
- label: resolveSenderLabel({
- name: safeTrim(ctx.SenderName),
- username: safeTrim(ctx.SenderUsername),
- tag: safeTrim(ctx.SenderTag),
- e164: safeTrim(ctx.SenderE164),
- }),
- name: safeTrim(ctx.SenderName),
- username: safeTrim(ctx.SenderUsername),
- tag: safeTrim(ctx.SenderTag),
- e164: safeTrim(ctx.SenderE164),
- };
+ const senderInfo = {
+ label: resolveSenderLabel({
+ name: safeTrim(ctx.SenderName),
+ username: safeTrim(ctx.SenderUsername),
+ tag: safeTrim(ctx.SenderTag),
+ e164: safeTrim(ctx.SenderE164),
+ id: safeTrim(ctx.SenderId),
+ }),
+ id: safeTrim(ctx.SenderId),
+ name: safeTrim(ctx.SenderName),
+ username: safeTrim(ctx.SenderUsername),
+ tag: safeTrim(ctx.SenderTag),
+ e164: safeTrim(ctx.SenderE164),
+ };
if (senderInfo?.label) {
blocks.push(
["Sender (untrusted metadata):", "```json", JSON.stringify(senderInfo, null, 2), "```"].join(
diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts
index bb376bded4b..7df42766cb2 100644
--- a/src/gateway/control-ui.http.test.ts
+++ b/src/gateway/control-ui.http.test.ts
@@ -42,7 +42,7 @@ describe("handleControlUiHttpRequest", () => {
function runControlUiRequest(params: {
url: string;
- method: "GET" | "HEAD";
+ method: "GET" | "HEAD" | "POST";
rootPath: string;
basePath?: string;
}) {
@@ -356,6 +356,36 @@ describe("handleControlUiHttpRequest", () => {
});
});
+ it("falls through POST requests when basePath is empty", async () => {
+ await withControlUiRoot({
+ fn: async (tmp) => {
+ const { handled, end } = runControlUiRequest({
+ url: "/webhook/bluebubbles",
+ method: "POST",
+ rootPath: tmp,
+ });
+ expect(handled).toBe(false);
+ expect(end).not.toHaveBeenCalled();
+ },
+ });
+ });
+
+ it("returns 405 for POST requests under configured basePath", async () => {
+ await withControlUiRoot({
+ fn: async (tmp) => {
+ const { handled, res, end } = runControlUiRequest({
+ url: "/openclaw/",
+ method: "POST",
+ rootPath: tmp,
+ basePath: "/openclaw",
+ });
+ expect(handled).toBe(true);
+ expect(res.statusCode).toBe(405);
+ expect(end).toHaveBeenCalledWith("Method Not Allowed");
+ },
+ });
+ });
+
it("rejects absolute-path escape attempts under basePath routes", async () => {
await withBasePathRootFixture({
siblingDir: "ui-secrets",
diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts
index e410eb23d17..ddfc70418e3 100644
--- a/src/gateway/control-ui.ts
+++ b/src/gateway/control-ui.ts
@@ -275,13 +275,6 @@ export function handleControlUiHttpRequest(
if (!urlRaw) {
return false;
}
- if (req.method !== "GET" && req.method !== "HEAD") {
- res.statusCode = 405;
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
- res.end("Method Not Allowed");
- return true;
- }
-
const url = new URL(urlRaw, "http://localhost");
const basePath = normalizeControlUiBasePath(opts?.basePath);
const pathname = url.pathname;
@@ -315,6 +308,19 @@ export function handleControlUiHttpRequest(
}
}
+ // Method guard must run AFTER path checks so that POST requests to non-UI
+ // paths (channel webhooks etc.) fall through to later handlers. When no
+ // basePath is configured the SPA catch-all would otherwise 405 every POST.
+ if (req.method !== "GET" && req.method !== "HEAD") {
+ if (!basePath) {
+ return false;
+ }
+ res.statusCode = 405;
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
+ res.end("Method Not Allowed");
+ return true;
+ }
+
applyControlUiSecurityHeaders(res);
const bootstrapConfigPath = basePath