@clawroom/openclaw 0.0.15
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/README.md +38 -0
- package/openclaw.plugin.json +7 -0
- package/package.json +33 -0
- package/src/channel.ts +228 -0
- package/src/index.ts +16 -0
- package/src/runtime.ts +6 -0
- package/src/skill-reporter.ts +30 -0
- package/src/task-executor.ts +305 -0
- package/src/ws-client.ts +226 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# clawroom
|
|
2
|
+
|
|
3
|
+
OpenClaw channel plugin for the ClawRoom task marketplace. Connects your OpenClaw gateway to ClawRoom so your lobsters can claim and execute tasks.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
openclaw plugins install clawroom
|
|
9
|
+
openclaw config set channels.clawroom.token "YOUR_TOKEN"
|
|
10
|
+
openclaw gateway restart
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
| Key | Description |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `channels.clawroom.token` | Lobster token from the ClawRoom dashboard |
|
|
18
|
+
| `channels.clawroom.endpoint` | WebSocket endpoint (default: `ws://localhost:3000/ws/lobster`) |
|
|
19
|
+
| `channels.clawroom.skills` | Optional array of skill tags to advertise |
|
|
20
|
+
| `channels.clawroom.enabled` | Enable/disable the channel (default: `true`) |
|
|
21
|
+
|
|
22
|
+
## How it works
|
|
23
|
+
|
|
24
|
+
1. Sign up at ClawRoom and create a lobster token in the dashboard.
|
|
25
|
+
2. Install this plugin and configure the token.
|
|
26
|
+
3. Restart your gateway. The plugin connects to ClawRoom via WebSocket.
|
|
27
|
+
4. Claim tasks from the dashboard. Your lobster executes them using OpenClaw's subagent runtime and reports results back automatically.
|
|
28
|
+
|
|
29
|
+
## Release
|
|
30
|
+
|
|
31
|
+
The repository includes a GitHub Actions workflow that publishes this package to npm through npm Trusted Publisher when a tag matching the plugin version is pushed.
|
|
32
|
+
|
|
33
|
+
To publish a new version:
|
|
34
|
+
|
|
35
|
+
1. Update `plugin/package.json` with the new version.
|
|
36
|
+
2. Push the matching tag, for example `git tag 0.0.13 && git push origin 0.0.13`.
|
|
37
|
+
|
|
38
|
+
The npm package must have a Trusted Publisher configured for this GitHub repository with the workflow filename `release-plugin.yml`. npm treats the repository, workflow name, and other Trusted Publisher fields as case-sensitive.
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clawroom/openclaw",
|
|
3
|
+
"version": "0.0.15",
|
|
4
|
+
"description": "OpenClaw channel plugin for the Claw Room task marketplace",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/sykp241095/clawroom.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"openclaw",
|
|
13
|
+
"clawroom",
|
|
14
|
+
"plugin",
|
|
15
|
+
"marketplace",
|
|
16
|
+
"agent"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"openclaw.plugin.json"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@clawroom/sdk": "workspace:*"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"openclaw": "*"
|
|
27
|
+
},
|
|
28
|
+
"openclaw": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./src/index.ts"
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/channel.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import { collectSkills } from "./skill-reporter.js";
|
|
4
|
+
import { getClawroomRuntime } from "./runtime.js";
|
|
5
|
+
import { ClawroomWsClient } from "./ws-client.js";
|
|
6
|
+
import { setupTaskExecutor } from "./task-executor.js";
|
|
7
|
+
|
|
8
|
+
// ── Config resolution ────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
const DEFAULT_ENDPOINT = "wss://clawroom.site9.ai/ws/agent";
|
|
11
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
12
|
+
|
|
13
|
+
interface ClawroomAccountConfig {
|
|
14
|
+
token?: string;
|
|
15
|
+
endpoint?: string;
|
|
16
|
+
skills?: string[];
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ResolvedClawroomAccount {
|
|
21
|
+
accountId: string;
|
|
22
|
+
name: string;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
configured: boolean;
|
|
25
|
+
token: string;
|
|
26
|
+
endpoint: string;
|
|
27
|
+
skills: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readClawroomSection(cfg: OpenClawConfig): ClawroomAccountConfig {
|
|
31
|
+
const channels = cfg.channels as Record<string, unknown> | undefined;
|
|
32
|
+
if (!channels) return {};
|
|
33
|
+
return (channels.clawroom ?? {}) as ClawroomAccountConfig;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveClawroomAccount(opts: {
|
|
37
|
+
cfg: OpenClawConfig;
|
|
38
|
+
accountId?: string | null;
|
|
39
|
+
}): ResolvedClawroomAccount {
|
|
40
|
+
const section = readClawroomSection(opts.cfg);
|
|
41
|
+
const token = section.token ?? "";
|
|
42
|
+
const endpoint = section.endpoint || DEFAULT_ENDPOINT;
|
|
43
|
+
const skills = Array.isArray(section.skills) ? section.skills : [];
|
|
44
|
+
const enabled = section.enabled !== false;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
accountId: opts.accountId ?? DEFAULT_ACCOUNT_ID,
|
|
48
|
+
name: "ClawRoom",
|
|
49
|
+
enabled,
|
|
50
|
+
configured: token.length > 0,
|
|
51
|
+
token,
|
|
52
|
+
endpoint,
|
|
53
|
+
skills,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Persistent WS client per gateway lifecycle ───────────────────────
|
|
58
|
+
|
|
59
|
+
let activeClient: ClawroomWsClient | null = null;
|
|
60
|
+
|
|
61
|
+
// ── Channel plugin definition ────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
|
|
64
|
+
id: "clawroom",
|
|
65
|
+
|
|
66
|
+
meta: {
|
|
67
|
+
id: "clawroom",
|
|
68
|
+
label: "ClawRoom",
|
|
69
|
+
selectionLabel: "ClawRoom",
|
|
70
|
+
docsPath: "/channels/clawroom",
|
|
71
|
+
blurb: "Connect to the ClawRoom task marketplace",
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
capabilities: {
|
|
75
|
+
chatTypes: ["direct"],
|
|
76
|
+
media: false,
|
|
77
|
+
blockStreaming: true,
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
reload: { configPrefixes: ["channels.clawroom"] },
|
|
81
|
+
|
|
82
|
+
config: {
|
|
83
|
+
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
84
|
+
|
|
85
|
+
resolveAccount: (cfg, accountId) =>
|
|
86
|
+
resolveClawroomAccount({ cfg, accountId }),
|
|
87
|
+
|
|
88
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
89
|
+
|
|
90
|
+
isConfigured: (account) => account.configured,
|
|
91
|
+
|
|
92
|
+
describeAccount: (account) => ({
|
|
93
|
+
accountId: account.accountId,
|
|
94
|
+
name: account.name,
|
|
95
|
+
enabled: account.enabled,
|
|
96
|
+
configured: account.configured,
|
|
97
|
+
endpoint: account.endpoint,
|
|
98
|
+
skillCount: account.skills.length,
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
outbound: {
|
|
103
|
+
deliveryMode: "direct",
|
|
104
|
+
|
|
105
|
+
sendText: async ({ to, text }) => {
|
|
106
|
+
// Outbound messages are task results sent via the WS client.
|
|
107
|
+
// The task-executor sends results directly through ws.send();
|
|
108
|
+
// this adapter exists for completeness if openclaw routing tries
|
|
109
|
+
// to deliver a reply through the channel outbound path.
|
|
110
|
+
if (activeClient) {
|
|
111
|
+
activeClient.send({
|
|
112
|
+
type: "agent.result",
|
|
113
|
+
taskId: to,
|
|
114
|
+
description: text,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return { channel: "clawroom", messageId: to, to };
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
gateway: {
|
|
122
|
+
startAccount: async (ctx) => {
|
|
123
|
+
const account = ctx.account as ResolvedClawroomAccount;
|
|
124
|
+
|
|
125
|
+
if (!account.configured) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
"ClawRoom is not configured: set channels.clawroom.token in your OpenClaw config.",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const runtime = getClawroomRuntime();
|
|
132
|
+
const skills = collectSkills({
|
|
133
|
+
runtime,
|
|
134
|
+
configuredSkills: account.skills,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const deviceId = resolveDeviceId(ctx);
|
|
138
|
+
const log = ctx.log ?? undefined;
|
|
139
|
+
|
|
140
|
+
const client = new ClawroomWsClient({
|
|
141
|
+
endpoint: account.endpoint,
|
|
142
|
+
token: account.token,
|
|
143
|
+
deviceId,
|
|
144
|
+
skills,
|
|
145
|
+
log,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
setupTaskExecutor({ ws: client, runtime, log });
|
|
149
|
+
client.connect();
|
|
150
|
+
activeClient = client;
|
|
151
|
+
|
|
152
|
+
ctx.setStatus({
|
|
153
|
+
accountId: account.accountId,
|
|
154
|
+
running: true,
|
|
155
|
+
lastStartAt: Date.now(),
|
|
156
|
+
lastStopAt: null,
|
|
157
|
+
lastError: null,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Keep alive until gateway shuts down — only then disconnect
|
|
161
|
+
await new Promise<void>((resolve) => {
|
|
162
|
+
ctx.abortSignal.addEventListener("abort", () => {
|
|
163
|
+
client.disconnect();
|
|
164
|
+
activeClient = null;
|
|
165
|
+
ctx.setStatus({
|
|
166
|
+
accountId: account.accountId,
|
|
167
|
+
running: false,
|
|
168
|
+
lastStartAt: null,
|
|
169
|
+
lastStopAt: Date.now(),
|
|
170
|
+
lastError: null,
|
|
171
|
+
});
|
|
172
|
+
resolve();
|
|
173
|
+
}, { once: true });
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
stopAccount: async () => {
|
|
178
|
+
if (activeClient) {
|
|
179
|
+
activeClient.disconnect();
|
|
180
|
+
activeClient = null;
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
status: {
|
|
186
|
+
defaultRuntime: {
|
|
187
|
+
accountId: DEFAULT_ACCOUNT_ID,
|
|
188
|
+
running: false,
|
|
189
|
+
lastStartAt: null,
|
|
190
|
+
lastStopAt: null,
|
|
191
|
+
lastError: null,
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
buildAccountSnapshot: ({ account, runtime }) => ({
|
|
195
|
+
accountId: account.accountId,
|
|
196
|
+
name: account.name,
|
|
197
|
+
enabled: account.enabled,
|
|
198
|
+
configured: account.configured,
|
|
199
|
+
running: runtime?.running ?? false,
|
|
200
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
201
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
202
|
+
lastError: runtime?.lastError ?? null,
|
|
203
|
+
endpoint: account.endpoint,
|
|
204
|
+
skillCount: account.skills.length,
|
|
205
|
+
}),
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Build a stable device identifier. We use the machine hostname from the
|
|
213
|
+
* runtime environment when available, falling back to a random id.
|
|
214
|
+
*/
|
|
215
|
+
function resolveDeviceId(ctx: {
|
|
216
|
+
runtime: { hostname?: string; machineId?: string };
|
|
217
|
+
}): string {
|
|
218
|
+
// The RuntimeEnv may expose hostname or machineId depending on version
|
|
219
|
+
const r = ctx.runtime as Record<string, unknown>;
|
|
220
|
+
if (typeof r.machineId === "string" && r.machineId) {
|
|
221
|
+
return r.machineId;
|
|
222
|
+
}
|
|
223
|
+
if (typeof r.hostname === "string" && r.hostname) {
|
|
224
|
+
return r.hostname;
|
|
225
|
+
}
|
|
226
|
+
// Fallback: random id for this session
|
|
227
|
+
return `agent-${Math.random().toString(36).slice(2, 10)}`;
|
|
228
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { clawroomPlugin } from "./channel.js";
|
|
3
|
+
import { setClawroomRuntime } from "./runtime.js";
|
|
4
|
+
|
|
5
|
+
const plugin = {
|
|
6
|
+
id: "clawroom",
|
|
7
|
+
name: "ClawRoom",
|
|
8
|
+
description: "ClawRoom task marketplace channel plugin",
|
|
9
|
+
configSchema: {},
|
|
10
|
+
register(api: OpenClawPluginApi) {
|
|
11
|
+
setClawroomRuntime(api.runtime);
|
|
12
|
+
api.registerChannel({ plugin: clawroomPlugin });
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default plugin;
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
const { setRuntime: setClawroomRuntime, getRuntime: getClawroomRuntime } =
|
|
5
|
+
createPluginRuntimeStore<PluginRuntime>("ClawRoom runtime not initialized");
|
|
6
|
+
export { getClawroomRuntime, setClawroomRuntime };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collect skills from the OpenClaw runtime (if available) and merge with
|
|
5
|
+
* manually configured skills from the channel config. Returns a deduplicated,
|
|
6
|
+
* sorted array.
|
|
7
|
+
*/
|
|
8
|
+
export function collectSkills(opts: {
|
|
9
|
+
runtime?: PluginRuntime | null;
|
|
10
|
+
configuredSkills?: string[];
|
|
11
|
+
}): string[] {
|
|
12
|
+
const skills = new Set<string>();
|
|
13
|
+
|
|
14
|
+
// Add manually configured skills from channels.clawroom.skills
|
|
15
|
+
if (opts.configuredSkills) {
|
|
16
|
+
for (const skill of opts.configuredSkills) {
|
|
17
|
+
const trimmed = skill.trim();
|
|
18
|
+
if (trimmed) {
|
|
19
|
+
skills.add(trimmed);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// TODO: If the PluginRuntime exposes a way to enumerate registered agent
|
|
25
|
+
// tools or provider capabilities at runtime, pull those in here. The current
|
|
26
|
+
// plugin SDK does not surface a tool-listing API, so this relies on manual
|
|
27
|
+
// configuration for now.
|
|
28
|
+
|
|
29
|
+
return Array.from(skills).sort();
|
|
30
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import type { ServerClaimAck, ServerTask, AgentResultFile } from "@clawroom/sdk";
|
|
2
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
3
|
+
import type { ClawroomWsClient } from "./ws-client.js";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
/** Default timeout for waiting on a subagent run (5 minutes). */
|
|
8
|
+
const SUBAGENT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
/** Max file size to upload (10 MB). */
|
|
11
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wire up the task execution pipeline:
|
|
15
|
+
* 1. On server.task / server.task_list -> store as available (no auto-claim)
|
|
16
|
+
* 2. On claim_ack(ok) -> invoke subagent -> send result/fail
|
|
17
|
+
*
|
|
18
|
+
* Claiming is triggered externally (by the owner via Dashboard).
|
|
19
|
+
*/
|
|
20
|
+
export function setupTaskExecutor(opts: {
|
|
21
|
+
ws: ClawroomWsClient;
|
|
22
|
+
runtime: PluginRuntime;
|
|
23
|
+
log?: {
|
|
24
|
+
info?: (...args: unknown[]) => void;
|
|
25
|
+
warn?: (...args: unknown[]) => void;
|
|
26
|
+
error?: (...args: unknown[]) => void;
|
|
27
|
+
};
|
|
28
|
+
}): void {
|
|
29
|
+
const { ws, runtime, log } = opts;
|
|
30
|
+
|
|
31
|
+
// Track received tasks (from broadcast or server push after claim)
|
|
32
|
+
const knownTasks = new Map<string, ServerTask>();
|
|
33
|
+
|
|
34
|
+
// New task received — store it for potential execution
|
|
35
|
+
ws.onTask((task: ServerTask) => {
|
|
36
|
+
log?.info?.(`[clawroom:executor] received task ${task.taskId}: ${task.title}`);
|
|
37
|
+
knownTasks.set(task.taskId, task);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Task list on connect — store all
|
|
41
|
+
ws.onTaskList((tasks: ServerTask[]) => {
|
|
42
|
+
log?.info?.(`[clawroom:executor] ${tasks.length} open task(s) available`);
|
|
43
|
+
for (const t of tasks) {
|
|
44
|
+
log?.info?.(`[clawroom:executor] - ${t.taskId}: ${t.title}`);
|
|
45
|
+
knownTasks.set(t.taskId, t);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Claim ack — either from plugin-initiated claim or Dashboard-initiated claim
|
|
50
|
+
ws.onClaimAck((ack: ServerClaimAck) => {
|
|
51
|
+
if (!ack.ok) {
|
|
52
|
+
log?.warn?.(
|
|
53
|
+
`[clawroom:executor] claim rejected for ${ack.taskId}: ${ack.reason ?? "unknown"}`,
|
|
54
|
+
);
|
|
55
|
+
knownTasks.delete(ack.taskId);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const task = knownTasks.get(ack.taskId);
|
|
60
|
+
knownTasks.delete(ack.taskId);
|
|
61
|
+
|
|
62
|
+
if (!task) {
|
|
63
|
+
log?.warn?.(
|
|
64
|
+
`[clawroom:executor] claim_ack for unknown task ${ack.taskId}`,
|
|
65
|
+
);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
log?.info?.(`[clawroom:executor] executing task ${task.taskId}`);
|
|
70
|
+
void executeTask({ ws, runtime, task, log });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Execute a claimed task by running a subagent session and reporting the
|
|
76
|
+
* result back to the ClawRoom server.
|
|
77
|
+
*/
|
|
78
|
+
async function executeTask(opts: {
|
|
79
|
+
ws: ClawroomWsClient;
|
|
80
|
+
runtime: PluginRuntime;
|
|
81
|
+
task: ServerTask;
|
|
82
|
+
log?: {
|
|
83
|
+
info?: (...args: unknown[]) => void;
|
|
84
|
+
error?: (...args: unknown[]) => void;
|
|
85
|
+
};
|
|
86
|
+
}): Promise<void> {
|
|
87
|
+
const { ws, runtime, task, log } = opts;
|
|
88
|
+
const sessionKey = `clawroom:task:${task.taskId}`;
|
|
89
|
+
|
|
90
|
+
const agentMessage = buildAgentMessage(task);
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
log?.info?.(`[clawroom:executor] running subagent for task ${task.taskId}`);
|
|
94
|
+
|
|
95
|
+
const { runId } = await runtime.subagent.run({
|
|
96
|
+
sessionKey,
|
|
97
|
+
idempotencyKey: `clawroom:${task.taskId}`,
|
|
98
|
+
message: agentMessage,
|
|
99
|
+
extraSystemPrompt:
|
|
100
|
+
"You are executing a task from the Claw Room marketplace. " +
|
|
101
|
+
"Complete the task and provide a SHORT summary (2-3 sentences) of what you did. " +
|
|
102
|
+
"Do NOT include any local file paths, machine info, or internal details in your summary. " +
|
|
103
|
+
"If you create output files, list their absolute paths at the very end, " +
|
|
104
|
+
"one per line, prefixed with 'OUTPUT_FILE: '. These markers will be processed " +
|
|
105
|
+
"automatically and stripped before showing to the user. " +
|
|
106
|
+
"Example: OUTPUT_FILE: /path/to/report.pdf",
|
|
107
|
+
lane: "clawroom",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const waitResult = await runtime.subagent.waitForRun({
|
|
111
|
+
runId,
|
|
112
|
+
timeoutMs: SUBAGENT_TIMEOUT_MS,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (waitResult.status === "error") {
|
|
116
|
+
log?.error?.(
|
|
117
|
+
`[clawroom:executor] subagent error for task ${task.taskId}: ${waitResult.error}`,
|
|
118
|
+
);
|
|
119
|
+
ws.send({
|
|
120
|
+
type: "agent.fail",
|
|
121
|
+
taskId: task.taskId,
|
|
122
|
+
reason: waitResult.error ?? "Agent execution failed",
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (waitResult.status === "timeout") {
|
|
128
|
+
log?.error?.(`[clawroom:executor] subagent timeout for task ${task.taskId}`);
|
|
129
|
+
ws.send({
|
|
130
|
+
type: "agent.fail",
|
|
131
|
+
taskId: task.taskId,
|
|
132
|
+
reason: "Agent execution timed out",
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { messages } = await runtime.subagent.getSessionMessages({
|
|
138
|
+
sessionKey,
|
|
139
|
+
limit: 100,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const rawOutput = extractAgentOutput(messages);
|
|
143
|
+
const filesFromTools = extractWrittenFiles(messages);
|
|
144
|
+
const filesFromOutput = extractOutputFileMarkers(rawOutput);
|
|
145
|
+
const allFiles = [...new Set([...filesFromTools, ...filesFromOutput])];
|
|
146
|
+
const file = readFirstFile(allFiles, log);
|
|
147
|
+
|
|
148
|
+
// Strip OUTPUT_FILE markers and internal paths from the user-facing output
|
|
149
|
+
const description = rawOutput
|
|
150
|
+
.split("\n")
|
|
151
|
+
.filter((line) => !line.match(/OUTPUT_FILE:\s*/))
|
|
152
|
+
.join("\n")
|
|
153
|
+
.replace(/`?\/\S+\/[^\s`]+`?/g, "") // strip absolute paths
|
|
154
|
+
.replace(/\n{3,}/g, "\n\n") // collapse extra newlines
|
|
155
|
+
.trim();
|
|
156
|
+
|
|
157
|
+
log?.info?.(`[clawroom:executor] task ${task.taskId} completed${file ? " (with file)" : ""}`);
|
|
158
|
+
ws.send({
|
|
159
|
+
type: "agent.result",
|
|
160
|
+
taskId: task.taskId,
|
|
161
|
+
description,
|
|
162
|
+
file,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await runtime.subagent.deleteSession({
|
|
166
|
+
sessionKey,
|
|
167
|
+
deleteTranscript: true,
|
|
168
|
+
});
|
|
169
|
+
} catch (err) {
|
|
170
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
171
|
+
log?.error?.(`[clawroom:executor] unexpected error for task ${task.taskId}: ${reason}`);
|
|
172
|
+
ws.send({
|
|
173
|
+
type: "agent.release",
|
|
174
|
+
taskId: task.taskId,
|
|
175
|
+
reason,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildAgentMessage(task: ServerTask): string {
|
|
181
|
+
const parts: string[] = [];
|
|
182
|
+
parts.push(`# Task: ${task.title}`);
|
|
183
|
+
if (task.description) {
|
|
184
|
+
parts.push("", task.description);
|
|
185
|
+
}
|
|
186
|
+
if (task.input) {
|
|
187
|
+
parts.push("", "## Input", "", task.input);
|
|
188
|
+
}
|
|
189
|
+
if (task.skillTags.length > 0) {
|
|
190
|
+
parts.push("", `Skills: ${task.skillTags.join(", ")}`);
|
|
191
|
+
}
|
|
192
|
+
return parts.join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function extractAgentOutput(messages: unknown[]): string {
|
|
196
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
197
|
+
const msg = messages[i] as Record<string, unknown> | undefined;
|
|
198
|
+
if (!msg) continue;
|
|
199
|
+
|
|
200
|
+
if (msg.role === "assistant") {
|
|
201
|
+
if (typeof msg.content === "string") {
|
|
202
|
+
return msg.content;
|
|
203
|
+
}
|
|
204
|
+
if (Array.isArray(msg.content)) {
|
|
205
|
+
const textParts: string[] = [];
|
|
206
|
+
for (const block of msg.content) {
|
|
207
|
+
const b = block as Record<string, unknown>;
|
|
208
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
209
|
+
textParts.push(b.text);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (textParts.length > 0) {
|
|
213
|
+
return textParts.join("\n");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return "(No output produced)";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const MIME_MAP: Record<string, string> = {
|
|
223
|
+
".md": "text/markdown", ".txt": "text/plain", ".pdf": "application/pdf",
|
|
224
|
+
".json": "application/json", ".csv": "text/csv", ".html": "text/html",
|
|
225
|
+
".js": "application/javascript", ".ts": "application/typescript",
|
|
226
|
+
".py": "text/x-python", ".png": "image/png", ".jpg": "image/jpeg",
|
|
227
|
+
".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml",
|
|
228
|
+
".mp4": "video/mp4", ".zip": "application/zip",
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Extract file paths from agent session messages by looking at Write/write_file
|
|
233
|
+
* tool calls in the assistant's content blocks.
|
|
234
|
+
*/
|
|
235
|
+
function extractWrittenFiles(messages: unknown[]): string[] {
|
|
236
|
+
const files = new Set<string>();
|
|
237
|
+
for (const msg of messages) {
|
|
238
|
+
const m = msg as Record<string, unknown> | undefined;
|
|
239
|
+
if (!m || !Array.isArray(m.content)) continue;
|
|
240
|
+
for (const block of m.content) {
|
|
241
|
+
const b = block as Record<string, unknown>;
|
|
242
|
+
// Match tool_use blocks (Claude-style) and tool_call blocks (OpenAI-style)
|
|
243
|
+
if (b.type !== "tool_use" && b.type !== "tool_call") continue;
|
|
244
|
+
const name = String(b.name ?? b.function ?? "").toLowerCase();
|
|
245
|
+
if (!name.includes("write") && !name.includes("create_file") && !name.includes("save")) continue;
|
|
246
|
+
const raw = b.input ?? b.arguments;
|
|
247
|
+
const input = typeof raw === "string" ? tryParse(raw) : raw;
|
|
248
|
+
if (input && typeof input === "object") {
|
|
249
|
+
const fp = (input as Record<string, unknown>).file_path ?? (input as Record<string, unknown>).path;
|
|
250
|
+
if (typeof fp === "string" && fp) files.add(fp);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return Array.from(files);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Extract file paths from OUTPUT_FILE: markers in the agent's text output.
|
|
259
|
+
*/
|
|
260
|
+
function extractOutputFileMarkers(output: string): string[] {
|
|
261
|
+
const files: string[] = [];
|
|
262
|
+
for (const line of output.split("\n")) {
|
|
263
|
+
const match = line.match(/OUTPUT_FILE:\s*(.+)/);
|
|
264
|
+
if (match) {
|
|
265
|
+
const fp = match[1].trim().replace(/`/g, "");
|
|
266
|
+
if (fp) files.push(fp);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return files;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function tryParse(s: string): unknown {
|
|
273
|
+
try { return JSON.parse(s); } catch { return null; }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Read the first valid file from disk and encode it as base64.
|
|
278
|
+
* Skips missing files and files > MAX_FILE_SIZE.
|
|
279
|
+
*/
|
|
280
|
+
function readFirstFile(
|
|
281
|
+
paths: string[],
|
|
282
|
+
log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void },
|
|
283
|
+
): AgentResultFile | undefined {
|
|
284
|
+
for (const [index, fp] of paths.entries()) {
|
|
285
|
+
try {
|
|
286
|
+
if (!fs.existsSync(fp)) { log?.warn?.(`[clawroom:executor] file not found: ${fp}`); continue; }
|
|
287
|
+
const stat = fs.statSync(fp);
|
|
288
|
+
if (stat.size > MAX_FILE_SIZE) { log?.warn?.(`[clawroom:executor] file too large: ${fp} (${stat.size})`); continue; }
|
|
289
|
+
const data = fs.readFileSync(fp);
|
|
290
|
+
const ext = path.extname(fp).toLowerCase();
|
|
291
|
+
if (index > 0) {
|
|
292
|
+
log?.warn?.(`[clawroom:executor] multiple output files detected; only reporting the first usable file`);
|
|
293
|
+
}
|
|
294
|
+
log?.info?.(`[clawroom:executor] attached: ${path.basename(fp)} (${data.length} bytes)`);
|
|
295
|
+
return {
|
|
296
|
+
filename: path.basename(fp),
|
|
297
|
+
mimeType: MIME_MAP[ext] ?? "application/octet-stream",
|
|
298
|
+
data: data.toString("base64"),
|
|
299
|
+
};
|
|
300
|
+
} catch (err) {
|
|
301
|
+
log?.warn?.(`[clawroom:executor] failed to read ${fp}: ${err}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
package/src/ws-client.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentMessage,
|
|
3
|
+
ServerClaimAck,
|
|
4
|
+
ServerMessage,
|
|
5
|
+
ServerTask,
|
|
6
|
+
} from "@clawroom/sdk";
|
|
7
|
+
|
|
8
|
+
const WATCHDOG_INTERVAL_MS = 10_000;
|
|
9
|
+
const DEAD_THRESHOLD_MS = 25_000; // 2.5 missed heartbeats = dead
|
|
10
|
+
const RECONNECT_BASE_MS = 1_000;
|
|
11
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
type TaskCallback = (task: ServerTask) => void;
|
|
14
|
+
type TaskListCallback = (tasks: ServerTask[]) => void;
|
|
15
|
+
type ClaimAckCallback = (ack: ServerClaimAck) => void;
|
|
16
|
+
type ClaimRequestCallback = (task: ServerTask) => void;
|
|
17
|
+
type ErrorCallback = (error: ServerMessage & { type: "server.error" }) => void;
|
|
18
|
+
type WelcomeCallback = (agentId: string) => void;
|
|
19
|
+
|
|
20
|
+
export type ClawroomWsClientOptions = {
|
|
21
|
+
endpoint: string;
|
|
22
|
+
token: string;
|
|
23
|
+
deviceId: string;
|
|
24
|
+
skills: string[];
|
|
25
|
+
log?: {
|
|
26
|
+
info?: (...args: unknown[]) => void;
|
|
27
|
+
warn?: (...args: unknown[]) => void;
|
|
28
|
+
error?: (...args: unknown[]) => void;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* WebSocket client with aggressive reconnection.
|
|
34
|
+
*
|
|
35
|
+
* Watchdog strategy:
|
|
36
|
+
* - Every 10s, send heartbeat and record the time
|
|
37
|
+
* - Every 10s, check if last successful pong was > 25s ago
|
|
38
|
+
* - If yes: server is dead. Destroy socket + HTTP verify + reconnect
|
|
39
|
+
* - HTTP verify is fire-and-forget; reconnect is triggered regardless
|
|
40
|
+
* - Watchdog NEVER stops unless disconnect() is called
|
|
41
|
+
*/
|
|
42
|
+
export class ClawroomWsClient {
|
|
43
|
+
private ws: WebSocket | null = null;
|
|
44
|
+
private watchdog: ReturnType<typeof setInterval> | null = null;
|
|
45
|
+
private lastActivity = 0; // timestamp of last successful server response
|
|
46
|
+
private reconnectAttempt = 0;
|
|
47
|
+
private reconnecting = false;
|
|
48
|
+
private stopped = false;
|
|
49
|
+
|
|
50
|
+
private taskCallbacks: TaskCallback[] = [];
|
|
51
|
+
private taskListCallbacks: TaskListCallback[] = [];
|
|
52
|
+
private claimAckCallbacks: ClaimAckCallback[] = [];
|
|
53
|
+
private claimRequestCallbacks: ClaimRequestCallback[] = [];
|
|
54
|
+
private errorCallbacks: ErrorCallback[] = [];
|
|
55
|
+
private welcomeCallbacks: WelcomeCallback[] = [];
|
|
56
|
+
|
|
57
|
+
constructor(private readonly options: ClawroomWsClientOptions) {}
|
|
58
|
+
|
|
59
|
+
connect(): void {
|
|
60
|
+
this.stopped = false;
|
|
61
|
+
this.reconnecting = false;
|
|
62
|
+
this.reconnectAttempt = 0;
|
|
63
|
+
this.lastActivity = Date.now();
|
|
64
|
+
this.doConnect();
|
|
65
|
+
this.startWatchdog();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
disconnect(): void {
|
|
69
|
+
this.stopped = true;
|
|
70
|
+
this.stopWatchdog();
|
|
71
|
+
this.destroySocket();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
send(message: AgentMessage): void {
|
|
75
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
76
|
+
try { this.ws.send(JSON.stringify(message)); } catch {}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
|
|
80
|
+
onTaskList(cb: TaskListCallback): void { this.taskListCallbacks.push(cb); }
|
|
81
|
+
onClaimAck(cb: ClaimAckCallback): void { this.claimAckCallbacks.push(cb); }
|
|
82
|
+
onClaimRequest(cb: ClaimRequestCallback): void { this.claimRequestCallbacks.push(cb); }
|
|
83
|
+
onError(cb: ErrorCallback): void { this.errorCallbacks.push(cb); }
|
|
84
|
+
onWelcome(cb: WelcomeCallback): void { this.welcomeCallbacks.push(cb); }
|
|
85
|
+
|
|
86
|
+
// ── Watchdog ──────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
private startWatchdog(): void {
|
|
89
|
+
this.stopWatchdog();
|
|
90
|
+
this.watchdog = setInterval(() => this.tick(), WATCHDOG_INTERVAL_MS);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private stopWatchdog(): void {
|
|
94
|
+
if (this.watchdog) { clearInterval(this.watchdog); this.watchdog = null; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Synchronous tick — no async, no promises, no way to silently fail. */
|
|
98
|
+
private tick(): void {
|
|
99
|
+
if (this.stopped) return;
|
|
100
|
+
|
|
101
|
+
// If already reconnecting, skip
|
|
102
|
+
if (this.reconnecting) return;
|
|
103
|
+
|
|
104
|
+
const ws = this.ws;
|
|
105
|
+
|
|
106
|
+
// No socket → reconnect
|
|
107
|
+
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
|
|
108
|
+
this.triggerReconnect("no socket");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Still connecting → wait
|
|
113
|
+
if (ws.readyState === WebSocket.CONNECTING) return;
|
|
114
|
+
|
|
115
|
+
// Socket is OPEN — check if server is responsive
|
|
116
|
+
const elapsed = Date.now() - this.lastActivity;
|
|
117
|
+
if (elapsed > DEAD_THRESHOLD_MS) {
|
|
118
|
+
// No response from server in 25s — it's dead
|
|
119
|
+
this.options.log?.warn?.(`[clawroom] watchdog: no response for ${Math.round(elapsed / 1000)}s, forcing reconnect`);
|
|
120
|
+
this.triggerReconnect("dead connection");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Send heartbeat
|
|
125
|
+
this.send({ type: "agent.heartbeat" });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private triggerReconnect(reason: string): void {
|
|
129
|
+
if (this.reconnecting || this.stopped) return;
|
|
130
|
+
this.reconnecting = true;
|
|
131
|
+
this.destroySocket();
|
|
132
|
+
|
|
133
|
+
const delayMs = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS);
|
|
134
|
+
this.reconnectAttempt++;
|
|
135
|
+
this.options.log?.info?.(`[clawroom] reconnecting in ${delayMs}ms (${reason}, attempt ${this.reconnectAttempt})`);
|
|
136
|
+
|
|
137
|
+
setTimeout(() => {
|
|
138
|
+
this.reconnecting = false;
|
|
139
|
+
if (!this.stopped) this.doConnect();
|
|
140
|
+
}, delayMs);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Connection ────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
private doConnect(): void {
|
|
146
|
+
this.destroySocket();
|
|
147
|
+
const { endpoint, token } = this.options;
|
|
148
|
+
const url = `${endpoint}?token=${encodeURIComponent(token)}`;
|
|
149
|
+
|
|
150
|
+
this.options.log?.info?.(`[clawroom] connecting to ${endpoint}`);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
this.ws = new WebSocket(url);
|
|
154
|
+
} catch {
|
|
155
|
+
return; // watchdog will retry
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
this.ws.addEventListener("open", () => {
|
|
159
|
+
this.options.log?.info?.("[clawroom] connected");
|
|
160
|
+
this.reconnectAttempt = 0;
|
|
161
|
+
this.lastActivity = Date.now();
|
|
162
|
+
this.send({
|
|
163
|
+
type: "agent.hello",
|
|
164
|
+
deviceId: this.options.deviceId,
|
|
165
|
+
skills: this.options.skills,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
this.ws.addEventListener("message", (event) => {
|
|
170
|
+
this.lastActivity = Date.now(); // ANY message = server is alive
|
|
171
|
+
this.handleMessage(event.data as string);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
this.ws.addEventListener("close", () => {
|
|
175
|
+
this.options.log?.info?.("[clawroom] disconnected");
|
|
176
|
+
this.ws = null;
|
|
177
|
+
// Don't reconnect here — watchdog handles it
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
this.ws.addEventListener("error", () => {
|
|
181
|
+
// watchdog handles it
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private destroySocket(): void {
|
|
186
|
+
if (this.ws) {
|
|
187
|
+
try { this.ws.close(); } catch {}
|
|
188
|
+
this.ws = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Message handling ──────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
private handleMessage(raw: string): void {
|
|
195
|
+
let msg: ServerMessage;
|
|
196
|
+
try { msg = JSON.parse(raw) as ServerMessage; } catch { return; }
|
|
197
|
+
|
|
198
|
+
switch (msg.type) {
|
|
199
|
+
case "server.welcome":
|
|
200
|
+
this.options.log?.info?.(`[clawroom] welcome, agentId=${msg.agentId}`);
|
|
201
|
+
for (const cb of this.welcomeCallbacks) cb(msg.agentId);
|
|
202
|
+
break;
|
|
203
|
+
case "server.pong":
|
|
204
|
+
// lastActivity already updated in message handler
|
|
205
|
+
break;
|
|
206
|
+
case "server.task":
|
|
207
|
+
this.options.log?.info?.(`[clawroom] received task ${msg.taskId}: ${msg.title}`);
|
|
208
|
+
for (const cb of this.taskCallbacks) cb(msg);
|
|
209
|
+
break;
|
|
210
|
+
case "server.task_list":
|
|
211
|
+
this.options.log?.info?.(`[clawroom] received ${msg.tasks.length} open task(s)`);
|
|
212
|
+
for (const cb of this.taskListCallbacks) cb(msg.tasks);
|
|
213
|
+
break;
|
|
214
|
+
case "server.claim_ack":
|
|
215
|
+
this.options.log?.info?.(`[clawroom] claim_ack taskId=${msg.taskId} ok=${msg.ok}${msg.reason ? ` reason=${msg.reason}` : ""}`);
|
|
216
|
+
for (const cb of this.claimAckCallbacks) cb(msg);
|
|
217
|
+
break;
|
|
218
|
+
case "server.error":
|
|
219
|
+
this.options.log?.error?.(`[clawroom] server error: ${msg.message}`);
|
|
220
|
+
for (const cb of this.errorCallbacks) cb(msg);
|
|
221
|
+
break;
|
|
222
|
+
default:
|
|
223
|
+
this.options.log?.warn?.("[clawroom] unknown message type", msg);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|