#20268: feat(hooks): emit subagent:complete internal hook event
docs
agents
size: L
Cluster:
Agent Messaging Enhancements
## Summary
Adds a `subagent:complete` internal hook event that fires when sub-agent sessions finish. This gives external hooks observability into sub-agent lifecycle — currently the announce flow has no hook integration, so external systems can't track, log, or retry deliveries.
Refs: https://github.com/openclaw/openclaw/discussions/20205
## Changes
### `src/hooks/internal-hooks.ts`
- Added `"subagent"` to the `InternalHookEventType` union type
### `src/agents/subagent-registry.ts`
- Added `hookEmittedRuns` Set for exactly-once delivery guard
- Extracted `emitSubagentCompleteHook(entry)` helper that checks dedup + emits the hook
- Both resolution paths (lifecycle listener + `agent.wait`) call the helper
- Guard is cleared in `releaseSubagentRun()` and `resetSubagentRegistryForTests()`
### `src/agents/subagent-registry.hook-event.test.ts` (new)
7 tests covering:
- Lifecycle path fires hook with correct payload
- agent.wait path fires hook with correct payload
- Dedup guard ensures exactly-once delivery when both paths resolve
- Error outcomes include the error field
- Error outcomes via lifecycle error events
- Full payload shape validation (childSessionKey, runId, label, task, outcome, startedAt, endedAt, runtimeMs)
- No hook fires for unregistered runIds
## Hook payload shape
```ts
{
type: "subagent",
action: "complete",
sessionKey: "<requester session key>",
context: {
childSessionKey: string,
runId: string,
label?: string,
task: string,
outcome: { status: "ok" | "error", error?: string },
startedAt: number,
endedAt: number,
runtimeMs: number
}
}
```
## Example hook usage
```ts
// hooks/subagent-logger/handler.ts
import type { InternalHookEvent } from "../../hooks/internal-hooks.js";
export default async function handler(event: InternalHookEvent) {
if (event.action === "complete") {
const { runId, label, outcome, runtimeMs } = event.context;
console.log(`[subagent:complete] ${label ?? runId} → ${outcome.status} (${runtimeMs}ms)`);
}
}
```
## What's NOT included
The initial branch had an `agent:complete` event in `agent-events.ts` that would fire for ALL agent runs (heartbeats, main sessions, etc). This was removed to keep the PR focused on the discussion proposal — subagent:complete only. The broader `agent:complete` can be a follow-up.
---
> 🤖 This PR was prepared with AI assistance.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
This PR adds a `subagent:complete` internal hook event that fires when sub-agent sessions finish, giving external hooks observability into sub-agent lifecycle. The implementation is clean: a new `emitSubagentCompleteHook` helper with a `hookEmittedRuns` dedup Set ensures exactly-once delivery across both the lifecycle listener and `agent.wait` resolution paths. The type system change in `internal-hooks.ts` is minimal and correct.
- The dedup guard in `emitSubagentCompleteHook` is synchronous, which correctly prevents races in Node's single-threaded event loop
- `hookEmittedRuns` entries are not cleaned up in `sweepSubagentRuns()` or `finalizeSubagentCleanup()` — the two paths that actually remove entries from `subagentRuns` — causing the Set to grow unboundedly in long-running processes
- Test coverage is thorough (7 tests covering both resolution paths, dedup, error outcomes, payload shape, and unregistered runId filtering), though the tests don't mock `loadConfig` or the registry store, making them less isolated than sibling test files
<h3>Confidence Score: 3/5</h3>
- The core hook emission logic is correct but the dedup Set leaks memory in long-running processes because cleanup paths don't clear it.
- Score of 3 reflects that the feature works correctly for its intended purpose and has good test coverage, but the `hookEmittedRuns` Set is never cleaned up through the normal subagent lifecycle (sweep and finalize-delete paths), which will cause slow memory growth in long-lived gateway processes. The fix is straightforward — add `hookEmittedRuns.delete(runId)` alongside existing `subagentRuns.delete(runId)` calls.
- Pay close attention to `src/agents/subagent-registry.ts` — the `hookEmittedRuns` cleanup gap in `sweepSubagentRuns` and `finalizeSubagentCleanup`.
<sub>Last reviewed commit: 018eb88</sub>
<!-- greptile_other_comments_section -->
<sub>(4/5) You can add custom instructions or style guidelines for the agent [here](https://app.greptile.com/review/github)!</sub>
<!-- /greptile_comment -->
Most Similar PRs
#23166: fix(agents): restore subagent announce chain from #22223
by tyler6204 · 2026-02-22
81.1%
#19565: feat: add agent lifecycle hook events (session, message, error)
by tag-assistant · 2026-02-17
80.2%
#20418: feat(hooks): add session:pre-spawn and agent:pre-run hook events
by NOVA-Openclaw · 2026-02-18
79.8%
#18889: feat(hooks): add agent and tool lifecycle boundaries
by vincentkoc · 2026-02-17
77.4%
#15732: [AI-assisted] feat: emit agent:response internal hook after replies
by zontasticality · 2026-02-13
76.7%
#19922: feat(hooks): add message:received and message:sent hook events
by NOVA-Openclaw · 2026-02-18
76.4%
#13415: fix(hooks): bridge agent_end events to internal/workspace hooks
by mcaxtr · 2026-02-10
76.4%
#7580: feat: add message:received internal hook with prompt injection
by rodrigoschott · 2026-02-03
76.4%
#6103: feat(hooks): add agent:context_overflow event for context death han...
by G9Pedro · 2026-02-01
76.2%
#8855: feat(hooks): add configurable compliance logging plugin
by 100menotu001 · 2026-02-04
75.1%