From f918b336d11ab503371024ca4b30c399bfdce938 Mon Sep 17 00:00:00 2001 From: Tyler Yust <64381258+tyler6204@users.noreply.github.com> Date: Sun, 1 Mar 2026 22:52:11 -0800 Subject: [PATCH] fix: agent-only announce path, BB message IDs, sender identity, SSRF allowlist (#23970) * fix(agents): defer announces until descendant cleanup settles * fix(bluebubbles): harden message metadata extraction * feat(contributors): rank by composite score (commits, PRs, LOC, tenure) * refactor(control-ui): move method guard after path checks to improve request handling * fix subagent completion announce when only current run is pending * fix(subagents): keep orchestrator runs active until descendants finish * fix: prepare PR feedback follow-ups (#23970) (thanks @tyler6204) --- CHANGELOG.md | 4 + README.md | 104 +++++++------- extensions/bluebubbles/src/send-helpers.ts | 56 +++++--- extensions/bluebubbles/src/send.test.ts | 24 ++++ scripts/update-clawtributors.ts | 133 +++++++++++++++--- scripts/update-clawtributors.types.ts | 4 + src/agents/openclaw-tools.sessions.test.ts | 53 +++++++ .../subagent-announce.format.e2e.test.ts | 51 +++++++ src/agents/subagent-announce.timeout.test.ts | 1 + src/agents/subagent-announce.ts | 55 ++++++-- src/agents/subagent-registry-cleanup.test.ts | 81 +++++++++++ src/agents/subagent-registry-cleanup.ts | 13 +- src/agents/subagent-registry-queries.ts | 53 +++++++ ...agent-registry.announce-loop-guard.test.ts | 35 +++++ .../subagent-registry.nested.e2e.test.ts | 84 +++++++++++ .../subagent-registry.steer-restart.test.ts | 7 +- src/agents/subagent-registry.ts | 46 +++++- src/agents/tools/subagents-tool.ts | 29 +++- src/auto-reply/reply/inbound-meta.test.ts | 43 +++++- src/auto-reply/reply/inbound-meta.ts | 41 +++--- src/gateway/control-ui.http.test.ts | 32 ++++- src/gateway/control-ui.ts | 20 ++- 22 files changed, 814 insertions(+), 155 deletions(-) create mode 100644 src/agents/subagent-registry-cleanup.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e65b50bb8bc..4e251c9c357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai - Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured. - Tools/PDF analysis: add a first-class `pdf` tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (`agents.defaults.pdfModel`, `pdfMaxBytesMb`, `pdfMaxPages`), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204. - Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (`/health`, `/healthz`, `/ready`, `/readyz`) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc. +- README/Contributors: rank contributor avatars by composite score (commits + merged PRs + code LOC), excluding docs-only LOC to prevent bulk-generated files from inflating rankings. (#23970) Thanks @tyler6204. - Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus. - Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz. - Telegram/DM topics: add per-DM `direct` + topic config (allowlists, `dmPolicy`, `skills`, `systemPrompt`, `requireTopic`), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor. @@ -39,6 +40,9 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204. +- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204. +- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204. - Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin. - Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448) - Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin. diff --git a/README.md b/README.md index da73c5e0942..c705c2a1026 100644 --- a/README.md +++ b/README.md @@ -502,54 +502,58 @@ Special thanks to Adam Doppelt for lobster.bot. Thanks to all clawtributors:

- steipete sktbrd cpojer joshp123 Mariano Belinky Takhoffman sebslight tyler6204 quotentiroler Verite Igiraneza - gumadeiras bohdanpodvirnyi vincentkoc iHildy jaydenfyi Glucksberg joaohlisboa rodrigouroz mneves75 BunsDev - MatthieuBizien MaudeBot vignesh07 smartprogrammer93 advaitpaliwal HenryLoenwind rahthakor vrknetha abdelsfane radek-paclt - joshavant christianklotz mudrii zerone0x ranausmanai Tobias Bischoff heyhudson czekaj ethanpalm yinghaosang - nabbilkhan mukhtharcm aether-ai-agent coygeek Mrseenz maxsumrall xadenryan VACInc juanpablodlc conroywhitney - Harald Buerbaumer akoscz Bridgerz hsrvc magimetal openclaw-bot meaningfool JustasM Phineas1500 ENCHIGO - Hiren Patel NicholasSpisak claude jonisjongithub theonejvo abhisekbasu1 Ryan Haines Blakeshannon jamesgroat Marvae - arosstale shakkernerd gejifeng divanoli ryan-crabbe nyanjou Sam Padilla dantelex SocialNerd42069 solstead - natefikru daveonkels LeftX Yida-Dev Masataka Shinohara Lewis riccardogiorato lc0rp adam91holt mousberg - BillChirico shadril238 CharlieGreenman hougangdev Mars orlyjamie McRolly NWANGWU LI SHANXIN Simone Macario durenzidu - JustYannicc Minidoracat magendary Jessy LANGE mteam88 brandonwise hirefrank M00N7682 dbhurley Eng. Juan Combetto - Harrington-bot TSavo Lalit Singh julianengel Jay Caldwell Kirill Shchetynin nachx639 bradleypriest TsekaLuk benithors - Shailesh thewilloftheshadow jackheuberger loiie45e El-Fitz benostein pvtclawn 0xRaini ruypang xinhuagu - Taylor Asplund adhitShet Paul van Oorschot sreekaransrinath buddyh gupsammy AI-Reviewer-QS Stefan Galescu WalterSumbon nachoiacovino - rodbland2021 Vasanth Rao Naik Sabavat fagemx petter-b omair445 dorukardahan leszekszpunar Clawborn davidrudduck scald - Igor Markelov rrenamed Parker Todd Brooks AnonO6 Tanwa Arpornthip andranik-sahakyan davidguttman sleontenko denysvitali Tom Ron - popomore Patrick Barletta shayan919293 不做了睡大觉 Luis Conde Harry Cui Kepler SidQin-cyber Lucky Michael Lee sircrumpet - peschee dakshaymehta davidiach nonggia.liang seheepeak obviyus danielwanwx osolmaz minupla misterdas - Shuai-DaiDai dominicnunez lploc94 sfo2001 lutr0 dirbalak cathrynlavery Joly0 kiranjd niceysam - danielz1z Iranb carrotRakko Oceanswave cdorsey AdeboyeDN j2h4u Alg0rix Skyler Miao peetzweg/ - TideFinder CornBrother0x DukeDeSouth emanuelst bsormagec Diaspar4u evanotero Nate OscarMinjarez webvijayi - garnetlyx miloudbelarebia Jeremiah Lowin liebertar Max rhuanssauro joshrad-dev adityashaw2 CashWilliams taw0002 - asklee-klawd h0tp-ftw constansino mcaxtr onutc ryan unisone artuskg Solvely-Colin pahdo - Kimitaka Watanabe Lilo Rajat Joshi Yuting Lin Neo wu-tian807 ngutman crimeacs manuelhettich mcinteerj - bjesuiter Manik Vahsith alexgleason Nicholas Stephen Brian King justinhuangcode mahanandhi andreesg connorshea dinakars777 - Flash-LHR JINNYEONG KIM Protocol Zero kyleok Limitless grp06 robbyczgw-cla slonce70 JayMishra-source ide-rea - lailoo badlogic echoVic amitbiswal007 azade-c John Rood dddabtc Jonathan Works roshanasingh4 tosh-hamburg - dlauer ezhikkk Shivam Kumar Raut Mykyta Bozhenko YuriNachos Josh Phillips ThomsenDrake Wangnov akramcodez jadilson12 - Whoaa512 clawdinator[bot] emonty kaizen403 chriseidhof Lukavyi wangai-studio ysqander aj47 google-labs-jules[bot] - hyf0-agent Jeremy Mumford Kenny Lee superman32432432 widingmarcus-cyber DylanWoodAkers antons austinm911 boris721 damoahdominic - dan-dr doodlewind GHesericsu HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf - Randy Torres sumleo Yeom-JinHo akyourowngames aldoeliacim Dithilli dougvk erikpr1994 fal3 jonasjancarik - koala73 mitschabaude-bot mkbehr Oren shtse8 sibbl thesomewhatyou zats chrisrodz frankekn - gabriel-trigo ghsmc iamadig ibrahimq21 irtiq7 jeann2013 jogelin Jonathan D. Rhyne (DJ-D) Justin Ling kelvinCB - manmal Matthew MattQ Milofax mitsuhiko neist pejmanjohn ProspectOre rmorse rubyrunsstuff - rybnikov santiagomed Steve (OpenClaw) suminhthanh svkozak wes-davis 24601 AkashKobal ameno- awkoy - battman21 BinHPdev bonald dashed dawondyifraw dguido Django Navarro evalexpr henrino3 humanwritten - hyojin joeykrug larlyssa liuy Mark Liu natedenh odysseus0 pcty-nextgen-service-account pi0 Syhids - tmchow uli-will-code aaronveklabs andreabadesso BinaryMuse cash-echo-bot CJWTRUST cordx56 danballance Elarwei001 - EnzeD erik-agens Evizero fcatuhe gildo Grynn huntharo hydro13 itsjaydesu ivanrvpereira - jverdi kentaro loeclos longmaba MarvinCui MisterGuy420 mjrussell odnxe optimikelabs oswalpalash - p6l-richard philipp-spiess RamiNoodle733 Raymond Berger Rob Axelsen sauerdaniel SleuthCo T5-AndyML TaKO8Ki thejhinvirtuoso - travisp yudshj zknicker 0oAstro 8BlT Abdul535 abhaymundhara aduk059 afurm aisling404 - akari-musubi Alex-Alaniz alexanderatallah alexstyl andrewting19 araa47 Asleep123 Ayush10 bennewton999 bguidolim - caelum0x championswimmer Chloe-VP dario-github DarwinsBuddy David-Marsh-Photo dcantu96 dndodson dvrshil dxd5001 - dylanneve1 EmberCF ephraimm ereid7 eternauta1337 foeken gtsifrikas HazAT iamEvanYT ikari-pl - kesor knocte MackDing nobrainer-tech Noctivoro Olshansk Pratham Dubey Raikan10 SecondThread Swader - testingabc321 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou carlulsoe hrdwdmrbl hugobarauna jayhickey jiulingyun - kitze latitudeki5223 loukotal minghinmatthewlam MSch odrobnik rafaelreis-r ratulsarna reeltimeapps rhjoh - ronak-guliani snopoke thesash timkrase + steipete vincentkoc vignesh07 obviyus Mariano Belinky sebslight gumadeiras Takhoffman thewilloftheshadow cpojer + tyler6204 joshp123 Glucksberg mcaxtr quotentiroler osolmaz Sid-Qin joshavant shakkernerd bmendonca3 + mukhtharcm zerone0x mcinteerj ngutman lailoo arosstale rodrigouroz robbyczgw-cla Elonito Clawborn + yinghaosang BunsDev christianklotz echoVic coygeek roshanasingh4 mneves75 joaohlisboa bohdanpodvirnyi nachx639 + onutc Verite Igiraneza widingmarcus-cyber akramcodez aether-ai-agent bjesuiter MaudeBot YuriNachos chilu18 byungsker + dbhurley JayMishra-source iHildy mudrii dlauer Solvely-Colin czekaj advaitpaliwal lc0rp grp06 + HenryLoenwind azade-c Lukavyi vrknetha brandonwise conroywhitney Tobias Bischoff davidrudduck xinhuagu jaydenfyi + petter-b heyhudson MatthieuBizien huntharo omair445 adam91holt adhitShet smartprogrammer93 radek-paclt frankekn + bradleypriest rahthakor shadril238 VACInc juanpablodlc jonisjongithub magimetal stakeswky abhisekbasu1 MisterGuy420 + hsrvc nabbilkhan aldoeliacim jamesgroat orlyjamie Elarwei001 rubyrunsstuff Phineas1500 meaningfool sfo2001 + Marvae liuy shtse8 thebenignhacker carrotRakko ranausmanai kevinWangSheng gregmousseau rrenamed akoscz + jarvis-medmatic danielz1z pandego xadenryan NicholasSpisak graysurf gupsammy nyanjou sibbl gejifeng + ide-rea leszekszpunar Yida-Dev AI-Reviewer-QS SocialNerd42069 maxsumrall hougangdev Minidoracat AnonO6 sreekaransrinath + YuzuruS riccardogiorato Bridgerz Mrseenz buddyh Eng. Juan Combetto peschee cash-echo-bot jalehman zknicker + Harald Buerbaumer taw0002 scald openperf BUGKillerKing Oceanswave Hiren Patel kiranjd antons dan-dr + jadilson12 sumleo Whoaa512 luijoc niceysam JustYannicc emanuelst TsekaLuk JustasM loiie45e + davidguttman natefikru dougvk koala73 mkbehr zats Simone Macario openclaw-bot ENCHIGO mteam88 + Blakeshannon gabriel-trigo neist pejmanjohn durenzidu Ryan Haines hcl XuHao benithors bitfoundry-ai + HeMuling markmusson ameno- battman21 BinHPdev dguido evalexpr guirguispierre henrino3 joeykrug + loganprit odysseus0 dbachelder Divanoli Mydeen Pitchai liuxiaopai-ai Sam Padilla pvtclawn seheepeak TSavo nachoiacovino + misterdas LeftX badlogic Shuai-DaiDai mousberg Masataka Shinohara BillChirico Lewis solstead julianengel + dantelex sahilsatralkar kkarimi mahmoudashraf93 pkrmf ryan-crabbe miloudbelarebia Mars El-Fitz McRolly NWANGWU + carlulsoe Dithilli emonty fal3 mitschabaude-bot benostein LI SHANXIN magendary mahanandhi CashWilliams + j2h4u bsormagec Jessy LANGE Lalit Singh hyf0-agent andranik-sahakyan unisone jeann2013 jogelin rmorse + scz2011 wes-davis popomore cathrynlavery iamadig Vasanth Rao Naik Sabavat Jay Caldwell Shailesh Kirill Shchetynin ruypang + mitchmcalister Paul van Oorschot Xu Gu Menglin Li artuskg jackheuberger imfing superman32432432 Syhids Marvin + Taylor Asplund dakshaymehta Stefan Galescu lploc94 WalterSumbon krizpoon EnzeD Evizero Grynn hydro13 + jverdi kentaro kunalk16 longmaba mjrussell optimikelabs oswalpalash RamiNoodle733 sauerdaniel SleuthCo + TaKO8Ki travisp rodbland2021 fagemx BigUncle Igor Markelov zhoulc777 connorshea TIHU Tony Dehnke + pablohrcarvalho bonald rhuanssauro Tanwa Arpornthip webvijayi Tom Ron ozbillwang Patrick Barletta Ian Derrington austinm911 + Ayush10 boris721 damoahdominic doodlewind ikari-pl philipp-spiess shayan919293 Harrington-bot nonggia.liang Michael Lee + OscarMinjarez claude Alg0rix Lucky Harry Cui Kepler h0tp-ftw Youyou972 Dominic danielwanwx 0xJonHoldsCrypto + akyourowngames clawdinator[bot] erikpr1994 thesash thesomewhatyou dashed Dale Babiy Diaspar4u brianleach codexGW + dirbalak Iranb Max TideFinder Chase Dorsey Joly0 adityashaw2 tumf slonce70 alexgleason + theonejvo Skyler Miao Jeremiah Lowin peetzweg/ chrisrodz ghsmc ibrahimq21 irtiq7 Jonathan D. Rhyne (DJ-D) kelvinCB + mitsuhiko rybnikov santiagomed suminhthanh svkozak kaizen403 sleontenko Nate CornBrother0x DukeDeSouth + crimeacs Cklee Garnet Liu neverland ryan sircrumpet AdeboyeDN Neo asklee-klawd benediktjohannes + 张哲芳 constansino Yuting Lin OfflynAI Rajat Joshi Daniel Zou Manik Vahsith ProspectOre Lilo 24601 + awkoy dawondyifraw google-labs-jules[bot] hyojin Kansodata natedenh pi0 dddabtc AkashKobal wu-tian807 + Ganghyun Kim Stephen Brian King tosh-hamburg John Rood JINNYEONG KIM Dinakar Sarbada aj47 Protocol Zero Limitless Mykyta Bozhenko + Nicholas Shivam Kumar Raut andreesg Fred White Anandesh-Sharma ysqander ezhikkk andreabadesso BinaryMuse cordx56 + DevSecTim edincampara fcatuhe gildo itsjaydesu ivanrvpereira loeclos MarvinCui p6l-richard thejhinvirtuoso + yudshj Wangnov Jonathan Works Yassine Amjad Django Navarro Frank Harris Kenny Lee Drake Thomsen wangai-studio AytuncYildizli + Charlie Niño Jeremy Mumford Yeom-JinHo Rob Axelsen junwon Pratham Dubey amitbiswal007 Slats Oren Parker Todd Brooks + MattQ Milofax Steve (OpenClaw) Matthew Cassius0924 0xbrak 8BlT Abdul535 abhaymundhara aduk059 + afurm aisling404 akari-musubi albertlieyingadrian Alex-Alaniz ali-aljufairi altaywtf araa47 Asleep123 avacadobanana352 + barronlroth bennewton999 bguidolim bigwest60 caelum0x championswimmer dutifulbob eternauta1337 foeken gittb + HeimdallStrategy junsuwhy knocte MackDing nobrainer-tech Noctivoro Raikan10 Swader alexstyl Ethan Palm + yingchunbai joshrad-dev Dan Ballance Eric Su Kimitaka Watanabe Justin Ling lutr0 Raymond Berger atalovesyou jayhickey + jonasjancarik latitudeki5223 minghinmatthewlam rafaelreis-r ratulsarna timkrase efe-buken manmal easternbloc manuelhettich + sktbrd larlyssa Mind-Dragon pcty-nextgen-service-account tmchow uli-will-code Marc Gratch JackyWay aaronveklabs CJWTRUST + erik-agens odnxe T5-AndyML Josh Phillips mujiannan Marco Di Dionisio Randy Torres afern247 0oAstro alexanderatallah + testingabc321 humanwritten aaronn Alphonse-arianee gtsifrikas hrdwdmrbl hugobarauna jiulingyun kitze loukotal + MSch odrobnik reeltimeapps rhjoh ronak-guliani snopoke

diff --git a/extensions/bluebubbles/src/send-helpers.ts b/extensions/bluebubbles/src/send-helpers.ts index 53e03a92c8c..6fa2ab743cd 100644 --- a/extensions/bluebubbles/src/send-helpers.ts +++ b/extensions/bluebubbles/src/send-helpers.ts @@ -23,31 +23,43 @@ export function extractBlueBubblesMessageId(payload: unknown): string { if (!payload || typeof payload !== "object") { return "unknown"; } - const record = payload as Record; - const data = - record.data && typeof record.data === "object" - ? (record.data as Record) + + const asRecord = (value: unknown): Record | null => + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) : null; - const candidates = [ - record.messageId, - record.messageGuid, - record.message_guid, - record.guid, - record.id, - data?.messageId, - data?.messageGuid, - data?.message_guid, - data?.message_id, - data?.guid, - data?.id, - ]; - for (const candidate of candidates) { - if (typeof candidate === "string" && candidate.trim()) { - return candidate.trim(); + + const record = payload as Record; + const dataRecord = asRecord(record.data); + const resultRecord = asRecord(record.result); + const payloadRecord = asRecord(record.payload); + const messageRecord = asRecord(record.message); + const dataArrayFirst = Array.isArray(record.data) ? asRecord(record.data[0]) : null; + + const roots = [record, dataRecord, resultRecord, payloadRecord, messageRecord, dataArrayFirst]; + + for (const root of roots) { + if (!root) { + continue; } - if (typeof candidate === "number" && Number.isFinite(candidate)) { - return String(candidate); + const candidates = [ + root.message_id, + root.messageId, + root.messageGuid, + root.message_guid, + root.guid, + root.id, + root.uuid, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return String(candidate); + } } } + return "unknown"; } diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 6b2e5fe051f..3de22b4d714 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -721,6 +721,30 @@ describe("send", () => { expect(result.messageId).toBe("msg-guid-789"); }); + it("extracts top-level message_id from response payload", async () => { + mockResolvedHandleTarget(); + mockSendResponse({ message_id: "bb-msg-321" }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("bb-msg-321"); + }); + + it("extracts nested result.message_id from response payload", async () => { + mockResolvedHandleTarget(); + mockSendResponse({ result: { message_id: "bb-msg-654" } }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("bb-msg-654"); + }); + it("resolves credentials from config", async () => { mockResolvedHandleTarget(); mockSendResponse({ data: { guid: "msg-123" } }); diff --git a/scripts/update-clawtributors.ts b/scripts/update-clawtributors.ts index 0e106e65969..f8479778205 100644 --- a/scripts/update-clawtributors.ts +++ b/scripts/update-clawtributors.ts @@ -45,8 +45,10 @@ for (const login of ensureLogins) { } } -const log = run("git log --format=%aN%x7c%aE --numstat"); +// %x1f = unit separator to avoid collisions with author names containing "|" +const log = run("git log --reverse --format=%aN%x1f%aE%x1f%aI --numstat"); const linesByLogin = new Map(); +const firstCommitByLogin = new Map(); let currentName: string | null = null; let currentEmail: string | null = null; @@ -56,10 +58,21 @@ for (const line of log.split("\n")) { continue; } - if (line.includes("|") && !/^[0-9-]/.test(line)) { - const [name, email] = line.split("|", 2); + if (line.includes("\x1f") && !/^[0-9-]/.test(line)) { + const [name, email, date] = line.split("\x1f", 3); currentName = name?.trim() ?? null; currentEmail = email?.trim().toLowerCase() ?? null; + + // Track first commit date per login (log is --reverse so first seen = earliest) + if (currentName && date) { + const login = resolveLogin(currentName, currentEmail, apiByLogin, nameToLogin, emailToLogin); + if (login) { + const key = login.toLowerCase(); + if (!firstCommitByLogin.has(key)) { + firstCommitByLogin.set(key, date.slice(0, 10)); + } + } + } continue; } @@ -68,7 +81,13 @@ for (const line of log.split("\n")) { } const parts = line.split("\t"); - if (parts.length < 2) { + if (parts.length < 3) { + continue; + } + + // Skip docs paths so bulk-generated i18n scaffolds don't inflate rankings + const filePath = parts[2]; + if (filePath.startsWith("docs/")) { continue; } @@ -94,6 +113,43 @@ for (const login of ensureLogins) { } } +// Fetch merged PRs and count per author +const prsByLogin = new Map(); +const prRaw = run( + `gh pr list -R ${REPO} --state merged --limit 5000 --json author --jq '.[].author.login'`, +); +for (const login of prRaw.split("\n")) { + const trimmed = login.trim().toLowerCase(); + if (!trimmed) { + continue; + } + prsByLogin.set(trimmed, (prsByLogin.get(trimmed) ?? 0) + 1); +} + +// Repo epoch for tenure calculation (root commit date) +const rootCommit = run("git rev-list --max-parents=0 HEAD").split("\n")[0]; +const repoEpochStr = run(`git log --format=%aI -1 ${rootCommit}`); +const repoEpoch = new Date(repoEpochStr.slice(0, 10)).getTime(); +const nowDate = new Date().toISOString().slice(0, 10); +const now = new Date(nowDate).getTime(); +const repoAgeDays = Math.max(1, (now - repoEpoch) / 86_400_000); + +// Composite score: +// base = commits*2 + merged_PRs*10 + sqrt(code_LOC) +// tenure = 1.0 + (days_since_first_commit / repo_age)^2 * 0.5 +// score = base * tenure +// Squared curve: only true early contributors get meaningful boost. +// Day-1 = 1.5x, halfway through repo life = 1.125x, recent = ~1.0x. +function computeScore(loc: number, commits: number, prs: number, firstDate: string): number { + const base = commits * 2 + prs * 10 + Math.sqrt(loc); + const daysIn = firstDate + ? Math.max(0, (now - new Date(firstDate.slice(0, 10)).getTime()) / 86_400_000) + : 0; + const tenureRatio = Math.min(1, daysIn / repoAgeDays); + const tenure = 1.0 + tenureRatio * tenureRatio * 0.5; + return base * tenure; +} + const entriesByKey = new Map(); 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 `${entry.display}`; }); - 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