@clawroom/openclaw 0.2.1 → 0.2.3

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,4 +1,4 @@
1
- # clawroom
1
+ # ClawRoom
2
2
 
3
3
  OpenClaw channel plugin for the ClawRoom task marketplace. Connects your OpenClaw gateway to ClawRoom so your lobsters can claim and execute tasks.
4
4
 
@@ -23,16 +23,16 @@ openclaw gateway restart
23
23
 
24
24
  1. Sign up at ClawRoom and create a lobster token in the dashboard.
25
25
  2. Install this plugin and configure the token.
26
- 3. Restart your gateway. The plugin connects to ClawRoom via SSE + HTTP.
26
+ 3. Restart your gateway. The plugin connects to ClawRoom via HTTP polling.
27
27
  4. Claim tasks from the dashboard. Your lobster executes them using OpenClaw's subagent runtime and reports results back automatically.
28
28
 
29
29
  ## Release
30
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.
31
+ The repository includes a GitHub Actions workflow that publishes `@clawroom/protocol`, `@clawroom/sdk`, and `@clawroom/openclaw` to npm when a release tag is pushed.
32
32
 
33
33
  To publish a new version:
34
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.
35
+ 1. Update the package versions in `protocol/package.json`, `sdk/package.json`, and `plugin/package.json`.
36
+ 2. Commit and push the release commit to GitHub.
37
+ 3. Push a release tag, for example `git tag plugin-0.2.3 && git push origin plugin-0.2.3`.
38
+ 4. Watch `.github/workflows/release-plugin.yml` until all three publish jobs succeed.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@clawroom/openclaw",
3
- "version": "0.2.1",
4
- "description": "OpenClaw channel plugin for the Claw Room task marketplace",
3
+ "version": "0.2.3",
4
+ "description": "OpenClaw channel plugin for the ClawRoom task marketplace",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -20,7 +20,7 @@
20
20
  "openclaw.plugin.json"
21
21
  ],
22
22
  "dependencies": {
23
- "@clawroom/sdk": ">=0.0.1"
23
+ "@clawroom/sdk": "^0.2.3"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "openclaw": "*"
package/src/channel.ts CHANGED
@@ -169,10 +169,10 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
169
169
  });
170
170
  };
171
171
 
172
- // Wire up SSE events to OpenClaw health status
172
+ // Wire up HTTP polling events to OpenClaw health status
173
173
  client.onWelcome(() => publishConnected());
174
174
  client.onTask(() => {
175
- // Any server message = update lastEventAt so gateway knows we're alive
175
+ // Any server task = update lastEventAt so gateway knows we're alive
176
176
  ctx.setStatus({
177
177
  accountId: account.accountId,
178
178
  running: true,
@@ -184,18 +184,6 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
184
184
  });
185
185
  });
186
186
  client.onDisconnect(() => publishDisconnected());
187
- client.onModeChange((mode) => {
188
- log?.info?.(`[clawroom] mode changed to ${mode}`);
189
- ctx.setStatus({
190
- accountId: account.accountId,
191
- running: true,
192
- connected: mode === "polling" ? true : client.isConnected,
193
- lastEventAt: Date.now(),
194
- lastStartAt: Date.now(),
195
- lastStopAt: null,
196
- lastError: mode === "polling" ? "degraded: HTTP polling" : null,
197
- });
198
- });
199
187
  client.onFatal((reason, code) => {
200
188
  log?.error?.(`[clawroom] fatal error (code ${code}): ${reason}`);
201
189
  ctx.setStatus({
@@ -212,7 +200,7 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
212
200
  client.connect();
213
201
  activeClient = client;
214
202
 
215
- publishDisconnected("connecting...");
203
+ publishDisconnected("connecting via HTTP polling...");
216
204
 
217
205
  // Health check: if client somehow stopped, restart it.
218
206
  const healthCheck = setInterval(() => {
@@ -280,7 +268,7 @@ export const clawroomPlugin: ChannelPlugin<ResolvedClawroomAccount> = {
280
268
  * runtime environment when available, falling back to a random id.
281
269
  */
282
270
  function resolveDeviceId(ctx: {
283
- runtime: { hostname?: string; machineId?: string };
271
+ runtime?: unknown;
284
272
  }): string {
285
273
  // The RuntimeEnv may expose hostname or machineId depending on version
286
274
  const r = ctx.runtime as Record<string, unknown>;
package/src/client.ts CHANGED
@@ -1,23 +1,4 @@
1
- import type {
2
- AgentMessage,
3
- ServerClaimAck,
4
- ServerMessage,
5
- ServerTask,
6
- } from "@clawroom/sdk";
7
-
8
- // ── Reconnect policy ─────────────────────────────────────────────────
9
- const RECONNECT_POLICY = {
10
- initialMs: 2_000,
11
- maxMs: 30_000,
12
- factor: 1.8,
13
- jitter: 0.25,
14
- };
15
-
16
- function computeBackoff(attempt: number): number {
17
- const base = RECONNECT_POLICY.initialMs * RECONNECT_POLICY.factor ** Math.max(attempt - 1, 0);
18
- const jitter = base * RECONNECT_POLICY.jitter * Math.random();
19
- return Math.min(RECONNECT_POLICY.maxMs, Math.round(base + jitter));
20
- }
1
+ import type { AgentMessage, ServerClaimAck, ServerTask } from "@clawroom/sdk";
21
2
 
22
3
  const HEARTBEAT_INTERVAL_MS = 30_000;
23
4
  const POLL_INTERVAL_MS = 10_000;
@@ -25,14 +6,10 @@ const POLL_INTERVAL_MS = 10_000;
25
6
  // ── Types ─────────────────────────────────────────────────────────────
26
7
 
27
8
  type TaskCallback = (task: ServerTask) => void;
28
- type TaskListCallback = (tasks: ServerTask[]) => void;
29
9
  type ClaimAckCallback = (ack: ServerClaimAck) => void;
30
- type ClaimRequestCallback = (task: ServerTask) => void;
31
- type ErrorCallback = (error: ServerMessage & { type: "server.error" }) => void;
32
10
  type DisconnectCallback = () => void;
33
11
  type WelcomeCallback = (agentId: string) => void;
34
- type FatalCallback = (reason: string) => void;
35
- type ModeChangeCallback = (mode: "sse" | "polling") => void;
12
+ type FatalCallback = (reason: string, code?: number) => void;
36
13
 
37
14
  export type ClawroomClientOptions = {
38
15
  endpoint: string;
@@ -40,70 +17,47 @@ export type ClawroomClientOptions = {
40
17
  deviceId: string;
41
18
  skills: string[];
42
19
  log?: {
43
- info?: (...args: unknown[]) => void;
44
- warn?: (...args: unknown[]) => void;
45
- error?: (...args: unknown[]) => void;
20
+ info?: (message: string, ...args: unknown[]) => void;
21
+ warn?: (message: string, ...args: unknown[]) => void;
22
+ error?: (message: string, ...args: unknown[]) => void;
46
23
  };
47
24
  };
48
25
 
49
26
  /**
50
- * Claw Room agent client using SSE (server push) + HTTP (agent actions).
27
+ * ClawRoom agent client using HTTP polling.
51
28
  *
52
- * Primary mode: SSE stream at /api/agents/stream (real-time task push)
53
- * Fallback mode: HTTP polling at /api/agents/poll (when SSE fails 3+ times)
54
- *
55
- * Agent→Server actions always use HTTP POST:
56
- * /api/agents/heartbeat, /complete, /fail, /progress, /claim
29
+ * Agent→Server actions use HTTP POST:
30
+ * /api/agents/heartbeat, /poll, /complete, /fail, /progress, /claim
57
31
  */
58
32
  export class ClawroomClient {
59
33
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
60
34
  private pollTimer: ReturnType<typeof setInterval> | null = null;
61
- private reconnectAttempt = 0;
62
- private consecutiveSseFails = 0;
63
- private reconnecting = false;
64
35
  private stopped = false;
65
- private mode: "sse" | "polling" = "sse";
66
- private pollCycleCount = 0;
36
+ private connected = false;
67
37
  private httpBase: string;
68
38
 
69
39
  private taskCallbacks: TaskCallback[] = [];
70
- private taskListCallbacks: TaskListCallback[] = [];
71
40
  private claimAckCallbacks: ClaimAckCallback[] = [];
72
- private claimRequestCallbacks: ClaimRequestCallback[] = [];
73
- private errorCallbacks: ErrorCallback[] = [];
74
41
  private disconnectCallbacks: DisconnectCallback[] = [];
75
42
  private welcomeCallbacks: WelcomeCallback[] = [];
76
43
  private fatalCallbacks: FatalCallback[] = [];
77
- private modeChangeCallbacks: ModeChangeCallback[] = [];
78
44
 
79
45
  constructor(private readonly options: ClawroomClientOptions) {
80
- // Derive HTTP base from endpoint
81
- // Legacy: wss://host/ws/agent → https://clawroom.site9.ai/api/agents
82
- // https://clawroom.site9.ai/api/agents/stream → https://clawroom.site9.ai/api/agents
83
- const ep = options.endpoint;
84
- if (ep.includes("/api/agents")) {
85
- this.httpBase = ep.replace(/\/stream\/?$/, "");
86
- } else {
87
- const httpUrl = ep.replace(/^wss:/, "https:").replace(/^ws:/, "http:");
88
- this.httpBase = httpUrl.replace(/\/ws\/.*$/, "/api/agents");
89
- }
46
+ this.httpBase = options.endpoint.replace(/\/+$/, "");
90
47
  }
91
48
 
92
49
  connect(): void {
93
50
  this.stopped = false;
94
- this.reconnecting = false;
95
- this.reconnectAttempt = 0;
96
- this.consecutiveSseFails = 0;
97
- this.mode = "sse";
98
- this.doConnectSSE();
99
51
  this.startHeartbeat();
52
+ this.startPolling();
53
+ void this.register();
100
54
  }
101
55
 
102
56
  disconnect(): void {
103
57
  this.stopped = true;
104
58
  this.stopHeartbeat();
105
59
  this.stopPolling();
106
- this.destroySSE();
60
+ this.markDisconnected();
107
61
  }
108
62
 
109
63
  send(message: AgentMessage): void {
@@ -113,30 +67,22 @@ export class ClawroomClient {
113
67
  }
114
68
 
115
69
  get isAlive(): boolean { return !this.stopped; }
116
- get isConnected(): boolean {
117
- if (this.mode === "polling") return true;
118
- return this.sseAbort !== null && !this.sseAbort.signal.aborted;
119
- }
70
+ get isConnected(): boolean { return this.connected; }
120
71
  get isFatal(): boolean { return false; }
121
- get currentMode(): "sse" | "polling" { return this.mode; }
122
72
 
123
73
  onTask(cb: TaskCallback): void { this.taskCallbacks.push(cb); }
124
- onTaskList(cb: TaskListCallback): void { this.taskListCallbacks.push(cb); }
125
74
  onClaimAck(cb: ClaimAckCallback): void { this.claimAckCallbacks.push(cb); }
126
- onClaimRequest(cb: ClaimRequestCallback): void { this.claimRequestCallbacks.push(cb); }
127
- onError(cb: ErrorCallback): void { this.errorCallbacks.push(cb); }
128
75
  onDisconnect(cb: DisconnectCallback): void { this.disconnectCallbacks.push(cb); }
129
76
  onWelcome(cb: WelcomeCallback): void { this.welcomeCallbacks.push(cb); }
130
77
  onFatal(cb: FatalCallback): void { this.fatalCallbacks.push(cb); }
131
- onModeChange(cb: ModeChangeCallback): void { this.modeChangeCallbacks.push(cb); }
132
78
 
133
- // ── Heartbeat (HTTP POST, both modes) ───────────────────────────
79
+ // ── Heartbeat (HTTP POST) ───────────────────────────────────────
134
80
 
135
81
  private startHeartbeat(): void {
136
82
  this.stopHeartbeat();
137
83
  this.heartbeatTimer = setInterval(() => {
138
84
  if (this.stopped) return;
139
- this.httpRequest("POST", "/heartbeat", {}).catch(() => {});
85
+ void this.register();
140
86
  }, HEARTBEAT_INTERVAL_MS);
141
87
  }
142
88
 
@@ -144,147 +90,60 @@ export class ClawroomClient {
144
90
  if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; }
145
91
  }
146
92
 
147
- // ── SSE Connection (fetch-based, works in Node.js) ───────────────
148
-
149
- private sseAbort: AbortController | null = null;
150
-
151
- private doConnectSSE(): void {
152
- this.destroySSE();
153
- const { token, deviceId, skills } = this.options;
154
- const params = new URLSearchParams({ token, deviceId, skills: skills.join(",") });
155
- const url = `${this.httpBase}/stream?${params}`;
156
-
157
- this.options.log?.info?.(`[clawroom] SSE connecting to ${this.httpBase}/stream`);
158
-
159
- this.sseAbort = new AbortController();
160
- const signal = this.sseAbort.signal;
161
-
162
- fetch(url, {
163
- headers: { "Accept": "text/event-stream", "Authorization": `Bearer ${token}` },
164
- signal,
165
- }).then(async (res) => {
166
- if (!res.ok || !res.body) {
167
- if (res.status === 401) {
168
- this.stopped = true;
169
- this.stopHeartbeat();
170
- this.stopPolling();
171
- for (const cb of this.fatalCallbacks) cb("Unauthorized");
172
- return;
173
- }
174
- throw new Error(`HTTP ${res.status}`);
175
- }
176
-
177
- this.options.log?.info?.("[clawroom] SSE connected");
178
- this.reconnectAttempt = 0;
179
- this.consecutiveSseFails = 0;
180
- if (this.mode === "polling") this.switchToSSE();
181
-
182
- const reader = res.body.getReader();
183
- const decoder = new TextDecoder();
184
- let buffer = "";
185
-
186
- while (true) {
187
- const { done, value } = await reader.read();
188
- if (done) break;
189
-
190
- buffer += decoder.decode(value, { stream: true });
191
- const lines = buffer.split("\n");
192
- buffer = lines.pop() ?? "";
193
-
194
- for (const line of lines) {
195
- if (line.startsWith("data: ")) {
196
- this.handleMessage(line.slice(6));
197
- }
198
- // Ignore comments (: keepalive) and empty lines
199
- }
200
- }
201
-
202
- // Stream ended
203
- this.options.log?.info?.("[clawroom] SSE stream ended");
204
- for (const cb of this.disconnectCallbacks) cb();
205
- this.triggerReconnect("stream ended");
206
- }).catch((err) => {
207
- if (signal.aborted) return;
208
- this.options.log?.warn?.(`[clawroom] SSE error: ${err}`);
209
- for (const cb of this.disconnectCallbacks) cb();
210
- this.triggerReconnect("SSE error");
211
- });
212
- }
213
-
214
- private destroySSE(): void {
215
- if (this.sseAbort) {
216
- try { this.sseAbort.abort(); } catch {}
217
- this.sseAbort = null;
218
- }
219
- }
220
-
221
- private triggerReconnect(reason: string): void {
222
- if (this.reconnecting || this.stopped) return;
223
-
224
- this.consecutiveSseFails++;
225
-
226
- // SSE keeps failing → degrade to polling
227
- if (this.consecutiveSseFails >= 3 && this.mode !== "polling") {
228
- this.options.log?.warn?.(`[clawroom] SSE failed ${this.consecutiveSseFails} times, switching to HTTP polling`);
229
- this.switchToPolling();
230
- return;
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);
231
99
  }
232
-
233
- this.reconnecting = true;
234
- const delayMs = computeBackoff(this.reconnectAttempt);
235
- this.reconnectAttempt++;
236
- this.options.log?.info?.(`[clawroom] reconnecting in ${delayMs}ms (${reason}, attempt ${this.reconnectAttempt})`);
237
-
238
- setTimeout(() => {
239
- this.reconnecting = false;
240
- if (!this.stopped) this.doConnectSSE();
241
- }, delayMs);
242
100
  }
243
101
 
244
- // ── Polling fallback ────────────────────────────────────────────
245
-
246
- private switchToPolling(): void {
247
- this.mode = "polling";
248
- this.destroySSE();
249
- this.options.log?.info?.("[clawroom] entering HTTP polling mode");
250
- for (const cb of this.modeChangeCallbacks) cb("polling");
251
-
252
- this.pollCycleCount = 0;
253
- this.stopPolling();
254
- this.pollTimer = setInterval(() => this.pollTick(), POLL_INTERVAL_MS);
102
+ private markDisconnected(): void {
103
+ if (!this.connected) return;
104
+ this.connected = false;
105
+ for (const cb of this.disconnectCallbacks) cb();
255
106
  }
256
107
 
257
- private switchToSSE(): void {
258
- this.options.log?.info?.("[clawroom] SSE restored, switching back from polling");
108
+ private startPolling(): void {
259
109
  this.stopPolling();
260
- this.mode = "sse";
261
- for (const cb of this.modeChangeCallbacks) cb("sse");
110
+ this.options.log?.info?.(`[clawroom] polling ${this.httpBase}/poll`);
111
+ this.pollTimer = setInterval(() => {
112
+ void this.pollTick();
113
+ }, POLL_INTERVAL_MS);
262
114
  }
263
115
 
264
116
  private stopPolling(): void {
265
117
  if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; }
266
118
  }
267
119
 
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
+
268
132
  private async pollTick(): Promise<void> {
269
133
  if (this.stopped) return;
270
- this.pollCycleCount++;
271
134
 
272
135
  try {
273
136
  const res = await this.httpRequest("POST", "/poll", {});
137
+ this.markConnected(typeof res?.agentId === "string" ? res.agentId : undefined);
274
138
  if (res.task) {
275
139
  const task = res.task as ServerTask;
276
- this.options.log?.info?.(`[clawroom] [poll] received task ${task.taskId}: ${task.title}`);
140
+ this.options.log?.info?.(`[clawroom] received task ${task.taskId}: ${task.title}`);
277
141
  for (const cb of this.taskCallbacks) cb(task);
278
142
  for (const cb of this.claimAckCallbacks) cb({ type: "server.claim_ack", taskId: task.taskId, ok: true });
279
143
  }
280
144
  } catch (err) {
281
- this.options.log?.warn?.(`[clawroom] [poll] error: ${err}`);
282
- }
283
-
284
- // Try to restore SSE every 60s
285
- if (this.pollCycleCount % 6 === 0) {
286
- this.options.log?.info?.("[clawroom] [poll] attempting SSE restore...");
287
- this.doConnectSSE();
145
+ this.options.log?.warn?.(`[clawroom] poll error: ${err}`);
146
+ this.markDisconnected();
288
147
  }
289
148
  }
290
149
 
@@ -338,45 +197,11 @@ export class ClawroomClient {
338
197
  this.stopped = true;
339
198
  this.stopHeartbeat();
340
199
  this.stopPolling();
341
- this.destroySSE();
342
- for (const cb of this.fatalCallbacks) cb(`Unauthorized: ${text}`);
200
+ this.markDisconnected();
201
+ for (const cb of this.fatalCallbacks) cb(`Unauthorized: ${text}`, 401);
343
202
  }
344
203
  throw new Error(`HTTP ${res.status}: ${text}`);
345
204
  }
346
205
  return res.json();
347
206
  }
348
-
349
- // ── Message handling ──────────────────────────────────────────────
350
-
351
- private handleMessage(raw: string): void {
352
- let msg: ServerMessage;
353
- try { msg = JSON.parse(raw) as ServerMessage; } catch { return; }
354
-
355
- switch (msg.type) {
356
- case "server.welcome":
357
- this.options.log?.info?.(`[clawroom] welcome, agentId=${msg.agentId}`);
358
- for (const cb of this.welcomeCallbacks) cb(msg.agentId);
359
- break;
360
- case "server.pong":
361
- break;
362
- case "server.task":
363
- this.options.log?.info?.(`[clawroom] received task ${msg.taskId}: ${msg.title}`);
364
- for (const cb of this.taskCallbacks) cb(msg);
365
- break;
366
- case "server.task_list":
367
- this.options.log?.info?.(`[clawroom] received ${msg.tasks.length} open task(s)`);
368
- for (const cb of this.taskListCallbacks) cb(msg.tasks);
369
- break;
370
- case "server.claim_ack":
371
- this.options.log?.info?.(`[clawroom] claim_ack taskId=${msg.taskId} ok=${msg.ok}${msg.reason ? ` reason=${msg.reason}` : ""}`);
372
- for (const cb of this.claimAckCallbacks) cb(msg);
373
- break;
374
- case "server.error":
375
- this.options.log?.error?.(`[clawroom] server error: ${msg.message}`);
376
- for (const cb of this.errorCallbacks) cb(msg);
377
- break;
378
- default:
379
- this.options.log?.warn?.("[clawroom] unknown message type", msg);
380
- }
381
- }
382
207
  }
@@ -12,24 +12,26 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024;
12
12
 
13
13
  /**
14
14
  * Wire up the task execution pipeline:
15
- * 1. On server.task / server.task_list -> store as available (no auto-claim)
15
+ * 1. On server.task -> store as available
16
16
  * 2. On claim_ack(ok) -> invoke subagent -> send result/fail
17
17
  *
18
- * Claiming is triggered externally (by the owner via Dashboard).
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.
19
20
  */
20
21
  export function setupTaskExecutor(opts: {
21
22
  client: ClawroomClient;
22
23
  runtime: PluginRuntime;
23
24
  log?: {
24
- info?: (...args: unknown[]) => void;
25
- warn?: (...args: unknown[]) => void;
26
- error?: (...args: unknown[]) => void;
25
+ info?: (message: string, ...args: unknown[]) => void;
26
+ warn?: (message: string, ...args: unknown[]) => void;
27
+ error?: (message: string, ...args: unknown[]) => void;
27
28
  };
28
29
  }): void {
29
30
  const { client, runtime, log } = opts;
30
31
 
31
- // Track received tasks (from broadcast or server push after claim)
32
+ // Track received tasks until they transition into an active execution.
32
33
  const knownTasks = new Map<string, ServerTask>();
34
+ const activeTasks = new Set<string>();
33
35
 
34
36
  // New task received — store it for potential execution
35
37
  client.onTask((task: ServerTask) => {
@@ -37,15 +39,6 @@ export function setupTaskExecutor(opts: {
37
39
  knownTasks.set(task.taskId, task);
38
40
  });
39
41
 
40
- // Task list on connect — store all
41
- client.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
42
  // Claim ack — either from plugin-initiated claim or Dashboard-initiated claim
50
43
  client.onClaimAck((ack: ServerClaimAck) => {
51
44
  if (!ack.ok) {
@@ -59,6 +52,11 @@ export function setupTaskExecutor(opts: {
59
52
  const task = knownTasks.get(ack.taskId);
60
53
  knownTasks.delete(ack.taskId);
61
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
+
62
60
  if (!task) {
63
61
  log?.warn?.(
64
62
  `[clawroom:executor] claim_ack for unknown task ${ack.taskId}`,
@@ -67,7 +65,10 @@ export function setupTaskExecutor(opts: {
67
65
  }
68
66
 
69
67
  log?.info?.(`[clawroom:executor] executing task ${task.taskId}`);
70
- void executeTask({ client, runtime, task, log });
68
+ activeTasks.add(task.taskId);
69
+ void executeTask({ client, runtime, task, log }).finally(() => {
70
+ activeTasks.delete(task.taskId);
71
+ });
71
72
  });
72
73
  }
73
74
 
@@ -80,8 +81,8 @@ async function executeTask(opts: {
80
81
  runtime: PluginRuntime;
81
82
  task: ServerTask;
82
83
  log?: {
83
- info?: (...args: unknown[]) => void;
84
- error?: (...args: unknown[]) => void;
84
+ info?: (message: string, ...args: unknown[]) => void;
85
+ error?: (message: string, ...args: unknown[]) => void;
85
86
  };
86
87
  }): Promise<void> {
87
88
  const { client, runtime, task, log } = opts;
@@ -97,7 +98,7 @@ async function executeTask(opts: {
97
98
  idempotencyKey: `clawroom:${task.taskId}`,
98
99
  message: agentMessage,
99
100
  extraSystemPrompt:
100
- "You are executing a task from the Claw Room marketplace. " +
101
+ "You are executing a task from the ClawRoom marketplace. " +
101
102
  "Complete the task and provide a SHORT summary (2-3 sentences) of what you did. " +
102
103
  "Do NOT include any local file paths, machine info, or internal details in your summary. " +
103
104
  "If you create output files, list their absolute paths at the very end, " +
@@ -170,7 +171,7 @@ async function executeTask(opts: {
170
171
  const reason = err instanceof Error ? err.message : String(err);
171
172
  log?.error?.(`[clawroom:executor] unexpected error for task ${task.taskId}: ${reason}`);
172
173
  client.send({
173
- type: "agent.release",
174
+ type: "agent.fail",
174
175
  taskId: task.taskId,
175
176
  reason,
176
177
  });
@@ -279,7 +280,10 @@ function tryParse(s: string): unknown {
279
280
  */
280
281
  function readAllFiles(
281
282
  paths: string[],
282
- log?: { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void },
283
+ log?: {
284
+ info?: (message: string, ...args: unknown[]) => void;
285
+ warn?: (message: string, ...args: unknown[]) => void;
286
+ },
283
287
  ): AgentResultFile[] {
284
288
  const results: AgentResultFile[] = [];
285
289
  for (const fp of paths) {