@type-dot-com/type-openclaw-plugin 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/SKILL.md +130 -0
- package/openclaw.plugin.json +16 -0
- package/package.json +39 -0
- package/src/config.ts +63 -0
- package/src/connection.ts +151 -0
- package/src/index.ts +192 -0
- package/src/messageHandler.ts +165 -0
- package/src/outbound.ts +84 -0
- package/src/protocol.ts +108 -0
- package/src/streamSession.ts +181 -0
- package/src/toolEvents.ts +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Savvy Learn Inc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @openclaw/type
|
|
2
|
+
|
|
3
|
+
OpenClaw channel plugin for [Type](https://type.chat) team chat integration via WebSocket.
|
|
4
|
+
|
|
5
|
+
Connects OpenClaw agents to Type so users can mention an agent in any channel or DM and get streaming responses in real time.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Duplex WebSocket connection with auto-reconnect and exponential backoff
|
|
10
|
+
- Real-time token streaming (`stream_start` -> `stream_event` -> `stream_finish`)
|
|
11
|
+
- Tool call/result forwarding during agent execution
|
|
12
|
+
- Proactive messaging to Type channels
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add the plugin path to your OpenClaw config (`~/.openclaw/openclaw.json`):
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"plugins": {
|
|
21
|
+
"load": {
|
|
22
|
+
"paths": ["~/.openclaw/plugins/type"]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Then configure the channel:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"channels": {
|
|
33
|
+
"type": {
|
|
34
|
+
"enabled": true,
|
|
35
|
+
"token": "ta_your_agent_token",
|
|
36
|
+
"wsUrl": "wss://your-type-server/api/agents/ws",
|
|
37
|
+
"agentId": "agent_..."
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Field | Required | Description |
|
|
44
|
+
|-------|----------|-------------|
|
|
45
|
+
| `token` | Yes | Agent token from Type UI (`ta_`-prefixed) |
|
|
46
|
+
| `wsUrl` | Yes | Type server WebSocket endpoint |
|
|
47
|
+
| `agentId` | Yes | Agent ID from Type (shown in agent builder) |
|
|
48
|
+
|
|
49
|
+
> **Note:** Set `agents.defaults.verboseDefault` to `"on"` in your OpenClaw config to enable streaming via `onPartialReply` callbacks.
|
|
50
|
+
|
|
51
|
+
## Documentation
|
|
52
|
+
|
|
53
|
+
See [SKILL.md](./SKILL.md) for the full protocol reference, streaming details, and troubleshooting guide.
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
[MIT](./LICENSE)
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Type Channel Plugin for OpenClaw
|
|
2
|
+
|
|
3
|
+
Connect OpenClaw agents to Type team chat via a duplex WebSocket.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
### 1. OpenClaw Config (`~/.openclaw/openclaw.json`)
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"agents": {
|
|
12
|
+
"defaults": {
|
|
13
|
+
"verboseDefault": "on"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"channels": {
|
|
17
|
+
"type": {
|
|
18
|
+
"enabled": true,
|
|
19
|
+
"token": "ta_your_agent_token",
|
|
20
|
+
"wsUrl": "wss://your-type-server/api/agents/ws",
|
|
21
|
+
"agentId": "agent_..."
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**`agents.defaults.verboseDefault`** must be set to `"on"`. This enables `onToolResult` callbacks so tool outputs can be streamed to Type.
|
|
28
|
+
|
|
29
|
+
| Field | Required | Description |
|
|
30
|
+
|-------|----------|-------------|
|
|
31
|
+
| `token` | Yes | Agent token from Type UI (`ta_`-prefixed) |
|
|
32
|
+
| `wsUrl` | Yes | Type server WebSocket endpoint |
|
|
33
|
+
| `agentId` | Yes | Agent ID from Type (shown in agent builder) |
|
|
34
|
+
|
|
35
|
+
### 2. Plugin Registration
|
|
36
|
+
|
|
37
|
+
Add to `plugins.load.paths` in your OpenClaw config:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"plugins": {
|
|
42
|
+
"load": {
|
|
43
|
+
"paths": ["~/.openclaw/plugins/type"]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## How It Works
|
|
50
|
+
|
|
51
|
+
### Connection
|
|
52
|
+
|
|
53
|
+
The plugin maintains a single duplex WebSocket to the Type server. All communication flows over this one connection:
|
|
54
|
+
|
|
55
|
+
- **Type -> Agent**: message triggers, ping keepalive
|
|
56
|
+
- **Agent -> Type**: pong, streaming responses, proactive messages
|
|
57
|
+
|
|
58
|
+
The connection auto-reconnects with exponential backoff (1s base, 60s max, with jitter). Code 4000 means "replaced by another connection" and skips reconnect to avoid storms.
|
|
59
|
+
|
|
60
|
+
### Message Flow
|
|
61
|
+
|
|
62
|
+
1. User mentions the agent in Type (channel or DM)
|
|
63
|
+
2. Type creates a streaming placeholder message and sends a `message` trigger over WS
|
|
64
|
+
3. Plugin dispatches through OpenClaw's standard agent reply pipeline
|
|
65
|
+
4. Agent generates response; `onPartialReply` fires with accumulated text
|
|
66
|
+
5. Plugin streams deltas to Type: `stream_start` -> `stream_event` (tokens) -> `stream_finish`
|
|
67
|
+
6. Type accumulates text and publishes real-time updates to connected clients
|
|
68
|
+
|
|
69
|
+
### Streaming Protocol
|
|
70
|
+
|
|
71
|
+
**Critical ordering**: `stream_start` must be acknowledged by the server before sending any `stream_event`. The server does async DB validation (agent run lookup, message validation) before creating stream state. Sending `stream_event` before the state exists results in "No active stream" errors.
|
|
72
|
+
|
|
73
|
+
The plugin handles this with an ack gate (`StreamSession`):
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
onPartialReply(text1) -> send stream_start -> await server "success" ack
|
|
77
|
+
-> send stream_event(delta1)
|
|
78
|
+
onPartialReply(text2) -> send stream_event(delta2) (streamReady already resolved)
|
|
79
|
+
onPartialReply(text3) -> send stream_event(delta3)
|
|
80
|
+
dispatch complete -> send stream_finish
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The plugin sets `disableBlockStreaming: true` in `replyOptions` and uses `onPartialReply` for text streaming. Tool outputs arrive via the `deliver` callback (with `info.kind === "tool"`) and are forwarded as `tool-call` + `tool-result` stream events.
|
|
84
|
+
|
|
85
|
+
## WebSocket Message Reference
|
|
86
|
+
|
|
87
|
+
### Server -> Agent
|
|
88
|
+
|
|
89
|
+
| Type | Description |
|
|
90
|
+
|------|-------------|
|
|
91
|
+
| `message` | Agent was triggered (mentioned in channel or DM) |
|
|
92
|
+
| `ping` | Keepalive (reply with `pong`) |
|
|
93
|
+
| `success` | Ack for a previous outbound message |
|
|
94
|
+
| `error` | Error for a previous outbound message |
|
|
95
|
+
|
|
96
|
+
### Agent -> Server
|
|
97
|
+
|
|
98
|
+
| Type | Description |
|
|
99
|
+
|------|-------------|
|
|
100
|
+
| `pong` | Reply to ping |
|
|
101
|
+
| `send` | Proactive message to a channel |
|
|
102
|
+
| `stream_start` | Begin streaming response |
|
|
103
|
+
| `stream_event` | Stream token/tool-call/tool-result |
|
|
104
|
+
| `stream_finish` | End streaming response |
|
|
105
|
+
|
|
106
|
+
### Stream Event Kinds
|
|
107
|
+
|
|
108
|
+
| Kind | Fields | Description |
|
|
109
|
+
|------|--------|-------------|
|
|
110
|
+
| `token` | `text` | Text delta |
|
|
111
|
+
| `tool-call` | `toolCallId, toolName, input` | Tool invocation started |
|
|
112
|
+
| `tool-result` | `toolCallId, toolName, outcomes` | Tool completed |
|
|
113
|
+
|
|
114
|
+
## Troubleshooting
|
|
115
|
+
|
|
116
|
+
### No streaming (response arrives all at once)
|
|
117
|
+
|
|
118
|
+
- Ensure `agents.defaults.verboseDefault` is `"on"` in `~/.openclaw/openclaw.json`
|
|
119
|
+
- For short responses, `onPartialReply` may fire only once — this is expected
|
|
120
|
+
|
|
121
|
+
### Agent doesn't respond at all
|
|
122
|
+
|
|
123
|
+
- Check the gateway logs for `[type]` prefixed messages
|
|
124
|
+
- Verify the WS connection is established (`[type] WebSocket connected`)
|
|
125
|
+
- Confirm the agent token and agent ID match the Type UI config
|
|
126
|
+
|
|
127
|
+
### Message stuck in streaming state
|
|
128
|
+
|
|
129
|
+
- The connection likely dropped mid-stream before `stream_finish` was sent
|
|
130
|
+
- Server auto-cancels after 30s idle timeout; agent timeout (120s default) is the final safety net
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "type",
|
|
3
|
+
"name": "Type",
|
|
4
|
+
"description": "Type team chat integration",
|
|
5
|
+
"channels": ["type"],
|
|
6
|
+
"configSchema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"enabled": { "type": "boolean" },
|
|
11
|
+
"token": { "type": "string" },
|
|
12
|
+
"wsUrl": { "type": "string" },
|
|
13
|
+
"agentId": { "type": "string" }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@type-dot-com/type-openclaw-plugin",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenClaw channel plugin for Type team chat integration via WebSocket",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"openclaw": {
|
|
8
|
+
"extensions": [
|
|
9
|
+
"./src/index.ts"
|
|
10
|
+
],
|
|
11
|
+
"channel": {
|
|
12
|
+
"id": "type",
|
|
13
|
+
"label": "Type",
|
|
14
|
+
"selectionLabel": "Type (Team Chat)",
|
|
15
|
+
"docsPath": "/channels/type",
|
|
16
|
+
"blurb": "Connect OpenClaw to Type team chat via WebSocket."
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src/**/*.ts",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"openclaw.plugin.json",
|
|
23
|
+
"SKILL.md"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"typecheck": "tsc --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"ws": "^8.17.1",
|
|
30
|
+
"zod": "^3.23.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.7.3",
|
|
34
|
+
"@types/ws": "^8.5.0"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Config Schema and Account Resolution
|
|
3
|
+
*
|
|
4
|
+
* Defines the configuration shape for the Type channel plugin
|
|
5
|
+
* and how to resolve account credentials from the OpenClaw config.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
const typeChannelConfigSchema = z.object({
|
|
11
|
+
enabled: z.boolean().optional().default(false),
|
|
12
|
+
token: z.string().optional().default(""),
|
|
13
|
+
wsUrl: z.string().optional().default("wss://api.type.com/api/agents/ws"),
|
|
14
|
+
agentId: z.string().optional().default(""),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const cfgSchema = z.object({
|
|
18
|
+
channels: z
|
|
19
|
+
.object({
|
|
20
|
+
type: typeChannelConfigSchema.optional(),
|
|
21
|
+
})
|
|
22
|
+
.optional(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export interface TypeAccountConfig {
|
|
26
|
+
accountId: string;
|
|
27
|
+
token: string;
|
|
28
|
+
wsUrl: string;
|
|
29
|
+
agentId: string;
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseTypeConfig(cfg: Record<string, unknown>) {
|
|
34
|
+
const parsed = cfgSchema.safeParse(cfg);
|
|
35
|
+
return parsed.success ? parsed.data.channels?.type : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* List available account IDs from the config.
|
|
40
|
+
* Type uses a single account per plugin config.
|
|
41
|
+
*/
|
|
42
|
+
export function listAccountIds(cfg: Record<string, unknown>): string[] {
|
|
43
|
+
const typeConfig = parseTypeConfig(cfg);
|
|
44
|
+
if (typeConfig?.token) return ["default"];
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve account configuration from the OpenClaw global config.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveAccount(
|
|
52
|
+
cfg: Record<string, unknown>,
|
|
53
|
+
accountId?: string,
|
|
54
|
+
): TypeAccountConfig {
|
|
55
|
+
const typeConfig = parseTypeConfig(cfg);
|
|
56
|
+
return {
|
|
57
|
+
accountId: accountId ?? "default",
|
|
58
|
+
token: typeConfig?.token ?? "",
|
|
59
|
+
wsUrl: typeConfig?.wsUrl ?? "wss://api.type.com/api/agents/ws",
|
|
60
|
+
agentId: typeConfig?.agentId ?? "",
|
|
61
|
+
enabled: typeConfig?.enabled ?? false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Connection Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages the WebSocket connection to the Type server. Handles:
|
|
5
|
+
* - Authentication via Authorization header
|
|
6
|
+
* - Automatic reconnection with exponential backoff + jitter
|
|
7
|
+
* - Ping/pong keepalive
|
|
8
|
+
* - Message sending and receiving
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import WebSocket from "ws";
|
|
12
|
+
import {
|
|
13
|
+
type TypeInboundEvent,
|
|
14
|
+
type TypeOutboundMessage,
|
|
15
|
+
typeInboundEventSchema,
|
|
16
|
+
} from "./protocol.js";
|
|
17
|
+
|
|
18
|
+
const BASE_RECONNECT_DELAY_MS = 1000;
|
|
19
|
+
const MAX_RECONNECT_DELAY_MS = 60000;
|
|
20
|
+
|
|
21
|
+
export interface ConnectionConfig {
|
|
22
|
+
token: string;
|
|
23
|
+
wsUrl: string;
|
|
24
|
+
onMessage: (event: TypeInboundEvent) => void;
|
|
25
|
+
onConnected?: () => void;
|
|
26
|
+
onDisconnected?: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class TypeConnection {
|
|
30
|
+
private ws: WebSocket | null = null;
|
|
31
|
+
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
32
|
+
private reconnectAttempts = 0;
|
|
33
|
+
private stopped = false;
|
|
34
|
+
private readonly config: ConnectionConfig;
|
|
35
|
+
|
|
36
|
+
constructor(config: ConnectionConfig) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
connect(): void {
|
|
41
|
+
this.stopped = false;
|
|
42
|
+
this.doConnect();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
disconnect(): void {
|
|
46
|
+
this.stopped = true;
|
|
47
|
+
if (this.ws) {
|
|
48
|
+
this.ws.close();
|
|
49
|
+
this.ws = null;
|
|
50
|
+
}
|
|
51
|
+
if (this.reconnectTimeout) {
|
|
52
|
+
clearTimeout(this.reconnectTimeout);
|
|
53
|
+
this.reconnectTimeout = null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
send(message: TypeOutboundMessage): boolean {
|
|
58
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
this.ws.send(JSON.stringify(message));
|
|
63
|
+
return true;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get isConnected(): boolean {
|
|
70
|
+
return this.ws?.readyState === WebSocket.OPEN;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- Internal ---
|
|
74
|
+
|
|
75
|
+
private doConnect(): void {
|
|
76
|
+
if (this.stopped) return;
|
|
77
|
+
|
|
78
|
+
const ws = new WebSocket(this.config.wsUrl, {
|
|
79
|
+
headers: {
|
|
80
|
+
Authorization: `Bearer ${this.config.token}`,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
this.ws = ws;
|
|
84
|
+
|
|
85
|
+
ws.on("open", () => {
|
|
86
|
+
this.reconnectAttempts = 0;
|
|
87
|
+
this.config.onConnected?.();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
ws.on("message", (data: WebSocket.Data) => {
|
|
91
|
+
this.handleMessage(data.toString());
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
ws.on("error", (err: Error) => {
|
|
95
|
+
console.error("[Type WS] Connection error:", err.message);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
ws.on("close", (code: number) => {
|
|
99
|
+
this.ws = null;
|
|
100
|
+
this.config.onDisconnected?.();
|
|
101
|
+
// Code 4000 = replaced by another connection — don't reconnect (avoids storm)
|
|
102
|
+
if (!this.stopped && code !== 4000) {
|
|
103
|
+
this.scheduleReconnect();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private handleMessage(raw: string): void {
|
|
109
|
+
let json: unknown;
|
|
110
|
+
try {
|
|
111
|
+
json = JSON.parse(raw);
|
|
112
|
+
} catch {
|
|
113
|
+
console.error("[Type WS] Failed to parse message:", raw);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const parsed = typeInboundEventSchema.safeParse(json);
|
|
118
|
+
if (!parsed.success) {
|
|
119
|
+
console.error("[Type WS] Unknown message shape:", raw);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const msg = parsed.data;
|
|
124
|
+
|
|
125
|
+
// Handle ping internally
|
|
126
|
+
if (msg.type === "ping") {
|
|
127
|
+
this.send({ type: "pong" });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Forward all other events to the callback
|
|
132
|
+
this.config.onMessage(msg);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private scheduleReconnect(): void {
|
|
136
|
+
const baseDelay = Math.min(
|
|
137
|
+
BASE_RECONNECT_DELAY_MS * 2 ** this.reconnectAttempts,
|
|
138
|
+
MAX_RECONNECT_DELAY_MS,
|
|
139
|
+
);
|
|
140
|
+
// Add jitter to prevent thundering herd
|
|
141
|
+
const delay = Math.round(baseDelay * (0.5 + Math.random() * 0.5));
|
|
142
|
+
this.reconnectAttempts++;
|
|
143
|
+
console.log(
|
|
144
|
+
`[Type WS] Reconnecting in ${(delay / 1000).toFixed(1)}s (attempt ${this.reconnectAttempts})...`,
|
|
145
|
+
);
|
|
146
|
+
this.reconnectTimeout = setTimeout(() => {
|
|
147
|
+
this.reconnectTimeout = null;
|
|
148
|
+
this.doConnect();
|
|
149
|
+
}, delay);
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Type Channel Plugin
|
|
3
|
+
*
|
|
4
|
+
* Registers a Type channel with OpenClaw, enabling bidirectional
|
|
5
|
+
* communication via a single duplex WebSocket connection.
|
|
6
|
+
*
|
|
7
|
+
* Follows the same pattern as openclaw-mqtt: stores api.runtime at
|
|
8
|
+
* registration time, then uses runtime.channel.reply.* to dispatch
|
|
9
|
+
* inbound messages through the standard OpenClaw agent pipeline.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { listAccountIds, resolveAccount } from "./config.js";
|
|
13
|
+
import { TypeConnection } from "./connection.js";
|
|
14
|
+
import {
|
|
15
|
+
handleInboundMessage,
|
|
16
|
+
type PluginRuntime,
|
|
17
|
+
rejectStreamAck,
|
|
18
|
+
resolveStreamAck,
|
|
19
|
+
} from "./messageHandler.js";
|
|
20
|
+
import { TypeOutboundHandler } from "./outbound.js";
|
|
21
|
+
|
|
22
|
+
// Module-level runtime reference (set during register, used in gateway)
|
|
23
|
+
let pluginRuntime: PluginRuntime | null = null;
|
|
24
|
+
let _activeConnection: TypeConnection | null = null;
|
|
25
|
+
let activeOutbound: TypeOutboundHandler | null = null;
|
|
26
|
+
let connectionState: "disconnected" | "connecting" | "connected" =
|
|
27
|
+
"disconnected";
|
|
28
|
+
|
|
29
|
+
const typePlugin = {
|
|
30
|
+
id: "type",
|
|
31
|
+
meta: {
|
|
32
|
+
id: "type",
|
|
33
|
+
label: "Type",
|
|
34
|
+
selectionLabel: "Type (Team Chat)",
|
|
35
|
+
docsPath: "/channels/type",
|
|
36
|
+
blurb: "Type team chat integration via WebSocket.",
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
capabilities: {
|
|
40
|
+
chatTypes: ["direct", "channel", "thread"] satisfies readonly string[],
|
|
41
|
+
media: false,
|
|
42
|
+
reactions: false,
|
|
43
|
+
threads: true,
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
config: {
|
|
47
|
+
listAccountIds: (cfg: Record<string, unknown>) => listAccountIds(cfg),
|
|
48
|
+
resolveAccount: (cfg: Record<string, unknown>, accountId?: string) =>
|
|
49
|
+
resolveAccount(cfg, accountId),
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
outbound: {
|
|
53
|
+
deliveryMode: "direct" satisfies string,
|
|
54
|
+
textChunkLimit: 4000,
|
|
55
|
+
|
|
56
|
+
sendText: async ({
|
|
57
|
+
to,
|
|
58
|
+
text,
|
|
59
|
+
replyToId,
|
|
60
|
+
}: {
|
|
61
|
+
to: string;
|
|
62
|
+
text: string;
|
|
63
|
+
replyToId?: string;
|
|
64
|
+
}) => {
|
|
65
|
+
if (!activeOutbound) {
|
|
66
|
+
return { ok: false, error: "Not connected" };
|
|
67
|
+
}
|
|
68
|
+
const sent = activeOutbound.sendMessage(to, text, replyToId);
|
|
69
|
+
if (!sent) {
|
|
70
|
+
return { ok: false, error: "Failed to send message" };
|
|
71
|
+
}
|
|
72
|
+
return { ok: true, channel: "type" };
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
gateway: {
|
|
77
|
+
startAccount: async (ctx: {
|
|
78
|
+
cfg: Record<string, unknown>;
|
|
79
|
+
accountId: string;
|
|
80
|
+
account: { token: string; wsUrl: string };
|
|
81
|
+
runtime: PluginRuntime;
|
|
82
|
+
abortSignal: AbortSignal;
|
|
83
|
+
log?: { info: (msg: string) => void; error: (msg: string) => void };
|
|
84
|
+
}) => {
|
|
85
|
+
if (connectionState !== "disconnected") {
|
|
86
|
+
ctx.log?.info(
|
|
87
|
+
`Type connection already ${connectionState}, skipping duplicate startAccount`,
|
|
88
|
+
);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
connectionState = "connecting";
|
|
92
|
+
|
|
93
|
+
const runtime = pluginRuntime ?? ctx.runtime;
|
|
94
|
+
const { token, wsUrl } = ctx.account;
|
|
95
|
+
const accountId = ctx.accountId;
|
|
96
|
+
|
|
97
|
+
const connection = new TypeConnection({
|
|
98
|
+
token,
|
|
99
|
+
wsUrl,
|
|
100
|
+
onMessage: (event) => {
|
|
101
|
+
if (event.type === "success") {
|
|
102
|
+
const reqType = (event as { requestType?: string }).requestType;
|
|
103
|
+
console.log(`[type] Server success: ${reqType}`);
|
|
104
|
+
if (reqType === "stream_start") {
|
|
105
|
+
resolveStreamAck();
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (event.type === "error") {
|
|
110
|
+
const errEvt = event as {
|
|
111
|
+
requestType?: string;
|
|
112
|
+
error?: string;
|
|
113
|
+
details?: unknown;
|
|
114
|
+
};
|
|
115
|
+
console.error(
|
|
116
|
+
`[type] Server error: ${errEvt.requestType} — ${errEvt.error}`,
|
|
117
|
+
errEvt.details ?? "",
|
|
118
|
+
);
|
|
119
|
+
if (errEvt.requestType === "stream_start") {
|
|
120
|
+
rejectStreamAck(new Error(errEvt.error ?? "stream_start failed"));
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (event.type !== "message") return;
|
|
126
|
+
if (!activeOutbound) return;
|
|
127
|
+
|
|
128
|
+
handleInboundMessage({
|
|
129
|
+
msg: event,
|
|
130
|
+
accountId,
|
|
131
|
+
cfg: ctx.cfg,
|
|
132
|
+
runtime,
|
|
133
|
+
outbound: activeOutbound,
|
|
134
|
+
log: ctx.log,
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
onConnected: () => {
|
|
138
|
+
connectionState = "connected";
|
|
139
|
+
ctx.log?.info("[type] WebSocket connected");
|
|
140
|
+
},
|
|
141
|
+
onDisconnected: () => {
|
|
142
|
+
connectionState = "disconnected";
|
|
143
|
+
ctx.log?.info("[type] WebSocket disconnected");
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
_activeConnection = connection;
|
|
148
|
+
activeOutbound = new TypeOutboundHandler(connection);
|
|
149
|
+
|
|
150
|
+
connection.connect();
|
|
151
|
+
|
|
152
|
+
await new Promise<void>((resolve) => {
|
|
153
|
+
ctx.abortSignal.addEventListener("abort", () => {
|
|
154
|
+
connectionState = "disconnected";
|
|
155
|
+
connection.disconnect();
|
|
156
|
+
_activeConnection = null;
|
|
157
|
+
activeOutbound = null;
|
|
158
|
+
resolve();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* OpenClaw plugin entry point.
|
|
167
|
+
* Follows the object-with-register pattern from openclaw/plugin-sdk.
|
|
168
|
+
*/
|
|
169
|
+
const plugin = {
|
|
170
|
+
id: "type",
|
|
171
|
+
name: "Type",
|
|
172
|
+
description: "Type team chat integration via duplex WebSocket",
|
|
173
|
+
register(api: {
|
|
174
|
+
runtime: PluginRuntime;
|
|
175
|
+
registerChannel: (opts: { plugin: typeof typePlugin }) => void;
|
|
176
|
+
}) {
|
|
177
|
+
pluginRuntime = api.runtime;
|
|
178
|
+
api.registerChannel({ plugin: typePlugin });
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export default plugin;
|
|
183
|
+
|
|
184
|
+
export type { TypeAccountConfig } from "./config.js";
|
|
185
|
+
// Re-export components for advanced usage
|
|
186
|
+
export { TypeConnection } from "./connection.js";
|
|
187
|
+
export { TypeOutboundHandler } from "./outbound.js";
|
|
188
|
+
export type {
|
|
189
|
+
TypeInboundEvent,
|
|
190
|
+
TypeMessageEvent,
|
|
191
|
+
TypeOutboundMessage,
|
|
192
|
+
} from "./protocol.js";
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound Message Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles a single inbound message from Type: builds the OpenClaw
|
|
5
|
+
* inbound context, creates a StreamSession, and dispatches through
|
|
6
|
+
* the standard OpenClaw agent reply pipeline.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { TypeOutboundHandler } from "./outbound.js";
|
|
10
|
+
import type { TypeMessageEvent } from "./protocol.js";
|
|
11
|
+
import { StreamSession } from "./streamSession.js";
|
|
12
|
+
import { createToolEvents } from "./toolEvents.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimal typing for the OpenClaw plugin SDK runtime.
|
|
16
|
+
*/
|
|
17
|
+
export interface PluginRuntime {
|
|
18
|
+
channel: {
|
|
19
|
+
reply: {
|
|
20
|
+
finalizeInboundContext: (
|
|
21
|
+
ctx: Record<string, unknown>,
|
|
22
|
+
) => Record<string, unknown>;
|
|
23
|
+
dispatchReplyWithBufferedBlockDispatcher: (opts: {
|
|
24
|
+
ctx: Record<string, unknown>;
|
|
25
|
+
cfg: Record<string, unknown>;
|
|
26
|
+
dispatcherOptions: {
|
|
27
|
+
deliver: (
|
|
28
|
+
payload: { text?: string },
|
|
29
|
+
info: Record<string, unknown>,
|
|
30
|
+
) => Promise<void>;
|
|
31
|
+
onSkip?: (payload: unknown, info: Record<string, unknown>) => void;
|
|
32
|
+
onError?: (err: unknown, info: Record<string, unknown>) => void;
|
|
33
|
+
};
|
|
34
|
+
replyOptions?: Record<string, unknown>;
|
|
35
|
+
}) => Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Logger {
|
|
41
|
+
info: (msg: string) => void;
|
|
42
|
+
error: (msg: string) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Active stream session — one at a time per connection.
|
|
46
|
+
// Exposed via resolveStreamAck / rejectStreamAck for the server
|
|
47
|
+
// response handler in index.ts to call.
|
|
48
|
+
let activeStreamSession: StreamSession | null = null;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the pending stream_start ack on the active session.
|
|
52
|
+
*/
|
|
53
|
+
export function resolveStreamAck(): void {
|
|
54
|
+
activeStreamSession?.onAck();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Reject the pending stream_start ack on the active session.
|
|
59
|
+
*/
|
|
60
|
+
export function rejectStreamAck(error: Error): void {
|
|
61
|
+
activeStreamSession?.onAckError(error);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Handle a single inbound message trigger from Type.
|
|
66
|
+
*/
|
|
67
|
+
export function handleInboundMessage(params: {
|
|
68
|
+
msg: TypeMessageEvent;
|
|
69
|
+
accountId: string;
|
|
70
|
+
cfg: Record<string, unknown>;
|
|
71
|
+
runtime: PluginRuntime;
|
|
72
|
+
outbound: TypeOutboundHandler;
|
|
73
|
+
log?: Logger;
|
|
74
|
+
}): void {
|
|
75
|
+
const { msg, accountId, cfg, runtime, outbound, log } = params;
|
|
76
|
+
|
|
77
|
+
log?.info(
|
|
78
|
+
`[type] Inbound message from ${msg.sender?.name ?? "unknown"} in ${msg.channelName ?? msg.channelId}`,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const chatType = msg.parentMessageId ? "thread" : "channel";
|
|
83
|
+
const senderId = msg.sender?.id ?? "unknown";
|
|
84
|
+
const senderName = msg.sender?.name ?? "Unknown";
|
|
85
|
+
const messageBody = msg.content ?? "";
|
|
86
|
+
|
|
87
|
+
const ctxPayload = runtime.channel.reply.finalizeInboundContext({
|
|
88
|
+
Body: messageBody,
|
|
89
|
+
RawBody: messageBody,
|
|
90
|
+
CommandBody: messageBody,
|
|
91
|
+
CommandAuthorized: true,
|
|
92
|
+
From: `type:${senderId}`,
|
|
93
|
+
To: `type:${accountId}`,
|
|
94
|
+
SessionKey: msg.parentMessageId
|
|
95
|
+
? `agent:main:type:${msg.parentMessageId}`
|
|
96
|
+
: `agent:main:type:${msg.channelId}:${msg.messageId}`,
|
|
97
|
+
AccountId: accountId,
|
|
98
|
+
ChatType: chatType,
|
|
99
|
+
ConversationLabel: msg.channelName ?? msg.channelId,
|
|
100
|
+
SenderName: senderName,
|
|
101
|
+
SenderId: senderId,
|
|
102
|
+
Provider: "type",
|
|
103
|
+
Surface: "type",
|
|
104
|
+
MessageSid: msg.messageId,
|
|
105
|
+
Timestamp: msg.timestamp,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const session = new StreamSession(outbound, msg.messageId);
|
|
109
|
+
activeStreamSession = session;
|
|
110
|
+
|
|
111
|
+
void runtime.channel.reply
|
|
112
|
+
.dispatchReplyWithBufferedBlockDispatcher({
|
|
113
|
+
ctx: ctxPayload,
|
|
114
|
+
cfg,
|
|
115
|
+
dispatcherOptions: {
|
|
116
|
+
deliver: async (
|
|
117
|
+
payload: { text?: string },
|
|
118
|
+
info: Record<string, unknown>,
|
|
119
|
+
) => {
|
|
120
|
+
// Handle tool results as native tool-call + tool-result stream
|
|
121
|
+
// events so they render as collapsible cards in Type's UI.
|
|
122
|
+
if (info.kind === "tool" && payload.text) {
|
|
123
|
+
if (session.isFailed) return;
|
|
124
|
+
const [toolCall, toolResult] = createToolEvents(payload.text);
|
|
125
|
+
session.sendToolEvent(toolCall);
|
|
126
|
+
session.sendToolEvent(toolResult);
|
|
127
|
+
}
|
|
128
|
+
// Other kinds (block, final) are no-op — onPartialReply handles text
|
|
129
|
+
},
|
|
130
|
+
onSkip: (_payload, info) => {
|
|
131
|
+
console.log(`[type] Reply skipped: ${JSON.stringify(info)}`);
|
|
132
|
+
},
|
|
133
|
+
onError: (err, info) => {
|
|
134
|
+
console.error(`[type] Reply error: ${err} ${JSON.stringify(info)}`);
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
replyOptions: {
|
|
138
|
+
disableBlockStreaming: true,
|
|
139
|
+
onPartialReply: (payload: { text?: string }) => {
|
|
140
|
+
if (!payload.text || session.isFailed) return;
|
|
141
|
+
session.sendToken(payload.text);
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
.then(() => {
|
|
146
|
+
if (session.isStarted) {
|
|
147
|
+
session.finish();
|
|
148
|
+
}
|
|
149
|
+
activeStreamSession = null;
|
|
150
|
+
})
|
|
151
|
+
.catch((err: unknown) => {
|
|
152
|
+
console.error(
|
|
153
|
+
`[type] Stream dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
154
|
+
);
|
|
155
|
+
if (session.isStarted) {
|
|
156
|
+
session.finish();
|
|
157
|
+
}
|
|
158
|
+
activeStreamSession = null;
|
|
159
|
+
});
|
|
160
|
+
} catch (err) {
|
|
161
|
+
log?.error(
|
|
162
|
+
`[type] Message dispatch error: ${err instanceof Error ? err.message : String(err)}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound Message Handler
|
|
3
|
+
*
|
|
4
|
+
* Wraps the WebSocket connection with typed methods for sending
|
|
5
|
+
* messages and streaming responses back to Type.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TypeConnection } from "./connection.js";
|
|
9
|
+
|
|
10
|
+
export class TypeOutboundHandler {
|
|
11
|
+
constructor(private readonly connection: TypeConnection) {}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Send a non-streaming full response to a triggered message.
|
|
15
|
+
*/
|
|
16
|
+
respond(messageId: string, content: string): boolean {
|
|
17
|
+
return this.connection.send({
|
|
18
|
+
type: "respond",
|
|
19
|
+
messageId,
|
|
20
|
+
content,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Send a proactive message to a channel.
|
|
26
|
+
*/
|
|
27
|
+
sendMessage(
|
|
28
|
+
channelId: string,
|
|
29
|
+
content: string,
|
|
30
|
+
parentMessageId?: string,
|
|
31
|
+
): boolean {
|
|
32
|
+
return this.connection.send({
|
|
33
|
+
type: "send",
|
|
34
|
+
channelId,
|
|
35
|
+
content,
|
|
36
|
+
parentMessageId,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Begin a streaming response for a triggered message.
|
|
42
|
+
*/
|
|
43
|
+
startStream(messageId: string): boolean {
|
|
44
|
+
return this.connection.send({
|
|
45
|
+
type: "stream_start",
|
|
46
|
+
messageId,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Send a streaming token (text delta).
|
|
52
|
+
*/
|
|
53
|
+
streamToken(messageId: string, text: string): boolean {
|
|
54
|
+
return this.connection.send({
|
|
55
|
+
type: "stream_event",
|
|
56
|
+
messageId,
|
|
57
|
+
event: { kind: "token", text },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Send a generic stream event (tool-call, tool-result, etc.).
|
|
63
|
+
*/
|
|
64
|
+
streamEvent(
|
|
65
|
+
messageId: string,
|
|
66
|
+
event: { kind: string; [key: string]: unknown },
|
|
67
|
+
): boolean {
|
|
68
|
+
return this.connection.send({
|
|
69
|
+
type: "stream_event",
|
|
70
|
+
messageId,
|
|
71
|
+
event,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Finalize a streaming response.
|
|
77
|
+
*/
|
|
78
|
+
finishStream(messageId: string): boolean {
|
|
79
|
+
return this.connection.send({
|
|
80
|
+
type: "stream_finish",
|
|
81
|
+
messageId,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type WebSocket Protocol Types
|
|
3
|
+
*
|
|
4
|
+
* Defines the message types exchanged over the duplex WebSocket
|
|
5
|
+
* between the OpenClaw plugin and the Type server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Inbound: Type Server -> Plugin (Zod schemas + inferred types)
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const typeMessageEventSchema = z.object({
|
|
15
|
+
type: z.literal("message"),
|
|
16
|
+
messageId: z.string(),
|
|
17
|
+
channelId: z.string(),
|
|
18
|
+
channelName: z.string().nullable(),
|
|
19
|
+
parentMessageId: z.string().nullable(),
|
|
20
|
+
sender: z
|
|
21
|
+
.object({
|
|
22
|
+
id: z.string(),
|
|
23
|
+
name: z.string(),
|
|
24
|
+
})
|
|
25
|
+
.nullable(),
|
|
26
|
+
content: z.string().nullable(),
|
|
27
|
+
mentionsAgent: z.boolean(),
|
|
28
|
+
timestamp: z.number(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const typePingEventSchema = z.object({
|
|
32
|
+
type: z.literal("ping"),
|
|
33
|
+
timestamp: z.number(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const typeSuccessEventSchema = z.object({
|
|
37
|
+
type: z.literal("success"),
|
|
38
|
+
requestType: z.string(),
|
|
39
|
+
messageId: z.string().optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const typeErrorEventSchema = z.object({
|
|
43
|
+
type: z.literal("error"),
|
|
44
|
+
requestType: z.string(),
|
|
45
|
+
error: z.string(),
|
|
46
|
+
details: z.unknown().optional(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const typeInboundEventSchema = z.discriminatedUnion("type", [
|
|
50
|
+
typeMessageEventSchema,
|
|
51
|
+
typePingEventSchema,
|
|
52
|
+
typeSuccessEventSchema,
|
|
53
|
+
typeErrorEventSchema,
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
export type TypeMessageEvent = z.infer<typeof typeMessageEventSchema>;
|
|
57
|
+
export type TypePingEvent = z.infer<typeof typePingEventSchema>;
|
|
58
|
+
export type TypeSuccessEvent = z.infer<typeof typeSuccessEventSchema>;
|
|
59
|
+
export type TypeErrorEvent = z.infer<typeof typeErrorEventSchema>;
|
|
60
|
+
export type TypeInboundEvent = z.infer<typeof typeInboundEventSchema>;
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Outbound: Plugin -> Type Server
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export interface PongMessage {
|
|
67
|
+
type: "pong";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SendMessage {
|
|
71
|
+
type: "send";
|
|
72
|
+
channelId: string;
|
|
73
|
+
content: string;
|
|
74
|
+
parentMessageId?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface RespondMessage {
|
|
78
|
+
type: "respond";
|
|
79
|
+
messageId: string;
|
|
80
|
+
content: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface StreamStartMessage {
|
|
84
|
+
type: "stream_start";
|
|
85
|
+
messageId: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface StreamEventMessage {
|
|
89
|
+
type: "stream_event";
|
|
90
|
+
messageId: string;
|
|
91
|
+
event: {
|
|
92
|
+
kind: string;
|
|
93
|
+
[key: string]: unknown;
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface StreamFinishMessage {
|
|
98
|
+
type: "stream_finish";
|
|
99
|
+
messageId: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type TypeOutboundMessage =
|
|
103
|
+
| PongMessage
|
|
104
|
+
| SendMessage
|
|
105
|
+
| RespondMessage
|
|
106
|
+
| StreamStartMessage
|
|
107
|
+
| StreamEventMessage
|
|
108
|
+
| StreamFinishMessage;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream Session
|
|
3
|
+
*
|
|
4
|
+
* Manages the lifecycle of a single streaming response: lazy
|
|
5
|
+
* stream_start, ack gating, token/event buffering, and finish.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { TypeOutboundHandler } from "./outbound.js";
|
|
9
|
+
import type { ToolEventPayload } from "./toolEvents.js";
|
|
10
|
+
|
|
11
|
+
const ACK_TIMEOUT_MS = 5000;
|
|
12
|
+
|
|
13
|
+
export class StreamSession {
|
|
14
|
+
readonly #outbound: TypeOutboundHandler;
|
|
15
|
+
readonly #messageId: string;
|
|
16
|
+
|
|
17
|
+
#failed = false;
|
|
18
|
+
#started = false;
|
|
19
|
+
#ready = false;
|
|
20
|
+
#lastSentLength = 0;
|
|
21
|
+
|
|
22
|
+
#pendingTokens: string[] = [];
|
|
23
|
+
#pendingToolEvents: ToolEventPayload[] = [];
|
|
24
|
+
#pendingAck: { resolve: () => void; reject: (err: Error) => void } | null =
|
|
25
|
+
null;
|
|
26
|
+
#ackTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
|
|
28
|
+
constructor(outbound: TypeOutboundHandler, messageId: string) {
|
|
29
|
+
this.#outbound = outbound;
|
|
30
|
+
this.#messageId = messageId;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get isFailed(): boolean {
|
|
34
|
+
return this.#failed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get isStarted(): boolean {
|
|
38
|
+
return this.#started;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lazily send stream_start if not already started.
|
|
43
|
+
* Sets up the ack promise with a timeout. Call onAck() / onAckError()
|
|
44
|
+
* from the server response handler to resolve/reject.
|
|
45
|
+
*/
|
|
46
|
+
ensureStarted(): void {
|
|
47
|
+
if (this.#started || this.#failed) return;
|
|
48
|
+
|
|
49
|
+
const sent = this.#outbound.startStream(this.#messageId);
|
|
50
|
+
if (!sent) {
|
|
51
|
+
console.error("[type] startStream send failed (connection not open)");
|
|
52
|
+
this.#failed = true;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
this.#started = true;
|
|
56
|
+
|
|
57
|
+
this.#pendingAck = {
|
|
58
|
+
resolve: () => {
|
|
59
|
+
this.#clearAckTimeout();
|
|
60
|
+
this.#ready = true;
|
|
61
|
+
this.#flushBuffers();
|
|
62
|
+
},
|
|
63
|
+
reject: (err: Error) => {
|
|
64
|
+
this.#clearAckTimeout();
|
|
65
|
+
console.error(`[type] stream_start rejected: ${err.message}`);
|
|
66
|
+
this.#failed = true;
|
|
67
|
+
this.#pendingTokens.length = 0;
|
|
68
|
+
this.#pendingToolEvents.length = 0;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
this.#ackTimeoutId = setTimeout(() => {
|
|
73
|
+
this.#ackTimeoutId = null;
|
|
74
|
+
if (this.#pendingAck) {
|
|
75
|
+
this.#pendingAck.reject(new Error("stream_start ack timeout"));
|
|
76
|
+
this.#pendingAck = null;
|
|
77
|
+
}
|
|
78
|
+
}, ACK_TIMEOUT_MS);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Called by the server response handler when stream_start is acknowledged.
|
|
83
|
+
*/
|
|
84
|
+
onAck(): void {
|
|
85
|
+
if (this.#pendingAck) {
|
|
86
|
+
this.#pendingAck.resolve();
|
|
87
|
+
this.#pendingAck = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Called by the server response handler when stream_start fails.
|
|
93
|
+
*/
|
|
94
|
+
onAckError(error: Error): void {
|
|
95
|
+
if (this.#pendingAck) {
|
|
96
|
+
this.#pendingAck.reject(error);
|
|
97
|
+
this.#pendingAck = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Send a text token delta. Computes the delta from accumulated text,
|
|
103
|
+
* buffering if the stream_start ack hasn't arrived yet.
|
|
104
|
+
*/
|
|
105
|
+
sendToken(fullText: string): void {
|
|
106
|
+
if (this.#failed) return;
|
|
107
|
+
|
|
108
|
+
const delta = fullText.slice(this.#lastSentLength);
|
|
109
|
+
if (delta.length === 0) return;
|
|
110
|
+
this.#lastSentLength = fullText.length;
|
|
111
|
+
|
|
112
|
+
this.ensureStarted();
|
|
113
|
+
if (this.#failed) return;
|
|
114
|
+
|
|
115
|
+
if (this.#ready) {
|
|
116
|
+
const sent = this.#outbound.streamToken(this.#messageId, delta);
|
|
117
|
+
if (!sent) {
|
|
118
|
+
console.error("[type] streamToken send failed (connection not open)");
|
|
119
|
+
this.#failed = true;
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
this.#pendingTokens.push(delta);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Send a tool event (tool-call, tool-result, etc.), buffering if needed.
|
|
128
|
+
*/
|
|
129
|
+
sendToolEvent(event: ToolEventPayload): void {
|
|
130
|
+
if (this.#failed) return;
|
|
131
|
+
|
|
132
|
+
this.ensureStarted();
|
|
133
|
+
if (this.#failed) return;
|
|
134
|
+
|
|
135
|
+
if (this.#ready) {
|
|
136
|
+
this.#outbound.streamEvent(this.#messageId, event);
|
|
137
|
+
} else {
|
|
138
|
+
this.#pendingToolEvents.push(event);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Finalize the stream. Call after dispatch completes (success or error).
|
|
144
|
+
*/
|
|
145
|
+
finish(): void {
|
|
146
|
+
if (!this.#started) return;
|
|
147
|
+
const finished = this.#outbound.finishStream(this.#messageId);
|
|
148
|
+
if (!finished) {
|
|
149
|
+
console.error(
|
|
150
|
+
"[type] finishStream send failed (connection not open), stream will idle-timeout on server",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#clearAckTimeout(): void {
|
|
156
|
+
if (this.#ackTimeoutId) {
|
|
157
|
+
clearTimeout(this.#ackTimeoutId);
|
|
158
|
+
this.#ackTimeoutId = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#flushBuffers(): void {
|
|
163
|
+
// Flush tool events first (they came before text)
|
|
164
|
+
for (const evt of this.#pendingToolEvents) {
|
|
165
|
+
if (this.#failed) break;
|
|
166
|
+
this.#outbound.streamEvent(this.#messageId, evt);
|
|
167
|
+
}
|
|
168
|
+
this.#pendingToolEvents.length = 0;
|
|
169
|
+
|
|
170
|
+
// Then flush text tokens
|
|
171
|
+
for (const text of this.#pendingTokens) {
|
|
172
|
+
if (this.#failed) break;
|
|
173
|
+
const sent = this.#outbound.streamToken(this.#messageId, text);
|
|
174
|
+
if (!sent) {
|
|
175
|
+
console.error("[type] streamToken send failed (connection not open)");
|
|
176
|
+
this.#failed = true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
this.#pendingTokens.length = 0;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Event Parsing
|
|
3
|
+
*
|
|
4
|
+
* Parses OpenClaw's formatted tool output text into structured
|
|
5
|
+
* tool-call and tool-result stream events for Type's UI.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ToolEventPayload {
|
|
9
|
+
kind: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse formatted tool text into a tool name and output.
|
|
15
|
+
*
|
|
16
|
+
* OpenClaw formats tool output like:
|
|
17
|
+
* "📚 Read: /path/to/file"
|
|
18
|
+
* "🧪 Exec: command here\noutput..."
|
|
19
|
+
*
|
|
20
|
+
* Strips leading emoji, splits on first colon.
|
|
21
|
+
*/
|
|
22
|
+
export function parseToolText(text: string): {
|
|
23
|
+
toolName: string;
|
|
24
|
+
toolOutput: string;
|
|
25
|
+
} {
|
|
26
|
+
const trimmed = text.trim();
|
|
27
|
+
// Strip leading emoji (unicode emoji + variation selectors + ZWJ)
|
|
28
|
+
const stripped = trimmed.replace(/^(?:\p{Emoji}|\uFE0F|\u200D)+\s*/u, "");
|
|
29
|
+
const colonIdx = stripped.indexOf(":");
|
|
30
|
+
if (colonIdx > 0) {
|
|
31
|
+
return {
|
|
32
|
+
toolName: stripped.slice(0, colonIdx).trim(),
|
|
33
|
+
toolOutput: stripped.slice(colonIdx + 1).trim(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return { toolName: "tool", toolOutput: stripped };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a tool-call + tool-result event pair from formatted tool text.
|
|
41
|
+
* Returns a 2-element array: [tool-call, tool-result].
|
|
42
|
+
*/
|
|
43
|
+
export function createToolEvents(
|
|
44
|
+
text: string,
|
|
45
|
+
): [ToolEventPayload, ToolEventPayload] {
|
|
46
|
+
const { toolName, toolOutput } = parseToolText(text);
|
|
47
|
+
const toolCallId = `tool_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
48
|
+
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
kind: "tool-call",
|
|
52
|
+
toolCallId,
|
|
53
|
+
toolName,
|
|
54
|
+
input: toolOutput ? { command: toolOutput.split("\n")[0] } : {},
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
kind: "tool-result",
|
|
58
|
+
toolCallId,
|
|
59
|
+
toolName,
|
|
60
|
+
outcomes: [{ kind: "text", toolName, text: toolOutput }],
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
}
|