Skip to content

feat(matrix): add subagent hooks for ACP room-binding#51523

Closed
maesh3301 wants to merge 4 commits into
openclaw:mainfrom
maesh3301:feat/matrix-subagent-hooks
Closed

feat(matrix): add subagent hooks for ACP room-binding#51523
maesh3301 wants to merge 4 commits into
openclaw:mainfrom
maesh3301:feat/matrix-subagent-hooks

Conversation

@maesh3301

Copy link
Copy Markdown

Add subagent_spawning, subagent_delivery_target, and subagent_ended hooks to the Matrix extension, enabling thread-bound ACP sessions (Claude Code, Codex) on Matrix — analogous to the existing Discord implementation.

The Matrix extension already has the full infrastructure:

  • SessionBindingAdapter (thread-bindings.ts)
  • In-memory binding store (thread-bindings-shared.ts)
  • Inbound routing/preflight (monitor/route.ts)

This commit adds the missing glue: three hook handlers that connect the ACP session lifecycle to the existing binding infrastructure.

Summary

  • Problem: sessions_spawn(runtime: "acp", thread: true) only works on Discord because only Discord registers subagent_spawning hooks. Matrix users cannot use thread-bound ACP sessions despite the binding infrastructure already existing.
  • Why it matters: Blocks the orchestrator pattern and interactive coding agents (Claude Code, Codex) on Matrix. Users get "thread=true is unavailable because no channel plugin registered subagent_spawning hooks."
  • What changed: Added three hook handlers (subagent_spawning, subagent_delivery_target, subagent_ended) + hook registration in registerFull() via dynamic import.
  • What did NOT change: No changes to the existing SessionBindingAdapter, thread-bindings store, inbound routing, or any shared surfaces. Zero changes outside extensions/matrix/.

Change Type

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

  • Matrix channels can now use sessions_spawn(runtime: "acp", thread: true) to create thread-bound ACP sessions
  • Requires config: channels.matrix.threadBindings.enabled: true and channels.matrix.threadBindings.spawnSubagentSessions: true
  • Default: disabled (no behavior change without explicit opt-in)

Security Impact

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Environment

  • OS: macOS (Darwin 25.3.0 arm64)
  • Runtime: Node v22.22.1
  • Integration: Matrix (Synapse homeserver)

Steps

  1. Set channels.matrix.threadBindings.enabled: true and spawnSubagentSessions: true
  2. From a Matrix session, call sessions_spawn(runtime: "acp", thread: true)
  3. Observe ACP session binds to room, output routes back to Matrix

Expected

  • Hook fires, binding created, ACP output appears in Matrix room/thread

Actual

  • Not yet verified end-to-end (requires .14 release with conversation-runtime in SDK exports). Unit tests cover all hook logic.

Evidence

  • Failing test/log before + passing after
  • 82 test files, 584 tests passed (including 12 new subagent-hooks tests)
  • pnpm test:extension matrix — all green

Human Verification

  • Verified: Unit tests pass, code review against Discord blueprint, import boundaries clean
  • Edge cases checked: Missing manager, missing config flags, multi-account binding lookup, cleanup on session end
  • Not verified: End-to-end ACP session on live Matrix (blocked on .14 release — installed .13 doesn't expose registerSessionBindingAdapter via SDK)

AI-Assisted PR 🤖

  • AI-assisted (Claude Code + Claude Opus orchestration)
  • Fully tested (584 tests green)
  • Understand what the code does
  • Resolve or reply to bot review conversations

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No (new optional config flags, defaults to disabled)
  • Migration needed? No

Failure Recovery

  • How to disable: Remove or revert the three files. Hooks load via dynamic import with try/catch — if they fail, Matrix continues normally.
  • Known bad symptoms: None expected. Hooks only fire when subagent_spawning/subagent_delivery_target/subagent_ended events match channel=matrix.

Risks and Mitigations

  • Risk: Hook registration could conflict with future official Matrix hook implementation
    • Mitigation: Hooks follow exact same pattern as Discord, use existing thread-bindings-shared store. No new state management.

Add subagent_spawning, subagent_delivery_target, and subagent_ended
hooks to the Matrix extension, enabling thread-bound ACP sessions
(Claude Code, Codex) on Matrix — analogous to the existing Discord
implementation.

The Matrix extension already has the full infrastructure:
- SessionBindingAdapter (thread-bindings.ts)
- In-memory binding store (thread-bindings-shared.ts)
- Inbound routing/preflight (monitor/route.ts)

This commit adds the missing glue: three hook handlers that connect
the ACP session lifecycle to the existing binding infrastructure.
@openclaw-barnacle openclaw-barnacle Bot added channel: matrix Channel integration: matrix size: L labels Mar 21, 2026
@greptile-apps

greptile-apps Bot commented Mar 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR ports the Discord subagent-hooks pattern to the Matrix extension, adding three hook handlers (subagent_spawning, subagent_delivery_target, subagent_ended) that wire ACP thread-bound sessions into the existing Matrix binding infrastructure. The change is well-scoped: it only touches extensions/matrix/, follows the established Discord blueprint, and is safely guarded by spawnSubagentSessions: false defaults and a dynamic-import try/catch in registerFull().

Issues found:

  • P1 — subagent_ended multi-account cleanup bug (subagent-hooks.ts:115–117): When event.accountId is absent, the code calls listBindingsForAccount("default") to discover account IDs, but that function filters by accountId === "default", so it only ever yields ["default"]. Bindings for any named account (e.g. "work") won't be cleaned up, leading to stale in-memory binding records. The test suite doesn't exercise this code path (the only subagent_ended test always provides accountId: "work").

  • P2 — Two unused imports (subagent-hooks.ts:1–11): resolveBindingKey and toSessionBindingRecord are imported from thread-bindings-shared.js but never called in the file.

  • P2 — Misleading comment (subagent-hooks.ts:141): The subagent_delivery_target handler comments "Collect bindings across all accounts if no specific account is given" but then falls back to "default", not a cross-account scan.

Confidence Score: 4/5

  • Safe to merge after fixing the subagent_ended multi-account cleanup bug; all other changes are correct and backward-compatible.
  • The primary happy path (where event.accountId is always present, which is the common case) works correctly. The logic bug only manifests in the fallback branch when accountId is absent in subagent_ended. The feature is disabled by default (spawnSubagentSessions: false) and the fix is small and low-risk. No changes to shared surfaces, auth, or network calls.
  • extensions/matrix/src/matrix/subagent-hooks.ts — specifically the subagent_ended multi-account fallback (lines 115–117) and unused imports.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/matrix/src/matrix/subagent-hooks.ts
Line: 115-117

Comment:
**Multi-account fallback only scans "default" account**

When `event.accountId` is absent, the fallback builds `allAccountIds` by calling `listBindingsForAccount("default")` and mapping `.accountId` — which always returns `["default"]` (or `[]`). Bindings for any named account (e.g. `"work"`, `"personal"`) will never be iterated, so their bindings won't be cleaned up on session end, leaving stale in-memory entries.

The comment on line 113 says *"Find and remove all bindings matching the ended subagent session"*, but the implementation only ever searches the `"default"` bucket.

A correct approach would either:
1. Use a store-level helper that returns every binding regardless of account (e.g. an exported `listAllBindings()`), or
2. Rely on the fact that `subagent_ended` always carries a non-empty `accountId` and remove the fallback entirely with a guard.

The test suite also misses this path — `it("unbinds room routing on subagent_ended")` always passes `accountId: "work"`, so the broken fallback branch is never exercised.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/matrix/src/matrix/subagent-hooks.ts
Line: 1-11

Comment:
**Unused imports**

`resolveBindingKey` and `toSessionBindingRecord` are imported from `./thread-bindings-shared.js` but never called in this file. They should be removed to avoid confusion and keep the import surface clean.

```suggestion
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "./account-config.js";
import {
  getMatrixThreadBindingManager,
  listBindingsForAccount,
  removeBindingRecord,
  setBindingRecord,
} from "./thread-bindings-shared.js";
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/matrix/src/matrix/subagent-hooks.ts
Line: 141-142

Comment:
**Misleading comment contradicts implementation**

The comment says *"Collect bindings across all accounts if no specific account is given"*, but the code immediately falls back to `"default"` — it does **not** collect across all accounts. The same disconnect exists in `subagent_ended` (line 113). Either update the comment to reflect the actual single-account fallback, or implement the described cross-account scan.

```suggestion
    const accountId = requesterAccountId || "default";
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "feat(matrix): add su..."

Comment thread extensions/matrix/src/matrix/subagent-hooks.ts
Comment thread extensions/matrix/src/matrix/subagent-hooks.ts
Comment on lines +141 to +142
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Misleading comment contradicts implementation

The comment says "Collect bindings across all accounts if no specific account is given", but the code immediately falls back to "default" — it does not collect across all accounts. The same disconnect exists in subagent_ended (line 113). Either update the comment to reflect the actual single-account fallback, or implement the described cross-account scan.

Suggested change
return;
}
const accountId = requesterAccountId || "default";
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/matrix/src/matrix/subagent-hooks.ts
Line: 141-142

Comment:
**Misleading comment contradicts implementation**

The comment says *"Collect bindings across all accounts if no specific account is given"*, but the code immediately falls back to `"default"` — it does **not** collect across all accounts. The same disconnect exists in `subagent_ended` (line 113). Either update the comment to reflect the actual single-account fallback, or implement the described cross-account scan.

```suggestion
    const accountId = requesterAccountId || "default";
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed with b214686

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2dd782222c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/matrix/src/matrix/subagent-hooks.ts Outdated
Comment on lines +129 to +130
for (const binding of bindings) {
removeBindingRecord(binding);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist subagent cleanup instead of removing only in memory

The ended hook bypasses Matrix's normal unbind path and just drops the record from RAM. After any routed message, sessionBindingService.touch() can flush that binding to thread-bindings.json (extensions/matrix/src/matrix/monitor/route.ts:57-59), and the supported unbind flow persists removals before sending farewells (extensions/matrix/src/matrix/thread-bindings.ts:406-413). Here the file is never updated, so a restart reloads the dead binding and the room/thread starts routing back into a session that has already ended.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed with b214686

Comment thread extensions/matrix/src/matrix/subagent-hooks.ts Outdated
- P1: Multi-account fallback — use listAllBindings() instead of only
  scanning 'default' account in subagent_ended and delivery_target
- P1: Persist on subagent_ended — call manager.persist() after removing
  binding records (mirrors unbind flow in thread-bindings.ts)
- P2: Persist on spawn — call manager.persist() after setBindingRecord
- P2: Remove unused imports (resolveBindingKey, toSessionBindingRecord)
- P2: Fix misleading comments to match actual behavior
- Tests: added multi-account fallback test, persist verification

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1bb87c68c1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/matrix/src/matrix/subagent-hooks.ts Outdated
Comment thread extensions/matrix/src/matrix/subagent-hooks.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3cac943532

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +50 to +53
void import("./src/matrix/subagent-hooks.js")
.then(({ registerMatrixSubagentHooks }) => {
try {
registerMatrixSubagentHooks(api);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register Matrix subagent hooks before returning from plugin load

Because this registration is deferred behind a fire-and-forget import(), there is a startup/reload window where Matrix still has no subagent_spawning hooks. src/plugins/loader.ts:1274-1280 does not wait for plugin registration, and src/agents/subagent-spawn.ts:248-253 immediately rejects thread=true when no hook is present, so the first Matrix sessions_spawn(..., thread:true) after gateway start or plugin reload can fail nondeterministically depending on import timing.

Useful? React with 👍 / 👎.

Comment on lines +121 to +123
const candidates = accountId
? listBindingsForAccount(accountId)
: listAllBindings();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clean persisted Matrix bindings when the account monitor is offline

This cleanup only searches listBindingsForAccount() / listAllBindings(), so it can remove bindings only while that account's manager is still holding them in memory. When a Matrix monitor stops, extensions/matrix/src/matrix/thread-bindings.ts:372-376 drops the account's records from RAM but leaves thread-bindings.json on disk, and createMatrixThreadBindingManager() reloads that file on the next start (extensions/matrix/src/matrix/thread-bindings.ts:237-239). If the child session is reset or deleted while the Matrix account is offline, subagent_ended never sees the binding and the stale room/thread mapping comes back on restart.

Useful? React with 👍 / 👎.

- P1: subagent_spawning no longer calls setBindingRecord/persist directly.
  The core invokes the adapter's bind() with placement='child' after we
  return threadBindingReady: true. The adapter handles record creation,
  persistence, and child thread creation atomically.
- P2: Removed setBindingRecord import (no longer used in hooks).
- Tests updated to verify setBindingRecord is NOT called from hooks.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b21468513f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/matrix/src/matrix/subagent-hooks.ts
Comment on lines +95 to +103
for (const binding of matching) {
if (removeBindingRecord(binding)) {
affectedAccountIds.add(binding.accountId);
}
}
// Persist for each affected account via its manager.
for (const acctId of affectedAccountIds) {
const manager = getMatrixThreadBindingManager(acctId);
await manager?.persist();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route ended-session cleanup through Matrix's unbind flow

For spawn-failed, session-reset, and session-delete, callers set sendFarewell: true (src/agents/subagent-spawn.ts:693-700, src/gateway/session-reset-service.ts:95-104), but this handler removes bindings directly and only persists them. Because it never goes through extensions/matrix/src/matrix/thread-bindings.ts:406-413, Matrix never posts the farewell message that the existing unbind path sends, so users are left with a dead room/thread that looks silently active and can keep replying into it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid enhancement — the current implementation mirrors the cleanup pattern but skips farewell messages. Happy to add this if a maintainer wants it in scope.

@gumadeiras

Copy link
Copy Markdown
Member

Matrix ACP thread binding already exists on main. This is about Matrix runtime="subagent" with thread=true.

The main issue is that the hook returns threadBindingReady: true, but nothing creates the Matrix binding after that. Please reframe the PR around subagent thread binding, and use the existing binding service for bind/unbind; then I will take a look

@gumadeiras

Copy link
Copy Markdown
Member

Thanks for putting this together.

I’m going to close this as superseded: Matrix subagent thread binding hooks are already implemented on main, including subagent_spawning, subagent_delivery_target, and subagent_ended, with the current binding-service path and tests in extensions/matrix/src/matrix/subagent-hooks.test.ts.

If there is still a missing Matrix subagent behavior that this PR covers and main does not, please open a new PR against current main with that smaller delta and I’ll take another look.

@gumadeiras gumadeiras closed this Apr 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: matrix Channel integration: matrix size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants