Skip to content

Commit dacdeba

Browse files
committed
feat(bridge): add asyncRewake hooks for idle-Claude delivery
Replace the in-memory pendingBuffer in the Claude Code bridge with a file-backed queue at ~/.agents/bus/pending/claude-code--<slug>.jsonl. Both the in-process tool-call drain and the new out-of-process hook drain script use atomic rename as the synchronisation primitive, so concurrent drains never duplicate or lose events. Add hooks/drain.sh and hooks/hooks.json. The hooks declare PostToolUse, Stop, and UserPromptSubmit entries that invoke drain.sh via asyncRewake. When pending events exist, drain.sh exits 2 with the content on stderr; Claude Code wraps it in a <system-reminder> and wakes idle Claude via the enqueuePendingNotification / useQueueProcessor path. This removes the dependency on notifications/claude/channel (gated by tengu_harbor_ledger) for delivery to idle Claude sessions.
1 parent 6e3c7f8 commit dacdeba

6 files changed

Lines changed: 132 additions & 12 deletions

File tree

.claude-plugin/plugin.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"homepage": "https://github.com/ExaDev/agent-comms#readme",
99
"repository": "https://github.com/ExaDev/agent-comms",
1010
"license": "MIT",
11+
"hooks": "./hooks/hooks.json",
1112
"mcpServers": {
1213
"agent-comms": {
1314
"command": "npx",

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ agent_comms({ action: "update", visibility: "hidden" })
217217

218218
When `streamingBehavior` is absent, each bridge falls back to its existing heuristic: actionable events (DMs, room messages, invites) are treated as `steer`; status changes and membership events are treated as `info`.
219219

220-
**Limitations on non-native harnesses**: Claude Code's channel protocol is a single-lane push — there is no runtime mechanism to force a mid-call interrupt. The `[STEER]` and `[FOLLOWUP]` markers are visible in the session and structured `meta.streamingBehavior` carries the intent, but acting on them is down to the receiving agent. The pi bridge honours the hint natively.
220+
**Claude Code delivery mechanism**: Events are written to `~/.agents/bus/pending/claude-code--<cwd-slug>.jsonl`. Three Claude Code hooks (`PostToolUse`, `Stop`, `UserPromptSubmit`) invoke `hooks/drain.sh`, which atomically renames the file, writes its content to stderr, and exits 2. Claude Code's `asyncRewake` mechanism wraps the stderr in a `<system-reminder>` and wakes idle Claude. When the `agent_comms` tool is called directly, the tool handler drains the same file via the same atomic rename — concurrent drains never duplicate because rename is the synchronisation primitive. The `[STEER]` and `[FOLLOWUP]` markers and `meta.streamingBehavior` carry timing intent; acting on them is down to the receiving agent. The pi bridge honours the hint natively via `deliverAs`.
221221

222222
## Room types
223223

hooks/drain.sh

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#!/usr/bin/env bash
2+
# Drain pending agent-comms events and exit 2 to wake idle Claude via asyncRewake.
3+
#
4+
# Called by Claude Code hooks (PostToolUse, Stop). If there are pending events,
5+
# writes them to stderr and exits 2 — Claude Code wraps the stderr in a
6+
# <system-reminder> and enqueues it as a task-notification, waking idle Claude.
7+
#
8+
# Uses atomic rename to drain so concurrent drains (in-process tool drain vs
9+
# this out-of-process hook) never duplicate or lose events.
10+
11+
set -euo pipefail
12+
13+
SLUG="${PWD//[^a-zA-Z0-9]/_}"
14+
PENDING="$HOME/.agents/bus/pending/claude-code--${SLUG}.jsonl"
15+
DRAINING="${PENDING}.draining-$$-$(date +%s%N 2>/dev/null || date +%s)"
16+
17+
# Atomic drain — if rename fails (file absent), nothing to surface.
18+
if ! mv "$PENDING" "$DRAINING" 2>/dev/null; then
19+
exit 0
20+
fi
21+
22+
CONTENT=$(cat "$DRAINING")
23+
rm -f "$DRAINING"
24+
25+
if [ -z "$CONTENT" ]; then
26+
exit 0
27+
fi
28+
29+
echo "Pending agent-comms messages:" >&2
30+
while IFS= read -r line; do
31+
[ -n "$line" ] && echo " 📬 $line" >&2
32+
done <<< "$CONTENT"
33+
34+
exit 2

hooks/hooks.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"PostToolUse": [
3+
{
4+
"matcher": "*",
5+
"hooks": [
6+
{
7+
"type": "command",
8+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/drain.sh",
9+
"asyncRewake": true,
10+
"timeout": 5
11+
}
12+
]
13+
}
14+
],
15+
"Stop": [
16+
{
17+
"matcher": "*",
18+
"hooks": [
19+
{
20+
"type": "command",
21+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/drain.sh",
22+
"asyncRewake": true,
23+
"timeout": 5
24+
}
25+
]
26+
}
27+
],
28+
"UserPromptSubmit": [
29+
{
30+
"matcher": "*",
31+
"hooks": [
32+
{
33+
"type": "command",
34+
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/drain.sh",
35+
"timeout": 5
36+
}
37+
]
38+
}
39+
]
40+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
},
3535
"files": [
3636
"dist",
37-
".claude-plugin/"
37+
".claude-plugin/",
38+
"hooks/"
3839
],
3940
"bin": {
4041
"agent-comms": "dist/cli.js"

src/bridges/claude-code/channel.ts

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@
22
* Agent Comms — Claude Code channel bridge.
33
*
44
* MCP channel server that provides the "agent_comms" tool and pushes
5-
* incoming messages into Claude's context via <channel> events.
5+
* incoming messages into Claude's context via <channel> events and hooks.
66
* Uses TCP mesh for real-time delivery — no filesystem polling.
77
*
88
* Run via: npx agent-comms bridge claude-code
9-
* Requires: claude --dangerously-load-development-channels
9+
*
10+
* Pending events are persisted to ~/.agents/bus/pending/claude-code--<slug>.jsonl
11+
* so the asyncRewake hook scripts (hooks/drain.sh) can drain them out-of-process
12+
* and wake idle Claude via exit code 2.
1013
*/
1114

15+
import * as fs from "node:fs";
16+
import * as os from "node:os";
17+
import * as path from "node:path";
18+
1219
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1320
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1421

@@ -31,14 +38,51 @@ function isRecord(value: unknown): value is Record<string, unknown> {
3138
return typeof value === "object" && value !== null && !Array.isArray(value);
3239
}
3340

41+
function isErrnoException(err: unknown): err is NodeJS.ErrnoException {
42+
return typeof err === "object" && err !== null && "code" in err;
43+
}
44+
45+
function pendingFilePath(cwd: string): string {
46+
const slug = cwd.replace(/[^a-zA-Z0-9]/g, "_");
47+
return path.join(
48+
os.homedir(),
49+
".agents",
50+
"bus",
51+
"pending",
52+
`claude-code--${slug}.jsonl`,
53+
);
54+
}
55+
56+
function appendPending(filePath: string, line: string): void {
57+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
58+
fs.appendFileSync(filePath, line + "\n");
59+
}
60+
61+
function drainPending(filePath: string): string[] {
62+
const drainPath = `${filePath}.draining-${String(process.pid)}-${String(Date.now())}`;
63+
try {
64+
fs.renameSync(filePath, drainPath);
65+
} catch (err) {
66+
if (isErrnoException(err) && err.code === "ENOENT") return [];
67+
throw err;
68+
}
69+
const content = fs.readFileSync(drainPath, "utf-8");
70+
fs.unlinkSync(drainPath);
71+
return content
72+
.split("\n")
73+
.map((l) => l.trim())
74+
.filter((l) => l.length > 0);
75+
}
76+
3477
export async function run(): Promise<void> {
3578
const identity = generateIdentity();
3679
const store = new MeshStore();
3780
store.peerId = identity.fingerprint;
3881
store.setTransport(new TlsTransport(store.events, identity));
3982
const tool = new CommsTool(store, store.discovery);
4083
let agentId: string | undefined;
41-
const pendingBuffer: string[] = [];
84+
85+
const pendingFile = pendingFilePath(process.cwd());
4286

4387
const mcp = new McpServer(
4488
{ name: "agent-comms", version: "0.2.0" },
@@ -49,16 +93,16 @@ export async function run(): Promise<void> {
4993
},
5094
);
5195

52-
// All events land in the drain buffer so idle-Claude never silently misses
53-
// a message. Actionable events also get an eager channel push for mid-turn
54-
// delivery. If the push fired and Claude caught it, the drain will echo it
55-
// on the next tool call — acceptable duplication, far better than loss.
96+
// All events are written to the pending file so the out-of-process hook
97+
// drain script can surface them to idle Claude via asyncRewake exit 2.
98+
// Actionable events also get an eager channel push for mid-turn delivery
99+
// when channels are enabled (--dangerously-load-development-channels).
56100
store.onDelivery = async (_targetId: string, event) => {
57101
const line = formatDeliveryEvent(event);
58102
const hint = extractStreamingBehavior(event);
59103
const shouldPush = isActionableEvent(event) && hint !== "info";
60104

61-
pendingBuffer.push(line);
105+
appendPending(pendingFile, line);
62106

63107
if (shouldPush) {
64108
await mcp.server.notification({
@@ -82,7 +126,7 @@ export async function run(): Promise<void> {
82126
"Cross-harness agent communication mesh. Actions:",
83127
"register, update, whoami, create_room, list_rooms, join_room, leave_room,",
84128
"send, dm, list_agents, read_room, invite, decline_invite, kick, destroy_room.",
85-
'Incoming messages appear as <channel source="agent-comms"> events.',
129+
'Incoming messages appear as <channel source="agent-comms"> events or [comms] Pending prefix.',
86130
"Use streamingBehavior on send/dm: steer (act now), followUp (act when idle), info (whenever, default).",
87131
].join(" "),
88132
inputSchema: MCP_TOOL_PARAMS,
@@ -115,7 +159,7 @@ export async function run(): Promise<void> {
115159
action,
116160
);
117161

118-
const pending = pendingBuffer.splice(0);
162+
const pending = drainPending(pendingFile);
119163
const prefix =
120164
pending.length > 0
121165
? `[comms] Pending:\n${pending.map((l) => ` 📬 ${l}`).join("\n")}\n\n`

0 commit comments

Comments
 (0)