Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
eced5a1
Add OpenClaw bridge package skeleton
May 16, 2026
a388a23
Add OpenClaw bridge registration config
May 16, 2026
5905c34
Add OpenClaw agent room registry
May 16, 2026
313886f
Add OpenClaw approval response mapping
May 16, 2026
41b9fc6
Add OpenClaw gateway runtime wrapper
May 16, 2026
043966c
Add OpenClaw Matrix bridge coordinator
May 16, 2026
7252973
Add OpenClaw bridge management CLI
May 16, 2026
8b59e29
Add OpenClaw Pickle bridge connector
May 16, 2026
2974170
Add OpenClaw session backfill planning
May 16, 2026
cbf35b6
Wire OpenClaw history into bridge backfill
May 16, 2026
2ba589d
Add OpenClaw Beeper setup helpers
May 16, 2026
4918041
Add OpenClaw HTTP gateway transport
May 16, 2026
5922ca4
Add OpenClaw Beeper bridge runtime
May 16, 2026
c449f8a
Persist OpenClaw Beeper account identity
May 16, 2026
75b2fcb
Add OpenClaw bridge start command
May 16, 2026
d460ca3
Support Beeper account creation in OpenClaw setup
May 16, 2026
c6e401d
Model OpenClaw session users as ghosts
May 16, 2026
afeeabe
Add OpenClaw WebSocket gateway transport
May 16, 2026
b28f9fd
Expose broader OpenClaw gateway features
May 16, 2026
2d89f80
Add OpenClaw session backfill executor
May 16, 2026
adcb310
Normalize OpenClaw gateway stream events
May 16, 2026
58cfd37
Add OpenClaw protocol coverage manifest
May 16, 2026
f75e8b4
Document OpenClaw bridge package usage
May 16, 2026
a7c03ef
Wire OpenClaw startup backfill
batuhan May 16, 2026
7b6fce6
Force non-federated OpenClaw portals
batuhan May 16, 2026
227f644
Expose OpenClaw gateway RPC management
batuhan May 16, 2026
00a8a11
Create sessions for OpenClaw agent DMs
batuhan May 16, 2026
60ba686
Merge remote-tracking branch 'origin/main' into batuhan/oc-2
May 24, 2026
514a1a9
Refactor core workflow and supporting modules
May 24, 2026
51d4bdf
Remove gateway token fallback from OpenClaw bridge
May 25, 2026
485850a
Refine pickle openclaw plugin packaging and session handling
May 25, 2026
b38b4d9
Stream OpenClaw runs through AG-UI channel replies
May 25, 2026
b33f1c2
Require native OpenClaw channel turns for Beeper streaming
May 25, 2026
6a06ccd
Fix Beeper plugin to stream AG-UI responses natively
May 25, 2026
7475c36
Propagate Matrix room ids into OpenClaw turns
May 25, 2026
d58a809
Fix Beeper streaming and approval tool-call shapes
May 25, 2026
94a36cf
Stream Beeper tool output and handle OpenClaw slash commands
May 26, 2026
584b067
Add HTML formatting to OpenClaw command replies
May 27, 2026
41644c2
Rewrite OpenClaw as a first-class Beeper network connector
May 27, 2026
cb8fc40
Refactor pickle data flow and UI state handling
May 27, 2026
739e0ec
Sync openclaw manifest schema with bridge config
May 27, 2026
2e805e6
Enable native Beeper stream identities and tool streaming
May 27, 2026
7274fad
Handle Codex tool stream events without false positives
May 27, 2026
1c6b06c
Rename OpenClaw package and remove plan files
May 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Requires Node 22+, pnpm 9+, and a Go toolchain.
pnpm typecheck
pnpm test
pnpm build
go test ./... # run from packages/pickle/native
pnpm test:go # runs Pickle's Go tests with the goolm build tag
```

## Release
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "pickle-monorepo",
"name": "@beeper/openclaw",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify duplicate package names across all package.json files.
fd -a '^package\.json$' | while read -r f; do
  name=$(jq -r '.name // empty' "$f")
  if [ -n "$name" ]; then
    printf "%s\t%s\n" "$name" "$f"
  fi
done | sort | awk -F'\t' '
{
  count[$1]++
  files[$1]=files[$1] "\n  - " $2
}
END {
  for (n in count) {
    if (count[n] > 1) {
      print "DUPLICATE:", n, "(" count[n] " files)" files[n]
    }
  }
}'

Repository: beeper/pickle

Length of output: 185


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show relevant fields from root and workspace package
echo "=== ./package.json ==="
jq -r '{name, private, scripts_test_go: (.scripts["test:go"] // null)}' package.json

echo
echo "=== ./packages/openclaw/package.json ==="
jq -r '{name, private, scripts_test_go: (.scripts["test:go"] // null)}' packages/openclaw/package.json

echo
# Validate what `@beeper/pickle`'s scripts.test:go actually is (best-effort)
# Find package.json(s) with name "`@beeper/pickle`"
fd -a 'package\.json$' . -S | while read -r f; do
  n=$(jq -r '.name // empty' "$f" 2>/dev/null || true)
  if [ "$n" = "`@beeper/pickle`" ]; then
    echo "=== $f (name=`@beeper/pickle`) ==="
    jq -r '{path: "'"$f"'", scripts_test_go: (.scripts["test:go"] // null)}' "$f"
  fi
done | head -n 50

Repository: beeper/pickle

Length of output: 443


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find all package.json files and print scripts.test:go for the package(s) named `@beeper/pickle`
fd -t f 'package\.json$' . | while read -r f; do
  n="$(jq -r '.name // empty' "$f" 2>/dev/null || true)"
  if [ "$n" = "`@beeper/pickle`" ]; then
    echo "=== $f (name=`@beeper/pickle`) ==="
    jq -r '{path: "'"$f"'", scripts_test_go: (.scripts["test:go"] // null)}' "$f"
  fi
done

Repository: beeper/pickle

Length of output: 228


Avoid duplicate workspace package identity.

  • Line 2: root "name": "@beeper/openclaw" duplicates packages/openclaw/package.json’s "name": "@beeper/openclaw", which can cause workspace/package selection ambiguity in pnpm tooling—rename the root to a distinct identity (keep it private) or drop the root name.
  • Line 18: the test:go delegation to pnpm --filter @beeper/pickle test:go is correct; @beeper/pickle runs cd native && go test -tags goolm ./....
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 2, The root package.json currently duplicates the
workspace package identity by having "name": "`@beeper/openclaw`" (same as
packages/openclaw/package.json); remove or change the root "name" to a distinct
value (or drop it entirely) and ensure "private": true remains, so pnpm
workspace selection isn’t ambiguous; leave the existing "scripts.test:go"
delegating to "pnpm --filter `@beeper/pickle` test:go" unchanged.

"private": true,
"type": "module",
"packageManager": "pnpm@10.25.0",
Expand All @@ -15,7 +15,7 @@
"smoke:cloudflare": "node scripts/smoke-cloudflare-worker.mjs",
"smoke:consumer": "node scripts/package-consumer-smoke.mjs",
"smoke:package-consumer": "node scripts/package-consumer-smoke.mjs",
"test:go": "cd packages/pickle/native && go test -tags goolm ./...",
"test:go": "pnpm --filter @beeper/pickle test:go",
"test:e2e": "pnpm build && pnpm --dir e2e test",
"test:e2e:adapter": "pnpm build && pnpm --dir e2e test:adapter",
"test:e2e:browser:serve": "pnpm --dir e2e test:browser:serve",
Expand Down
26 changes: 10 additions & 16 deletions packages/bridge/src/appservice-websocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ afterEach(async () => {
});

describe("AppserviceWebsocket", () => {
it("connects to as_sync, dispatches transactions, and acknowledges them", async () => {
it("connects to as_sync, forwards transactions, and acknowledges them", async () => {
const httpServer = createServer();
const wsServer = new WebSocketServer({ server: httpServer });
servers.push(wsServer, httpServer);
await new Promise<void>((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
const homeserver = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/_hungryserv/alice`;
const dispatch = vi.fn(async () => {});
const handleTransaction = vi.fn(async () => {});
const connected = new Promise<void>((resolve, reject) => {
wsServer.on("connection", (socket, request) => {
try {
Expand Down Expand Up @@ -55,19 +55,21 @@ describe("AppserviceWebsocket", () => {
});
});
const websocket = createWebsocket(homeserver, {
dispatch,
handleTransaction,
log: (() => {}) as BridgeLogger,
});
websockets.push(websocket);

websocket.start();
await connected;

expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({
eventId: "$event",
kind: "message",
roomId: "!room:example",
text: "hi",
expect(handleTransaction).toHaveBeenCalledWith(expect.objectContaining({
events: [expect.objectContaining({
event_id: "$event",
room_id: "!room:example",
type: "m.room.message",
})],
txn_id: "txn-1",
}));
});

Expand Down Expand Up @@ -147,7 +149,6 @@ describe("AppserviceWebsocket", () => {
servers.push(wsServer, httpServer);
await new Promise<void>((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
const homeserver = `http://127.0.0.1:${(httpServer.address() as AddressInfo).port}/_hungryserv/alice`;
const dispatch = vi.fn(async () => {});
const handleTransaction = vi.fn(async () => {});
const connected = new Promise<void>((resolve, reject) => {
wsServer.on("connection", (socket) => {
Expand Down Expand Up @@ -183,7 +184,6 @@ describe("AppserviceWebsocket", () => {
});
});
const websocket = createWebsocket(homeserver, {
dispatch,
handleTransaction,
log: (() => {}) as BridgeLogger,
});
Expand All @@ -192,11 +192,6 @@ describe("AppserviceWebsocket", () => {
websocket.start();
await connected;

expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({
eventId: "$proxied",
kind: "message",
text: "proxied",
}));
expect(handleTransaction).toHaveBeenCalledWith(expect.objectContaining({
events: [expect.objectContaining({ event_id: "$proxied" })],
txn_id: "txn-2",
Expand Down Expand Up @@ -327,7 +322,6 @@ function createWebsocket(
url: "",
},
},
dispatch: vi.fn(async () => {}),
log: (() => {}) as BridgeLogger,
...overrides,
});
Expand Down
124 changes: 15 additions & 109 deletions packages/bridge/src/appservice-websocket.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import WebSocket from "ws";
import type { MatrixAppserviceInitOptions, MatrixClientEvent } from "@beeper/pickle";
import type { MatrixAppserviceInitOptions } from "@beeper/pickle";
import type { BridgeLogger } from "./types";

export interface AppserviceWebsocketOptions {
appservice: MatrixAppserviceInitOptions;
dispatch(event: MatrixClientEvent): Promise<unknown>;
handleHTTPProxy?(request: HTTPProxyRequest): Promise<HTTPProxyResponse | null>;
handleTransaction?(transaction: Record<string, unknown>): Promise<unknown>;
log: BridgeLogger;
Expand Down Expand Up @@ -40,7 +39,6 @@ export class AppserviceWebsocket {
};

readonly #appservice: MatrixAppserviceInitOptions;
readonly #dispatch: (event: MatrixClientEvent) => Promise<unknown>;
readonly #handleProxy: ((request: HTTPProxyRequest) => Promise<HTTPProxyResponse | null>) | undefined;
readonly #handleTransaction: ((transaction: Record<string, unknown>) => Promise<unknown>) | undefined;
readonly #log: BridgeLogger;
Expand All @@ -61,7 +59,6 @@ export class AppserviceWebsocket {

constructor(options: AppserviceWebsocketOptions) {
this.#appservice = options.appservice;
this.#dispatch = options.dispatch;
this.#handleProxy = options.handleHTTPProxy;
this.#handleTransaction = options.handleTransaction;
this.#log = options.log;
Expand Down Expand Up @@ -201,7 +198,19 @@ export class AppserviceWebsocket {
}

async #handleMessage(data: WebSocket.RawData): Promise<void> {
const message = JSON.parse(data.toString()) as WebsocketMessage;
const raw = data.toString();
if (!raw.trim()) {
this.#log("warn", "appservice_websocket_empty_message");
return;
}
let message: WebsocketMessage;
try {
message = JSON.parse(raw) as WebsocketMessage;
} catch (error: unknown) {
const messageText = error instanceof Error ? error.message : String(error);
this.#log("error", "appservice_websocket_invalid_json", { error: messageText, size: raw.length });
return;
}
this.#log("debug", "appservice_websocket_message", {
command: message.command ?? "transaction",
eventCount: message.events?.length,
Expand All @@ -220,16 +229,6 @@ export class AppserviceWebsocket {
if (message.command === "response" || message.command === "error") return;
if (!message.command || message.command === "transaction") {
await this.#handleTransaction?.(message as Record<string, unknown>);
for (const raw of message.events ?? []) {
const event = rawMatrixEvent(raw);
this.#log("debug", "appservice_websocket_transaction_event", {
eventId: raw.event_id,
roomId: raw.room_id,
sender: raw.sender,
type: raw.type,
});
if (event) await this.#dispatch(event);
}
this.#send(messageResponse(message, true, { txn_id: message.txn_id }));
return;
}
Expand Down Expand Up @@ -270,10 +269,6 @@ export class AppserviceWebsocket {
txnId: transactionMatch[1],
});
await this.#handleTransaction?.(transaction);
for (const raw of events) {
const event = rawMatrixEvent(raw as RawMatrixEvent);
if (event) await this.#dispatch(event);
}
return jsonHTTPResponse(200, {});
}
if (method === "GET" && /^\/?_matrix\/app\/v1\/users\//.test(path)) {
Expand Down Expand Up @@ -324,7 +319,7 @@ interface WebsocketRequest {
interface WebsocketMessage {
command?: string;
data?: unknown;
events?: RawMatrixEvent[];
events?: unknown[];
id?: number;
status?: string;
to_device?: unknown;
Expand All @@ -346,19 +341,6 @@ export interface HTTPProxyResponse {
status: number;
}

interface RawMatrixEvent {
[key: string]: unknown;
content?: Record<string, unknown>;
event_id?: string;
origin_server_ts?: number;
redacts?: string;
room_id?: string;
sender?: string;
state_key?: string;
type?: string;
unsigned?: Record<string, unknown>;
}

function messageResponse(message: WebsocketMessage, ok: boolean, data: unknown): WebsocketRequest | null {
if (message.id === undefined || message.id === null || message.command === "response" || message.command === "error") return null;
return {
Expand Down Expand Up @@ -400,86 +382,10 @@ function eventCount(events: unknown): number | undefined {
return Array.isArray(events) && events.length > 0 ? events.length : undefined;
}

function rawMatrixEvent(raw: RawMatrixEvent): MatrixClientEvent | null {
const type = raw.type ?? "";
const content = raw.content ?? {};
const roomId = raw.room_id;
const eventId = raw.event_id;
const senderId = raw.sender;
const sender = senderId ? { isMe: false, userId: senderId } : undefined;
if (type === "m.room.message" && roomId && eventId && sender) {
return stripUndefined({
attachments: [],
class: "message",
content,
edited: false,
encrypted: false,
eventId,
kind: "message",
messageType: stringValue(content.msgtype) ?? "m.text",
raw,
roomId,
sender,
text: stringValue(content.body) ?? "",
timestamp: raw.origin_server_ts,
type,
unsigned: raw.unsigned,
}) as MatrixClientEvent;
}
if (type === "m.reaction" && roomId && eventId && sender) {
const relates = objectValue(content["m.relates_to"]);
return stripUndefined({
added: true,
class: "message",
content,
eventId,
key: stringValue(relates?.key) ?? "",
kind: "reaction",
raw,
relatesTo: stringValue(relates?.event_id) ?? "",
roomId,
sender,
timestamp: raw.origin_server_ts,
type,
unsigned: raw.unsigned,
}) as MatrixClientEvent;
}
if (type === "m.room.redaction" && roomId) {
return genericEvent("redaction", raw, content);
}
if (type === "m.typing") {
return genericEvent("typing", raw, content);
}
return genericEvent("raw", raw, content);
}

function genericEvent(kind: "raw" | "redaction" | "typing", raw: RawMatrixEvent, content: Record<string, unknown>): MatrixClientEvent {
const event = {
class: kind === "typing" ? "ephemeral" : "unknown",
content,
eventId: raw.event_id,
kind,
raw,
roomId: raw.room_id,
sender: raw.sender ? { isMe: false, userId: raw.sender } : undefined,
timestamp: raw.origin_server_ts,
type: raw.type ?? "",
unsigned: raw.unsigned,
};
return stripUndefined(event) as MatrixClientEvent;
}

function objectValue(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" ? value as Record<string, unknown> : undefined;
}

function stringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}

function stripUndefined<T extends Record<string, unknown>>(value: T): T {
for (const key of Object.keys(value)) {
if (value[key] === undefined) delete value[key];
}
return value;
}
27 changes: 26 additions & 1 deletion packages/bridge/src/beeper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("Beeper bridge manager helpers", () => {
}
expect(String(url)).toBe("https://api.example/bridgebox/alice/bridge/sh-dummy/bridge_state");
expect(init?.method).toBe("POST");
expect(init?.headers).toMatchObject({ authorization: "Bearer token" });
expect(init?.headers).toMatchObject({ authorization: "Bearer as" });
expect(JSON.parse(String(init?.body))).toEqual({
info: {},
isSelfHosted: true,
Expand Down Expand Up @@ -110,6 +110,31 @@ describe("Beeper bridge manager helpers", () => {
id: "sh-dummy",
});
});

it("refuses to post bridge state without an appservice token", async () => {
const fetch = vi.fn(async (url: URL) => {
if (String(url) === "https://api.example/whoami") {
return jsonResponse({
user: { bridges: {} },
userInfo: { username: "alice" },
});
}
return jsonResponse({
hs_token: "hs",
id: "sh-dummy",
namespaces: { user_ids: [{ exclusive: true, regex: "@dummy_.*:beeper.local" }] },
sender_localpart: "dummybot",
url: "websocket",
});
});

await expect(createBeeperAppServiceInit({
baseDomain: "example",
bridge: "sh-dummy",
fetch: fetch as never,
token: "token",
})).rejects.toThrow("missing as_token");
});
});

function jsonResponse(data: unknown): Response {
Expand Down
9 changes: 6 additions & 3 deletions packages/bridge/src/beeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,19 +112,22 @@ export class BeeperBridgeManagerClient {
self_hosted: options.selfHosted ?? true,
}));
if (options.postState !== false) {
if (!registration.asToken) {
throw new Error(`Beeper appservice registration for ${options.bridge} did not include an appservice token`);
}
const stateOptions: PostBridgeStateOptions = {
bridge: options.bridge,
isSelfHosted: options.selfHosted ?? true,
reason: "SELF_HOST_REGISTERED",
stateEvent: bridgeStateEvent(options),
};
if (options.bridgeType !== undefined) stateOptions.bridgeType = options.bridgeType;
await this.postBridgeState(stateOptions);
await this.postBridgeState(stateOptions, registration.asToken);
}
return registration;
}

async postBridgeState(options: PostBridgeStateOptions): Promise<void> {
async postBridgeState(options: PostBridgeStateOptions, token?: string): Promise<void> {
const whoami = await this.whoami();
const username = this.#username ?? whoami.userInfo.username;
await this.#request("api", "POST", `/bridgebox/${encodeURIComponent(username)}/bridge/${encodeURIComponent(options.bridge)}/bridge_state`, {
Expand All @@ -133,7 +136,7 @@ export class BeeperBridgeManagerClient {
isSelfHosted: options.isSelfHosted ?? true,
reason: options.reason,
stateEvent: options.stateEvent,
});
}, undefined, token);
}

async createAppService(options: CreateAppServiceOptions): Promise<RegisteredAppService> {
Expand Down
Loading
Loading