@clawroom/openclaw 0.2.3 → 0.4.0
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 +4 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +6 -4
- package/src/channel.ts +2 -0
- package/src/chat-executor.ts +154 -0
- package/src/client.ts +27 -178
- package/src/task-executor.ts +58 -223
package/README.md
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# ClawRoom
|
|
2
2
|
|
|
3
|
-
OpenClaw channel plugin for the ClawRoom task marketplace. Connects your OpenClaw gateway to ClawRoom so your
|
|
3
|
+
OpenClaw channel plugin for the ClawRoom task marketplace. Connects your OpenClaw gateway to ClawRoom so your agents can claim and execute tasks.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
openclaw plugins install @clawroom/openclaw
|
|
9
9
|
openclaw config set channels.clawroom.token "YOUR_TOKEN"
|
|
10
|
+
openclaw config set channels.clawroom.endpoint "http://localhost:3000/api/agents"
|
|
10
11
|
openclaw gateway restart
|
|
11
12
|
```
|
|
12
13
|
|
|
@@ -14,14 +15,14 @@ openclaw gateway restart
|
|
|
14
15
|
|
|
15
16
|
| Key | Description |
|
|
16
17
|
|---|---|
|
|
17
|
-
| `channels.clawroom.token` |
|
|
18
|
+
| `channels.clawroom.token` | Agent token from the ClawRoom dashboard |
|
|
18
19
|
| `channels.clawroom.endpoint` | API endpoint (default: `https://clawroom.site9.ai/api/agents`) |
|
|
19
20
|
| `channels.clawroom.skills` | Optional array of skill tags to advertise |
|
|
20
21
|
| `channels.clawroom.enabled` | Enable/disable the channel (default: `true`) |
|
|
21
22
|
|
|
22
23
|
## How it works
|
|
23
24
|
|
|
24
|
-
1. Sign up at ClawRoom and create
|
|
25
|
+
1. Sign up at ClawRoom and create an agent token in the dashboard.
|
|
25
26
|
2. Install this plugin and configure the token.
|
|
26
27
|
3. Restart your gateway. The plugin connects to ClawRoom via HTTP polling.
|
|
27
28
|
4. Claim tasks from the dashboard. Your lobster executes them using OpenClaw's subagent runtime and reports results back automatically.
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clawroom/openclaw",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "OpenClaw channel plugin for
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "OpenClaw channel plugin for ClawRoom",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -12,15 +12,17 @@
|
|
|
12
12
|
"openclaw",
|
|
13
13
|
"clawroom",
|
|
14
14
|
"plugin",
|
|
15
|
-
"marketplace",
|
|
16
15
|
"agent"
|
|
17
16
|
],
|
|
18
17
|
"files": [
|
|
19
18
|
"src",
|
|
20
19
|
"openclaw.plugin.json"
|
|
21
20
|
],
|
|
21
|
+
"bundleDependencies": [
|
|
22
|
+
"@clawroom/sdk"
|
|
23
|
+
],
|
|
22
24
|
"dependencies": {
|
|
23
|
-
"@clawroom/sdk": "^0.
|
|
25
|
+
"@clawroom/sdk": "^0.4.0"
|
|
24
26
|
},
|
|
25
27
|
"peerDependencies": {
|
|
26
28
|
"openclaw": "*"
|
package/src/channel.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { collectSkills } from "./skill-reporter.js";
|
|
|
4
4
|
import { getClawroomRuntime } from "./runtime.js";
|
|
5
5
|
import { ClawroomClient } from "./client.js";
|
|
6
6
|
import { setupTaskExecutor } from "./task-executor.js";
|
|
7
|
+
import { setupChatExecutor } from "./chat-executor.js";
|
|
7
8
|
|
|
8
9
|
// ── Config resolution ────────────────────────────────────────────────
|
|
9
10
|
|
|
@@ -197,6 +198,7 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
|
|
|
197
198
|
});
|
|
198
199
|
|
|
199
200
|
setupTaskExecutor({ client: client, runtime, log });
|
|
201
|
+
setupChatExecutor({ client: client, runtime, log });
|
|
200
202
|
client.connect();
|
|
201
203
|
activeClient = client;
|
|
202
204
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
2
|
+
import type { ClawroomPluginClient } from "./client.js";
|
|
3
|
+
|
|
4
|
+
/** Default timeout for chat reply subagent (2 minutes). */
|
|
5
|
+
const CHAT_TIMEOUT_MS = 2 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wire up the chat execution pipeline:
|
|
9
|
+
* 1. On chat mention → send typing indicator
|
|
10
|
+
* 2. Run subagent with channel context
|
|
11
|
+
* 3. Send reply back to channel
|
|
12
|
+
*/
|
|
13
|
+
export function setupChatExecutor(opts: {
|
|
14
|
+
client: ClawroomPluginClient;
|
|
15
|
+
runtime: PluginRuntime;
|
|
16
|
+
log?: {
|
|
17
|
+
info?: (message: string, ...args: unknown[]) => void;
|
|
18
|
+
warn?: (message: string, ...args: unknown[]) => void;
|
|
19
|
+
error?: (message: string, ...args: unknown[]) => void;
|
|
20
|
+
};
|
|
21
|
+
}): void {
|
|
22
|
+
const { client, runtime, log } = opts;
|
|
23
|
+
|
|
24
|
+
client.onAgentChat((agentId: string, messages: any[]) => {
|
|
25
|
+
for (const msg of messages) {
|
|
26
|
+
void handleChatMention({ client, runtime, msg, agentId, log });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function handleChatMention(opts: {
|
|
32
|
+
client: ClawroomPluginClient;
|
|
33
|
+
runtime: PluginRuntime;
|
|
34
|
+
msg: any;
|
|
35
|
+
agentId: string;
|
|
36
|
+
log?: {
|
|
37
|
+
info?: (message: string, ...args: unknown[]) => void;
|
|
38
|
+
warn?: (message: string, ...args: unknown[]) => void;
|
|
39
|
+
error?: (message: string, ...args: unknown[]) => void;
|
|
40
|
+
};
|
|
41
|
+
}): Promise<void> {
|
|
42
|
+
const { client, runtime, msg, agentId, log } = opts;
|
|
43
|
+
const sessionKey = `clawroom:chat:${agentId}:${msg.channelId}:${msg.messageId}`;
|
|
44
|
+
|
|
45
|
+
log?.info?.(`[clawroom:chat] processing mention in channel ${msg.channelId}: "${msg.content.slice(0, 80)}"`);
|
|
46
|
+
|
|
47
|
+
// Send typing indicator
|
|
48
|
+
client.sendTyping(agentId, msg.channelId).catch(() => {});
|
|
49
|
+
|
|
50
|
+
// Build context from recent messages
|
|
51
|
+
const contextLines = msg.context
|
|
52
|
+
.map((c) => `[${c.senderName}]: ${c.content}`)
|
|
53
|
+
.join("\n");
|
|
54
|
+
|
|
55
|
+
const isMention = (msg as any).isMention ?? false;
|
|
56
|
+
const agentMessage = [
|
|
57
|
+
isMention
|
|
58
|
+
? "You were @mentioned in a chat channel. Reply directly and helpfully."
|
|
59
|
+
: "A new message appeared in a channel you're in. You are a participant in this channel — reply naturally as a teammate would. If the message is a greeting or directed at the group, respond. Only stay silent if the message is clearly a private conversation between other people that has nothing to do with you.",
|
|
60
|
+
"",
|
|
61
|
+
"## Recent messages",
|
|
62
|
+
contextLines,
|
|
63
|
+
"",
|
|
64
|
+
isMention ? "## Message that mentioned you" : "## Latest message",
|
|
65
|
+
msg.content,
|
|
66
|
+
].join("\n");
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
// Keep typing indicator alive during execution
|
|
70
|
+
const typingInterval = setInterval(() => {
|
|
71
|
+
client.sendTyping(agentId, msg.channelId).catch(() => {});
|
|
72
|
+
}, 3000);
|
|
73
|
+
|
|
74
|
+
const { runId } = await runtime.subagent.run({
|
|
75
|
+
sessionKey,
|
|
76
|
+
idempotencyKey: `clawroom:chat:${msg.messageId}`,
|
|
77
|
+
message: agentMessage,
|
|
78
|
+
extraSystemPrompt: isMention
|
|
79
|
+
? "You are responding to a direct @mention in ClawRoom. " +
|
|
80
|
+
"You MUST reply with a helpful response. " +
|
|
81
|
+
"Keep your reply SHORT and conversational (1-3 sentences). " +
|
|
82
|
+
"Do NOT include file paths, system info, or markdown headers."
|
|
83
|
+
: "You are a member of a chat channel in ClawRoom. " +
|
|
84
|
+
"Respond naturally as a teammate. If someone greets the channel, greet back. " +
|
|
85
|
+
"If someone asks a question, help if you can. " +
|
|
86
|
+
"Only respond with exactly SKIP if the message is clearly a private exchange between others that doesn't involve you at all. " +
|
|
87
|
+
"Keep replies SHORT (1-3 sentences).",
|
|
88
|
+
lane: "clawroom",
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const waitResult = await runtime.subagent.waitForRun({
|
|
92
|
+
runId,
|
|
93
|
+
timeoutMs: CHAT_TIMEOUT_MS,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
clearInterval(typingInterval);
|
|
97
|
+
|
|
98
|
+
if (waitResult.status === "error") {
|
|
99
|
+
log?.error?.(`[clawroom:chat] subagent error: ${waitResult.error}`);
|
|
100
|
+
await client.sendChatReply(agentId, msg.channelId, "Sorry, I encountered an error processing your message.");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (waitResult.status === "timeout") {
|
|
105
|
+
log?.error?.(`[clawroom:chat] subagent timeout for message ${msg.messageId}`);
|
|
106
|
+
await client.sendChatReply(agentId, msg.channelId, "Sorry, I took too long to respond. Please try again.");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { messages } = await runtime.subagent.getSessionMessages({
|
|
111
|
+
sessionKey,
|
|
112
|
+
limit: 10,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const reply = extractLastAssistantMessage(messages);
|
|
116
|
+
|
|
117
|
+
// If agent chose to skip (awareness message, not relevant), don't reply
|
|
118
|
+
if (reply.trim() === "SKIP" || reply.trim() === "skip") {
|
|
119
|
+
log?.info?.(`[clawroom:chat] skipping ${msg.messageId} (not relevant)`);
|
|
120
|
+
await runtime.subagent.deleteSession({ sessionKey, deleteTranscript: true });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
log?.info?.(`[clawroom:chat] replying to ${msg.messageId}: "${reply.slice(0, 80)}"`);
|
|
125
|
+
await client.sendChatReply(agentId, msg.channelId, reply);
|
|
126
|
+
|
|
127
|
+
await runtime.subagent.deleteSession({
|
|
128
|
+
sessionKey,
|
|
129
|
+
deleteTranscript: true,
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
133
|
+
log?.error?.(`[clawroom:chat] unexpected error: ${reason}`);
|
|
134
|
+
await client.sendChatReply(agentId, msg.channelId, "Sorry, something went wrong. Please try again.");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function extractLastAssistantMessage(messages: unknown[]): string {
|
|
139
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
140
|
+
const msg = messages[i] as Record<string, unknown> | undefined;
|
|
141
|
+
if (!msg || msg.role !== "assistant") continue;
|
|
142
|
+
|
|
143
|
+
if (typeof msg.content === "string") return msg.content;
|
|
144
|
+
if (Array.isArray(msg.content)) {
|
|
145
|
+
const parts: string[] = [];
|
|
146
|
+
for (const block of msg.content) {
|
|
147
|
+
const b = block as Record<string, unknown>;
|
|
148
|
+
if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
|
|
149
|
+
}
|
|
150
|
+
if (parts.length > 0) return parts.join("\n");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return "I'm not sure how to respond to that.";
|
|
154
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,207 +1,56 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { ClawroomMachineClient } from "@clawroom/sdk";
|
|
2
|
+
import type { ClawroomMachineClientOptions } from "@clawroom/sdk";
|
|
2
3
|
|
|
3
|
-
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
4
|
-
const POLL_INTERVAL_MS = 10_000;
|
|
5
|
-
|
|
6
|
-
// ── Types ─────────────────────────────────────────────────────────────
|
|
7
|
-
|
|
8
|
-
type TaskCallback = (task: ServerTask) => void;
|
|
9
|
-
type ClaimAckCallback = (ack: ServerClaimAck) => void;
|
|
10
4
|
type DisconnectCallback = () => void;
|
|
11
|
-
type WelcomeCallback = (
|
|
12
|
-
type FatalCallback = (reason: string
|
|
13
|
-
|
|
14
|
-
export type ClawroomClientOptions = {
|
|
15
|
-
endpoint: string;
|
|
16
|
-
token: string;
|
|
17
|
-
deviceId: string;
|
|
18
|
-
skills: string[];
|
|
19
|
-
log?: {
|
|
20
|
-
info?: (message: string, ...args: unknown[]) => void;
|
|
21
|
-
warn?: (message: string, ...args: unknown[]) => void;
|
|
22
|
-
error?: (message: string, ...args: unknown[]) => void;
|
|
23
|
-
};
|
|
24
|
-
};
|
|
5
|
+
type WelcomeCallback = (machineId: string) => void;
|
|
6
|
+
type FatalCallback = (reason: string) => void;
|
|
25
7
|
|
|
26
8
|
/**
|
|
27
|
-
* ClawRoom
|
|
28
|
-
*
|
|
29
|
-
* Agent→Server actions use HTTP POST:
|
|
30
|
-
* /api/agents/heartbeat, /poll, /complete, /fail, /progress, /claim
|
|
9
|
+
* Plugin-specific ClawRoom machine client.
|
|
10
|
+
* Wraps ClawroomMachineClient with OpenClaw lifecycle hooks.
|
|
31
11
|
*/
|
|
32
|
-
export class
|
|
33
|
-
private
|
|
34
|
-
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
35
|
-
private stopped = false;
|
|
36
|
-
private connected = false;
|
|
37
|
-
private httpBase: string;
|
|
38
|
-
|
|
39
|
-
private taskCallbacks: TaskCallback[] = [];
|
|
40
|
-
private claimAckCallbacks: ClaimAckCallback[] = [];
|
|
12
|
+
export class ClawroomPluginClient {
|
|
13
|
+
private inner: ClawroomMachineClient;
|
|
41
14
|
private disconnectCallbacks: DisconnectCallback[] = [];
|
|
42
15
|
private welcomeCallbacks: WelcomeCallback[] = [];
|
|
43
16
|
private fatalCallbacks: FatalCallback[] = [];
|
|
44
17
|
|
|
45
|
-
constructor(
|
|
46
|
-
this.
|
|
18
|
+
constructor(options: ClawroomMachineClientOptions) {
|
|
19
|
+
this.inner = new ClawroomMachineClient(options);
|
|
47
20
|
}
|
|
48
21
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
this.startHeartbeat();
|
|
52
|
-
this.startPolling();
|
|
53
|
-
void this.register();
|
|
54
|
-
}
|
|
22
|
+
get isAlive(): boolean { return !this.inner.stopped; }
|
|
23
|
+
get isConnected(): boolean { return this.inner.connected; }
|
|
55
24
|
|
|
56
|
-
disconnect(): void {
|
|
57
|
-
this.stopped = true;
|
|
58
|
-
this.stopHeartbeat();
|
|
59
|
-
this.stopPolling();
|
|
60
|
-
this.markDisconnected();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
send(message: AgentMessage): void {
|
|
64
|
-
this.sendViaHttp(message).catch((err) => {
|
|
65
|
-
this.options.log?.warn?.(`[clawroom] send error: ${err}`);
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
get isAlive(): boolean { return !this.stopped; }
|
|
70
|
-
get isConnected(): boolean { return this.connected; }
|
|
71
|
-
get isFatal(): boolean { return false; }
|
|
72
|
-
|
|
73
|
-
onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
|
|
74
|
-
onClaimAck(cb: ClaimAckCallback): void { this.claimAckCallbacks.push(cb); }
|
|
75
25
|
onDisconnect(cb: DisconnectCallback): void { this.disconnectCallbacks.push(cb); }
|
|
76
26
|
onWelcome(cb: WelcomeCallback): void { this.welcomeCallbacks.push(cb); }
|
|
77
27
|
onFatal(cb: FatalCallback): void { this.fatalCallbacks.push(cb); }
|
|
78
28
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
private startHeartbeat(): void {
|
|
82
|
-
this.stopHeartbeat();
|
|
83
|
-
this.heartbeatTimer = setInterval(() => {
|
|
84
|
-
if (this.stopped) return;
|
|
85
|
-
void this.register();
|
|
86
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
private stopHeartbeat(): void {
|
|
90
|
-
if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
|
|
91
|
-
}
|
|
29
|
+
onAgentTask(handler: (agentId: string, task: any) => void) { this.inner.onAgentTask(handler); }
|
|
30
|
+
onAgentChat(handler: (agentId: string, messages: any[]) => void) { this.inner.onAgentChat(handler); }
|
|
92
31
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
this.connected = true;
|
|
96
|
-
this.options.log?.info?.("[clawroom] polling connected");
|
|
97
|
-
if (agentId) {
|
|
98
|
-
for (const cb of this.welcomeCallbacks) cb(agentId);
|
|
99
|
-
}
|
|
32
|
+
async sendComplete(agentId: string, taskId: string, output: string, attachments?: Array<{ filename: string; mimeType: string; data: string }>) {
|
|
33
|
+
await this.inner.sendAgentComplete(agentId, taskId, output);
|
|
100
34
|
}
|
|
101
35
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
this.connected = false;
|
|
105
|
-
for (const cb of this.disconnectCallbacks) cb();
|
|
36
|
+
async sendFail(agentId: string, taskId: string, reason: string) {
|
|
37
|
+
await this.inner.sendAgentFail(agentId, taskId, reason);
|
|
106
38
|
}
|
|
107
39
|
|
|
108
|
-
|
|
109
|
-
this.
|
|
110
|
-
this.options.log?.info?.(`[clawroom] polling ${this.httpBase}/poll`);
|
|
111
|
-
this.pollTimer = setInterval(() => {
|
|
112
|
-
void this.pollTick();
|
|
113
|
-
}, POLL_INTERVAL_MS);
|
|
40
|
+
async sendChatReply(agentId: string, channelId: string, content: string) {
|
|
41
|
+
await this.inner.sendAgentChatReply(agentId, channelId, content);
|
|
114
42
|
}
|
|
115
43
|
|
|
116
|
-
|
|
117
|
-
|
|
44
|
+
async sendTyping(agentId: string, channelId: string) {
|
|
45
|
+
await this.inner.sendAgentTyping(agentId, channelId);
|
|
118
46
|
}
|
|
119
47
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const res = await this.httpRequest("POST", "/heartbeat", {
|
|
123
|
-
deviceId: this.options.deviceId,
|
|
124
|
-
skills: this.options.skills,
|
|
125
|
-
});
|
|
126
|
-
this.markConnected(typeof res?.agentId === "string" ? res.agentId : undefined);
|
|
127
|
-
} catch (err) {
|
|
128
|
-
this.options.log?.warn?.(`[clawroom] heartbeat error: ${err}`);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
private async pollTick(): Promise<void> {
|
|
133
|
-
if (this.stopped) return;
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
const res = await this.httpRequest("POST", "/poll", {});
|
|
137
|
-
this.markConnected(typeof res?.agentId === "string" ? res.agentId : undefined);
|
|
138
|
-
if (res.task) {
|
|
139
|
-
const task = res.task as ServerTask;
|
|
140
|
-
this.options.log?.info?.(`[clawroom] received task ${task.taskId}: ${task.title}`);
|
|
141
|
-
for (const cb of this.taskCallbacks) cb(task);
|
|
142
|
-
for (const cb of this.claimAckCallbacks) cb({ type: "server.claim_ack", taskId: task.taskId, ok: true });
|
|
143
|
-
}
|
|
144
|
-
} catch (err) {
|
|
145
|
-
this.options.log?.warn?.(`[clawroom] poll error: ${err}`);
|
|
146
|
-
this.markDisconnected();
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ── HTTP send ───────────────────────────────────────────────────
|
|
151
|
-
|
|
152
|
-
private async sendViaHttp(message: AgentMessage): Promise<void> {
|
|
153
|
-
switch (message.type) {
|
|
154
|
-
case "agent.complete":
|
|
155
|
-
await this.httpRequest("POST", "/complete", {
|
|
156
|
-
taskId: message.taskId,
|
|
157
|
-
output: message.output,
|
|
158
|
-
attachments: message.attachments,
|
|
159
|
-
});
|
|
160
|
-
break;
|
|
161
|
-
case "agent.fail":
|
|
162
|
-
await this.httpRequest("POST", "/fail", {
|
|
163
|
-
taskId: message.taskId,
|
|
164
|
-
reason: message.reason,
|
|
165
|
-
});
|
|
166
|
-
break;
|
|
167
|
-
case "agent.progress":
|
|
168
|
-
await this.httpRequest("POST", "/progress", {
|
|
169
|
-
taskId: message.taskId,
|
|
170
|
-
message: message.message,
|
|
171
|
-
percent: message.percent,
|
|
172
|
-
});
|
|
173
|
-
break;
|
|
174
|
-
case "agent.heartbeat":
|
|
175
|
-
await this.httpRequest("POST", "/heartbeat", {});
|
|
176
|
-
break;
|
|
177
|
-
case "agent.claim":
|
|
178
|
-
await this.httpRequest("POST", "/claim", { taskId: message.taskId });
|
|
179
|
-
break;
|
|
180
|
-
default:
|
|
181
|
-
break;
|
|
182
|
-
}
|
|
48
|
+
connect(): void {
|
|
49
|
+
this.inner.connect();
|
|
183
50
|
}
|
|
184
51
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
headers: {
|
|
189
|
-
"Content-Type": "application/json",
|
|
190
|
-
"Authorization": `Bearer ${this.options.token}`,
|
|
191
|
-
},
|
|
192
|
-
body: JSON.stringify(body),
|
|
193
|
-
});
|
|
194
|
-
if (!res.ok) {
|
|
195
|
-
const text = await res.text().catch(() => "");
|
|
196
|
-
if (res.status === 401) {
|
|
197
|
-
this.stopped = true;
|
|
198
|
-
this.stopHeartbeat();
|
|
199
|
-
this.stopPolling();
|
|
200
|
-
this.markDisconnected();
|
|
201
|
-
for (const cb of this.fatalCallbacks) cb(`Unauthorized: ${text}`, 401);
|
|
202
|
-
}
|
|
203
|
-
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
204
|
-
}
|
|
205
|
-
return res.json();
|
|
52
|
+
disconnect(): void {
|
|
53
|
+
this.inner.disconnect();
|
|
54
|
+
for (const cb of this.disconnectCallbacks) cb();
|
|
206
55
|
}
|
|
207
56
|
}
|
package/src/task-executor.ts
CHANGED
|
@@ -1,238 +1,99 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AgentResultFile } from "@clawroom/sdk";
|
|
2
2
|
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ClawroomPluginClient } from "./client.js";
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
|
|
7
|
-
/** Default timeout for waiting on a subagent run (5 minutes). */
|
|
8
7
|
const SUBAGENT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
-
|
|
10
|
-
/** Max file size to upload (10 MB). */
|
|
11
8
|
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
12
9
|
|
|
13
|
-
/**
|
|
14
|
-
* Wire up the task execution pipeline:
|
|
15
|
-
* 1. On server.task -> store as available
|
|
16
|
-
* 2. On claim_ack(ok) -> invoke subagent -> send result/fail
|
|
17
|
-
*
|
|
18
|
-
* Tasks arrive through the polling client, either because the agent auto-claimed
|
|
19
|
-
* them or because the owner manually claimed them from the dashboard.
|
|
20
|
-
*/
|
|
21
10
|
export function setupTaskExecutor(opts: {
|
|
22
|
-
client:
|
|
11
|
+
client: ClawroomPluginClient;
|
|
23
12
|
runtime: PluginRuntime;
|
|
24
|
-
log?: {
|
|
25
|
-
info?: (message: string, ...args: unknown[]) => void;
|
|
26
|
-
warn?: (message: string, ...args: unknown[]) => void;
|
|
27
|
-
error?: (message: string, ...args: unknown[]) => void;
|
|
28
|
-
};
|
|
13
|
+
log?: { info?: (m: string, ...a: unknown[]) => void; warn?: (m: string, ...a: unknown[]) => void; error?: (m: string, ...a: unknown[]) => void };
|
|
29
14
|
}): void {
|
|
30
15
|
const { client, runtime, log } = opts;
|
|
31
|
-
|
|
32
|
-
// Track received tasks until they transition into an active execution.
|
|
33
|
-
const knownTasks = new Map<string, ServerTask>();
|
|
34
16
|
const activeTasks = new Set<string>();
|
|
35
17
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
// Claim ack — either from plugin-initiated claim or Dashboard-initiated claim
|
|
43
|
-
client.onClaimAck((ack: ServerClaimAck) => {
|
|
44
|
-
if (!ack.ok) {
|
|
45
|
-
log?.warn?.(
|
|
46
|
-
`[clawroom:executor] claim rejected for ${ack.taskId}: ${ack.reason ?? "unknown"}`,
|
|
47
|
-
);
|
|
48
|
-
knownTasks.delete(ack.taskId);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const task = knownTasks.get(ack.taskId);
|
|
53
|
-
knownTasks.delete(ack.taskId);
|
|
54
|
-
|
|
55
|
-
if (activeTasks.has(ack.taskId)) {
|
|
56
|
-
log?.info?.(`[clawroom:executor] task ${ack.taskId} is already running, ignoring duplicate dispatch`);
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (!task) {
|
|
61
|
-
log?.warn?.(
|
|
62
|
-
`[clawroom:executor] claim_ack for unknown task ${ack.taskId}`,
|
|
63
|
-
);
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
log?.info?.(`[clawroom:executor] executing task ${task.taskId}`);
|
|
68
|
-
activeTasks.add(task.taskId);
|
|
69
|
-
void executeTask({ client, runtime, task, log }).finally(() => {
|
|
70
|
-
activeTasks.delete(task.taskId);
|
|
71
|
-
});
|
|
18
|
+
client.onAgentTask((agentId: string, task: any) => {
|
|
19
|
+
const key = `${agentId}:${task.taskId}`;
|
|
20
|
+
if (activeTasks.has(key)) return;
|
|
21
|
+
activeTasks.add(key);
|
|
22
|
+
log?.info?.(`[clawroom:executor] [${agentId}] executing task ${task.taskId}: ${task.title}`);
|
|
23
|
+
void executeTask({ client, runtime, task, agentId, log }).finally(() => activeTasks.delete(key));
|
|
72
24
|
});
|
|
73
25
|
}
|
|
74
26
|
|
|
75
|
-
/**
|
|
76
|
-
* Execute a claimed task by running a subagent session and reporting the
|
|
77
|
-
* result back to the ClawRoom server.
|
|
78
|
-
*/
|
|
79
27
|
async function executeTask(opts: {
|
|
80
|
-
client:
|
|
28
|
+
client: ClawroomPluginClient;
|
|
81
29
|
runtime: PluginRuntime;
|
|
82
|
-
task:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
error?: (message: string, ...args: unknown[]) => void;
|
|
86
|
-
};
|
|
30
|
+
task: any;
|
|
31
|
+
agentId: string;
|
|
32
|
+
log?: { info?: (m: string, ...a: unknown[]) => void; error?: (m: string, ...a: unknown[]) => void };
|
|
87
33
|
}): Promise<void> {
|
|
88
|
-
const { client, runtime, task, log } = opts;
|
|
89
|
-
const sessionKey = `clawroom:task:${task.taskId}`;
|
|
34
|
+
const { client, runtime, task, agentId, log } = opts;
|
|
35
|
+
const sessionKey = `clawroom:task:${agentId}:${task.taskId}`;
|
|
90
36
|
|
|
91
|
-
const
|
|
37
|
+
const parts: string[] = [`# Task: ${task.title}`];
|
|
38
|
+
if (task.description) parts.push("", task.description);
|
|
39
|
+
if (task.input) parts.push("", "## Input", "", task.input);
|
|
40
|
+
if (task.skillTags?.length) parts.push("", `Skills: ${task.skillTags.join(", ")}`);
|
|
41
|
+
const agentMessage = parts.join("\n");
|
|
92
42
|
|
|
93
43
|
try {
|
|
94
|
-
log?.info?.(`[clawroom:executor] running subagent for task ${task.taskId}`);
|
|
95
|
-
|
|
96
44
|
const { runId } = await runtime.subagent.run({
|
|
97
45
|
sessionKey,
|
|
98
|
-
idempotencyKey: `clawroom:${task.taskId}`,
|
|
46
|
+
idempotencyKey: `clawroom:${agentId}:${task.taskId}`,
|
|
99
47
|
message: agentMessage,
|
|
100
48
|
extraSystemPrompt:
|
|
101
|
-
"You are executing a task from
|
|
102
|
-
"
|
|
103
|
-
"
|
|
104
|
-
"If you create output files, list their absolute paths at the very end, " +
|
|
105
|
-
"one per line, prefixed with 'OUTPUT_FILE: '. These markers will be processed " +
|
|
106
|
-
"automatically and stripped before showing to the user. " +
|
|
107
|
-
"Example: OUTPUT_FILE: /path/to/report.pdf",
|
|
49
|
+
"You are executing a task from ClawRoom. Complete it and provide a SHORT summary (2-3 sentences). " +
|
|
50
|
+
"Do NOT include local file paths or internal details. " +
|
|
51
|
+
"If you create output files, list them at the end prefixed with 'OUTPUT_FILE: /path'.",
|
|
108
52
|
lane: "clawroom",
|
|
109
53
|
});
|
|
110
54
|
|
|
111
|
-
const waitResult = await runtime.subagent.waitForRun({
|
|
112
|
-
runId,
|
|
113
|
-
timeoutMs: SUBAGENT_TIMEOUT_MS,
|
|
114
|
-
});
|
|
55
|
+
const waitResult = await runtime.subagent.waitForRun({ runId, timeoutMs: SUBAGENT_TIMEOUT_MS });
|
|
115
56
|
|
|
116
57
|
if (waitResult.status === "error") {
|
|
117
|
-
log?.error?.(
|
|
118
|
-
|
|
119
|
-
);
|
|
120
|
-
client.send({
|
|
121
|
-
type: "agent.fail",
|
|
122
|
-
taskId: task.taskId,
|
|
123
|
-
reason: waitResult.error ?? "Agent execution failed",
|
|
124
|
-
});
|
|
58
|
+
log?.error?.(`[clawroom:executor] [${agentId}] subagent error: ${waitResult.error}`);
|
|
59
|
+
await client.sendFail(agentId, task.taskId, waitResult.error ?? "Agent execution failed");
|
|
125
60
|
return;
|
|
126
61
|
}
|
|
127
|
-
|
|
128
62
|
if (waitResult.status === "timeout") {
|
|
129
|
-
log?.error?.(`[clawroom:executor] subagent timeout
|
|
130
|
-
client.
|
|
131
|
-
type: "agent.fail",
|
|
132
|
-
taskId: task.taskId,
|
|
133
|
-
reason: "Agent execution timed out",
|
|
134
|
-
});
|
|
63
|
+
log?.error?.(`[clawroom:executor] [${agentId}] subagent timeout`);
|
|
64
|
+
await client.sendFail(agentId, task.taskId, "Agent execution timed out");
|
|
135
65
|
return;
|
|
136
66
|
}
|
|
137
67
|
|
|
138
|
-
const { messages } = await runtime.subagent.getSessionMessages({
|
|
139
|
-
sessionKey,
|
|
140
|
-
limit: 100,
|
|
141
|
-
});
|
|
142
|
-
|
|
68
|
+
const { messages } = await runtime.subagent.getSessionMessages({ sessionKey, limit: 100 });
|
|
143
69
|
const rawOutput = extractAgentOutput(messages);
|
|
144
|
-
const
|
|
145
|
-
const filesFromOutput = extractOutputFileMarkers(rawOutput);
|
|
146
|
-
const allFiles = [...new Set([...filesFromTools, ...filesFromOutput])];
|
|
70
|
+
const allFiles = [...new Set([...extractWrittenFiles(messages), ...extractOutputFileMarkers(rawOutput)])];
|
|
147
71
|
const files = readAllFiles(allFiles, log);
|
|
72
|
+
const output = rawOutput.split("\n").filter((l) => !l.match(/OUTPUT_FILE:\s*/)).join("\n").replace(/`?\/\S+\/[^\s`]+`?/g, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
148
73
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
.filter((line) => !line.match(/OUTPUT_FILE:\s*/))
|
|
153
|
-
.join("\n")
|
|
154
|
-
.replace(/`?\/\S+\/[^\s`]+`?/g, "") // strip absolute paths
|
|
155
|
-
.replace(/\n{3,}/g, "\n\n") // collapse extra newlines
|
|
156
|
-
.trim();
|
|
157
|
-
|
|
158
|
-
log?.info?.(`[clawroom:executor] task ${task.taskId} completed${files.length > 0 ? ` (with ${files.length} file(s))` : ""}`);
|
|
159
|
-
client.send({
|
|
160
|
-
type: "agent.complete",
|
|
161
|
-
taskId: task.taskId,
|
|
162
|
-
output,
|
|
163
|
-
attachments: files.length > 0 ? files : undefined,
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
await runtime.subagent.deleteSession({
|
|
167
|
-
sessionKey,
|
|
168
|
-
deleteTranscript: true,
|
|
169
|
-
});
|
|
74
|
+
log?.info?.(`[clawroom:executor] [${agentId}] task completed${files.length > 0 ? ` (${files.length} files)` : ""}`);
|
|
75
|
+
await client.sendComplete(agentId, task.taskId, output, files.length > 0 ? files : undefined);
|
|
76
|
+
await runtime.subagent.deleteSession({ sessionKey, deleteTranscript: true });
|
|
170
77
|
} catch (err) {
|
|
171
78
|
const reason = err instanceof Error ? err.message : String(err);
|
|
172
|
-
log?.error?.(`[clawroom:executor]
|
|
173
|
-
client.
|
|
174
|
-
type: "agent.fail",
|
|
175
|
-
taskId: task.taskId,
|
|
176
|
-
reason,
|
|
177
|
-
});
|
|
79
|
+
log?.error?.(`[clawroom:executor] [${agentId}] error: ${reason}`);
|
|
80
|
+
await client.sendFail(agentId, task.taskId, reason);
|
|
178
81
|
}
|
|
179
82
|
}
|
|
180
83
|
|
|
181
|
-
function buildAgentMessage(task: ServerTask): string {
|
|
182
|
-
const parts: string[] = [];
|
|
183
|
-
parts.push(`# Task: ${task.title}`);
|
|
184
|
-
if (task.description) {
|
|
185
|
-
parts.push("", task.description);
|
|
186
|
-
}
|
|
187
|
-
if (task.input) {
|
|
188
|
-
parts.push("", "## Input", "", task.input);
|
|
189
|
-
}
|
|
190
|
-
if (task.skillTags.length > 0) {
|
|
191
|
-
parts.push("", `Skills: ${task.skillTags.join(", ")}`);
|
|
192
|
-
}
|
|
193
|
-
return parts.join("\n");
|
|
194
|
-
}
|
|
195
|
-
|
|
196
84
|
function extractAgentOutput(messages: unknown[]): string {
|
|
197
85
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
198
86
|
const msg = messages[i] as Record<string, unknown> | undefined;
|
|
199
|
-
if (!msg) continue;
|
|
200
|
-
|
|
201
|
-
if (msg.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
if (Array.isArray(msg.content)) {
|
|
206
|
-
const textParts: string[] = [];
|
|
207
|
-
for (const block of msg.content) {
|
|
208
|
-
const b = block as Record<string, unknown>;
|
|
209
|
-
if (b.type === "text" && typeof b.text === "string") {
|
|
210
|
-
textParts.push(b.text);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
if (textParts.length > 0) {
|
|
214
|
-
return textParts.join("\n");
|
|
215
|
-
}
|
|
216
|
-
}
|
|
87
|
+
if (!msg || msg.role !== "assistant") continue;
|
|
88
|
+
if (typeof msg.content === "string") return msg.content;
|
|
89
|
+
if (Array.isArray(msg.content)) {
|
|
90
|
+
const parts = (msg.content as Record<string, unknown>[]).filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text as string);
|
|
91
|
+
if (parts.length > 0) return parts.join("\n");
|
|
217
92
|
}
|
|
218
93
|
}
|
|
219
|
-
|
|
220
94
|
return "(No output produced)";
|
|
221
95
|
}
|
|
222
96
|
|
|
223
|
-
const MIME_MAP: Record<string, string> = {
|
|
224
|
-
".md": "text/markdown", ".txt": "text/plain", ".pdf": "application/pdf",
|
|
225
|
-
".json": "application/json", ".csv": "text/csv", ".html": "text/html",
|
|
226
|
-
".js": "application/javascript", ".ts": "application/typescript",
|
|
227
|
-
".py": "text/x-python", ".png": "image/png", ".jpg": "image/jpeg",
|
|
228
|
-
".jpeg": "image/jpeg", ".gif": "image/gif", ".svg": "image/svg+xml",
|
|
229
|
-
".mp4": "video/mp4", ".zip": "application/zip",
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Extract file paths from agent session messages by looking at Write/write_file
|
|
234
|
-
* tool calls in the assistant's content blocks.
|
|
235
|
-
*/
|
|
236
97
|
function extractWrittenFiles(messages: unknown[]): string[] {
|
|
237
98
|
const files = new Set<string>();
|
|
238
99
|
for (const msg of messages) {
|
|
@@ -240,14 +101,13 @@ function extractWrittenFiles(messages: unknown[]): string[] {
|
|
|
240
101
|
if (!m || !Array.isArray(m.content)) continue;
|
|
241
102
|
for (const block of m.content) {
|
|
242
103
|
const b = block as Record<string, unknown>;
|
|
243
|
-
// Match tool_use blocks (Claude-style) and tool_call blocks (OpenAI-style)
|
|
244
104
|
if (b.type !== "tool_use" && b.type !== "tool_call") continue;
|
|
245
105
|
const name = String(b.name ?? b.function ?? "").toLowerCase();
|
|
246
106
|
if (!name.includes("write") && !name.includes("create_file") && !name.includes("save")) continue;
|
|
247
107
|
const raw = b.input ?? b.arguments;
|
|
248
|
-
const input = typeof raw === "string" ?
|
|
108
|
+
const input = typeof raw === "string" ? (() => { try { return JSON.parse(raw); } catch { return null; } })() : raw;
|
|
249
109
|
if (input && typeof input === "object") {
|
|
250
|
-
const fp = (input as
|
|
110
|
+
const fp = (input as any).file_path ?? (input as any).path;
|
|
251
111
|
if (typeof fp === "string" && fp) files.add(fp);
|
|
252
112
|
}
|
|
253
113
|
}
|
|
@@ -255,53 +115,28 @@ function extractWrittenFiles(messages: unknown[]): string[] {
|
|
|
255
115
|
return Array.from(files);
|
|
256
116
|
}
|
|
257
117
|
|
|
258
|
-
/**
|
|
259
|
-
* Extract file paths from OUTPUT_FILE: markers in the agent's text output.
|
|
260
|
-
*/
|
|
261
118
|
function extractOutputFileMarkers(output: string): string[] {
|
|
262
|
-
|
|
263
|
-
for (const line of output.split("\n")) {
|
|
264
|
-
const match = line.match(/OUTPUT_FILE:\s*(.+)/);
|
|
265
|
-
if (match) {
|
|
266
|
-
const fp = match[1].trim().replace(/`/g, "");
|
|
267
|
-
if (fp) files.push(fp);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
return files;
|
|
119
|
+
return output.split("\n").map((l) => l.match(/OUTPUT_FILE:\s*(.+)/)?.[1]?.trim().replace(/`/g, "")).filter((f): f is string => !!f);
|
|
271
120
|
}
|
|
272
121
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
122
|
+
const MIME_MAP: Record<string, string> = {
|
|
123
|
+
".md": "text/markdown", ".txt": "text/plain", ".pdf": "application/pdf",
|
|
124
|
+
".json": "application/json", ".csv": "text/csv", ".html": "text/html",
|
|
125
|
+
".js": "application/javascript", ".ts": "application/typescript",
|
|
126
|
+
".py": "text/x-python", ".png": "image/png", ".jpg": "image/jpeg",
|
|
127
|
+
".gif": "image/gif", ".svg": "image/svg+xml", ".zip": "application/zip",
|
|
128
|
+
};
|
|
276
129
|
|
|
277
|
-
|
|
278
|
-
* Read all valid files from disk and encode them as base64.
|
|
279
|
-
* Skips missing files and files > MAX_FILE_SIZE.
|
|
280
|
-
*/
|
|
281
|
-
function readAllFiles(
|
|
282
|
-
paths: string[],
|
|
283
|
-
log?: {
|
|
284
|
-
info?: (message: string, ...args: unknown[]) => void;
|
|
285
|
-
warn?: (message: string, ...args: unknown[]) => void;
|
|
286
|
-
},
|
|
287
|
-
): AgentResultFile[] {
|
|
130
|
+
function readAllFiles(paths: string[], log?: { warn?: (m: string, ...a: unknown[]) => void; info?: (m: string, ...a: unknown[]) => void }): AgentResultFile[] {
|
|
288
131
|
const results: AgentResultFile[] = [];
|
|
289
132
|
for (const fp of paths) {
|
|
290
133
|
try {
|
|
291
|
-
if (!fs.existsSync(fp))
|
|
134
|
+
if (!fs.existsSync(fp)) continue;
|
|
292
135
|
const stat = fs.statSync(fp);
|
|
293
|
-
if (stat.size > MAX_FILE_SIZE)
|
|
136
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
294
137
|
const data = fs.readFileSync(fp);
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
results.push({
|
|
298
|
-
filename: path.basename(fp),
|
|
299
|
-
mimeType: MIME_MAP[ext] ?? "application/octet-stream",
|
|
300
|
-
data: data.toString("base64"),
|
|
301
|
-
});
|
|
302
|
-
} catch (err) {
|
|
303
|
-
log?.warn?.(`[clawroom:executor] failed to read ${fp}: ${err}`);
|
|
304
|
-
}
|
|
138
|
+
results.push({ filename: path.basename(fp), mimeType: MIME_MAP[path.extname(fp).toLowerCase()] ?? "application/octet-stream", data: data.toString("base64") });
|
|
139
|
+
} catch {}
|
|
305
140
|
}
|
|
306
141
|
return results;
|
|
307
142
|
}
|