@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 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 lobsters can claim and execute tasks.
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` | Lobster token from the ClawRoom dashboard |
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 a lobster token in the dashboard.
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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "clawroom",
3
3
  "name": "ClawRoom",
4
- "description": "ClawRoom task marketplace channel plugin",
4
+ "description": "ClawRoom workspace channel plugin",
5
5
  "configSchema": {},
6
6
  "channels": ["clawroom"]
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clawroom/openclaw",
3
- "version": "0.2.3",
4
- "description": "OpenClaw channel plugin for the ClawRoom task marketplace",
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.2.3"
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 type { AgentMessage, ServerClaimAck, ServerTask } from "@clawroom/sdk";
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 = (agentId: string) => void;
12
- type FatalCallback = (reason: string, code?: number) => void;
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 agent client using HTTP polling.
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 ClawroomClient {
33
- private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
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(private readonly options: ClawroomClientOptions) {
46
- this.httpBase = options.endpoint.replace(/\/+$/, "");
18
+ constructor(options: ClawroomMachineClientOptions) {
19
+ this.inner = new ClawroomMachineClient(options);
47
20
  }
48
21
 
49
- connect(): void {
50
- this.stopped = false;
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
- // ── Heartbeat (HTTP POST) ───────────────────────────────────────
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
- private markConnected(agentId?: string): void {
94
- if (this.connected) return;
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
- private markDisconnected(): void {
103
- if (!this.connected) return;
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
- private startPolling(): void {
109
- this.stopPolling();
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
- private stopPolling(): void {
117
- if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
44
+ async sendTyping(agentId: string, channelId: string) {
45
+ await this.inner.sendAgentTyping(agentId, channelId);
118
46
  }
119
47
 
120
- private async register(): Promise<void> {
121
- try {
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
- private async httpRequest(method: string, path: string, body: unknown): Promise<any> {
186
- const res = await fetch(`${this.httpBase}${path}`, {
187
- method,
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
  }
@@ -1,238 +1,99 @@
1
- import type { ServerClaimAck, ServerTask, AgentResultFile } from "@clawroom/sdk";
1
+ import type { AgentResultFile } from "@clawroom/sdk";
2
2
  import type { PluginRuntime } from "openclaw/plugin-sdk/core";
3
- import type { ClawroomClient } from "./client.js";
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: ClawroomClient;
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
- // New task received store it for potential execution
37
- client.onTask((task: ServerTask) => {
38
- log?.info?.(`[clawroom:executor] received task ${task.taskId}: ${task.title}`);
39
- knownTasks.set(task.taskId, task);
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: ClawroomClient;
28
+ client: ClawroomPluginClient;
81
29
  runtime: PluginRuntime;
82
- task: ServerTask;
83
- log?: {
84
- info?: (message: string, ...args: unknown[]) => void;
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 agentMessage = buildAgentMessage(task);
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 the ClawRoom marketplace. " +
102
- "Complete the task and provide a SHORT summary (2-3 sentences) of what you did. " +
103
- "Do NOT include any local file paths, machine info, or internal details in your summary. " +
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
- `[clawroom:executor] subagent error for task ${task.taskId}: ${waitResult.error}`,
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 for task ${task.taskId}`);
130
- client.send({
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 filesFromTools = extractWrittenFiles(messages);
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
- // Strip OUTPUT_FILE markers and internal paths from the user-facing output
150
- const output = rawOutput
151
- .split("\n")
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] unexpected error for task ${task.taskId}: ${reason}`);
173
- client.send({
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.role === "assistant") {
202
- if (typeof msg.content === "string") {
203
- return msg.content;
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" ? tryParse(raw) : raw;
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 Record<string, unknown>).file_path ?? (input as Record<string, unknown>).path;
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
- const files: string[] = [];
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
- function tryParse(s: string): unknown {
274
- try { return JSON.parse(s); } catch { return null; }
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)) { log?.warn?.(`[clawroom:executor] file not found: ${fp}`); continue; }
134
+ if (!fs.existsSync(fp)) continue;
292
135
  const stat = fs.statSync(fp);
293
- if (stat.size > MAX_FILE_SIZE) { log?.warn?.(`[clawroom:executor] file too large: ${fp} (${stat.size})`); continue; }
136
+ if (stat.size > MAX_FILE_SIZE) continue;
294
137
  const data = fs.readFileSync(fp);
295
- const ext = path.extname(fp).toLowerCase();
296
- log?.info?.(`[clawroom:executor] attached: ${path.basename(fp)} (${data.length} bytes)`);
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
  }