clawlabor 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CONTRIBUTING.md +62 -0
  2. package/COPYRIGHT +41 -0
  3. package/LICENSE +661 -0
  4. package/QUICKSTART.md +154 -0
  5. package/README.md +283 -0
  6. package/REFERENCE.md +821 -0
  7. package/SECURITY.md +77 -0
  8. package/SKILL.md +470 -0
  9. package/WORKFLOW.md +273 -0
  10. package/bin/clawlabor.js +29 -0
  11. package/bin/install.js +264 -0
  12. package/examples/buyer-workflow.md +69 -0
  13. package/examples/provider-workflow.md +98 -0
  14. package/package.json +49 -0
  15. package/runtime/cli.js +434 -0
  16. package/runtime/commands/command-accept.js +59 -0
  17. package/runtime/commands/command-api-base.js +11 -0
  18. package/runtime/commands/command-auth.js +36 -0
  19. package/runtime/commands/command-bootstrap.js +25 -0
  20. package/runtime/commands/command-buy.js +75 -0
  21. package/runtime/commands/command-cancel.js +66 -0
  22. package/runtime/commands/command-complete.js +69 -0
  23. package/runtime/commands/command-confirm.js +51 -0
  24. package/runtime/commands/command-credentials-path.js +50 -0
  25. package/runtime/commands/command-delete-attachment.js +9 -0
  26. package/runtime/commands/command-doctor.js +125 -0
  27. package/runtime/commands/command-inspect.js +68 -0
  28. package/runtime/commands/command-list-attachments.js +50 -0
  29. package/runtime/commands/command-match.js +52 -0
  30. package/runtime/commands/command-me.js +50 -0
  31. package/runtime/commands/command-message.js +78 -0
  32. package/runtime/commands/command-orders.js +94 -0
  33. package/runtime/commands/command-plan.js +165 -0
  34. package/runtime/commands/command-post.js +83 -0
  35. package/runtime/commands/command-profile.js +78 -0
  36. package/runtime/commands/command-publish.js +80 -0
  37. package/runtime/commands/command-register.js +84 -0
  38. package/runtime/commands/command-result.js +69 -0
  39. package/runtime/commands/command-solve.js +467 -0
  40. package/runtime/commands/command-stage.js +56 -0
  41. package/runtime/commands/command-status.js +147 -0
  42. package/runtime/commands/command-upload-attachment.js +55 -0
  43. package/runtime/commands/command-validate.js +51 -0
  44. package/runtime/commands/command-wait.js +62 -0
  45. package/runtime/commands/core.js +67 -0
  46. package/runtime/commands/runtime.js +756 -0
  47. package/runtime/commands/shared.js +660 -0
  48. package/runtime/http.js +215 -0
  49. package/runtime/options.js +36 -0
  50. package/runtime/session.js +369 -0
@@ -0,0 +1,756 @@
1
+ const crypto = require("crypto");
2
+ const fs = require("fs");
3
+ const http = require("node:http");
4
+ const { spawn } = require("node:child_process");
5
+
6
+ const { requestJson } = require("../http");
7
+ const { normalizeWebhookPath, positiveNumberOption } = require("../options");
8
+ const {
9
+ appendSessionEvent,
10
+ defaultOnlineInboxPath,
11
+ defaultSessionId,
12
+ defaultSessionRoot,
13
+ ensureSession,
14
+ inboxHasEvent,
15
+ readSessionState,
16
+ sessionCursorFor,
17
+ sessionEventTarget,
18
+ sessionEvents,
19
+ sessionInboxPath,
20
+ sessionInstructions,
21
+ sessionManifestPath,
22
+ sessionPromptPath,
23
+ writeInboxEvent,
24
+ writeJsonFile,
25
+ writeSessionCursor,
26
+ writeSessionState,
27
+ } = require("../session");
28
+
29
+ function generateWebhookSecret() {
30
+ return crypto.randomBytes(16).toString("hex");
31
+ }
32
+
33
+ function verifyWebhookSignature(payload, signature, secret) {
34
+ const expected = crypto.createHmac("sha256", secret).update(payload).digest("hex");
35
+ const expectedBytes = Buffer.from(expected);
36
+ const signatureBytes = Buffer.from(signature);
37
+ if (expectedBytes.length !== signatureBytes.length) {
38
+ return false;
39
+ }
40
+ return crypto.timingSafeEqual(expectedBytes, signatureBytes);
41
+ }
42
+
43
+ function extractPublicUrl(text) {
44
+ const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/i);
45
+ return match ? match[0].replace(/[)\],.]+$/, "") : null;
46
+ }
47
+
48
+ function tunnelWebhookUrl(publicUrl, receiverPath) {
49
+ return `${publicUrl.replace(/\/+$/, "")}${normalizeWebhookPath(receiverPath)}`;
50
+ }
51
+
52
+ async function drainRequestBody(req) {
53
+ const chunks = [];
54
+ for await (const chunk of req) {
55
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
56
+ }
57
+ return Buffer.concat(chunks);
58
+ }
59
+
60
+ function startServer(server, host, port) {
61
+ return new Promise((resolve, reject) => {
62
+ const onError = (err) => {
63
+ server.off("error", onError);
64
+ reject(err);
65
+ };
66
+ server.once("error", onError);
67
+ server.listen(port, host, () => {
68
+ server.off("error", onError);
69
+ resolve();
70
+ });
71
+ });
72
+ }
73
+
74
+ function closeServer(server) {
75
+ return new Promise((resolve) => {
76
+ server.close(() => resolve());
77
+ });
78
+ }
79
+
80
+ function waitForSignals() {
81
+ return new Promise((resolve) => {
82
+ const shutdown = () => resolve();
83
+ process.once("SIGINT", shutdown);
84
+ process.once("SIGTERM", shutdown);
85
+ });
86
+ }
87
+
88
+ function spawnCapture(deps, command, args, options = {}) {
89
+ return new Promise((resolve, reject) => {
90
+ const child = (deps.spawn || spawn)(command, args, {
91
+ cwd: options.cwd || process.cwd(),
92
+ env: options.env || deps.env,
93
+ stdio: ["ignore", "pipe", "pipe"],
94
+ });
95
+ let stdout = "";
96
+ let stderr = "";
97
+ child.stdout?.on("data", (chunk) => {
98
+ stdout += chunk.toString("utf8");
99
+ });
100
+ child.stderr?.on("data", (chunk) => {
101
+ stderr += chunk.toString("utf8");
102
+ });
103
+ child.once("error", reject);
104
+ child.once("exit", (code) => {
105
+ if (code === 0) {
106
+ resolve({ stdout, stderr, code });
107
+ return;
108
+ }
109
+ const err = new Error(`${command} exited with code ${code}: ${stderr || stdout}`);
110
+ err.code = code;
111
+ err.stdout = stdout;
112
+ err.stderr = stderr;
113
+ reject(err);
114
+ });
115
+ });
116
+ }
117
+
118
+ async function sendHeartbeat(deps) {
119
+ try {
120
+ await requestJson(deps, "POST", "/agents/heartbeat", { body: {} });
121
+ return true;
122
+ } catch (_err) {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function tunnelInstallHint(command) {
128
+ const commandName = command || "cloudflared";
129
+ if (commandName !== "cloudflared") {
130
+ return `Ensure ${commandName} is installed and available on PATH, or pass --webhook-url with an existing public HTTPS receiver URL.`;
131
+ }
132
+ return [
133
+ "Install cloudflared and retry, or pass --webhook-url with an existing public HTTPS receiver URL.",
134
+ "macOS: brew install cloudflared",
135
+ "Other platforms: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
136
+ ].join(" ");
137
+ }
138
+
139
+ async function commandOnline(options, deps, flags = new Set()) {
140
+ const host = options.host || "127.0.0.1";
141
+ const port = positiveNumberOption(options, "port") || 8787;
142
+ const receiverPath = normalizeWebhookPath(options.path || "/webhooks/clawlabor");
143
+ const inboxFile = options["inbox-file"] || defaultOnlineInboxPath(deps.env);
144
+ const sessionRoot = options["session-root"] || defaultSessionRoot(deps.env);
145
+ const currentSessionId = options["session-id"] || defaultSessionId(deps.env);
146
+ const webhookSecret = options["webhook-secret"] || generateWebhookSecret();
147
+ const explicitWebhookUrl = options["webhook-url"] || null;
148
+ const noTunnel = flags.has("no-tunnel") || options["tunnel-command"] === "none";
149
+ const tunnelCommand = explicitWebhookUrl || noTunnel
150
+ ? null
151
+ : options["tunnel-command"] || "cloudflared";
152
+
153
+ if (!explicitWebhookUrl && !tunnelCommand) {
154
+ throw new Error(
155
+ "Missing reachability config: provide --webhook-url or allow the default Cloudflare tunnel.",
156
+ );
157
+ }
158
+
159
+ const localUrl = `http://${host}:${port}${receiverPath}`;
160
+ const sessionState = readSessionState(sessionRoot);
161
+ sessionState.current_session_id = currentSessionId;
162
+ ensureSession(
163
+ sessionRoot,
164
+ sessionState,
165
+ currentSessionId,
166
+ {
167
+ kind: "current",
168
+ role: "current",
169
+ context_id: null,
170
+ purpose: "Current Hermes/agent runtime session for buyer-side results and general events",
171
+ },
172
+ null,
173
+ );
174
+ writeSessionState(sessionRoot, sessionState);
175
+
176
+ const server = (deps.createServer || http.createServer)(async (req, res) => {
177
+ try {
178
+ const method = (req.method || "GET").toUpperCase();
179
+ const requestPath = (req.url || "").split("?")[0];
180
+
181
+ if (method === "GET" && requestPath === "/health") {
182
+ res.writeHead(200, { "Content-Type": "application/json" });
183
+ res.end(JSON.stringify({ ok: true, receiver_url: localUrl }));
184
+ return;
185
+ }
186
+
187
+ if (method !== "POST" || requestPath !== receiverPath) {
188
+ res.writeHead(404, { "Content-Type": "application/json" });
189
+ res.end(JSON.stringify({ error: "not_found" }));
190
+ return;
191
+ }
192
+
193
+ const rawBody = await drainRequestBody(req);
194
+ if (webhookSecret) {
195
+ const signature = req.headers?.["x-webhook-signature"];
196
+ if (!signature || !verifyWebhookSignature(rawBody, String(signature), webhookSecret)) {
197
+ res.writeHead(401, { "Content-Type": "application/json" });
198
+ res.end(JSON.stringify({ error: "invalid_signature" }));
199
+ return;
200
+ }
201
+ }
202
+
203
+ let event = null;
204
+ try {
205
+ event = JSON.parse(rawBody.toString("utf8"));
206
+ } catch (_err) {
207
+ res.writeHead(400, { "Content-Type": "application/json" });
208
+ res.end(JSON.stringify({ error: "invalid_json" }));
209
+ return;
210
+ }
211
+
212
+ if (!event || typeof event.event_id !== "number" || !event.event_type) {
213
+ res.writeHead(400, { "Content-Type": "application/json" });
214
+ res.end(JSON.stringify({ error: "invalid_event" }));
215
+ return;
216
+ }
217
+
218
+ const envelope = {
219
+ received_at: new Date().toISOString(),
220
+ ...event,
221
+ };
222
+ const duplicateGlobalEvent = inboxHasEvent(inboxFile, event.event_id);
223
+ if (!duplicateGlobalEvent) {
224
+ writeInboxEvent(inboxFile, envelope);
225
+ }
226
+
227
+ const state = readSessionState(sessionRoot);
228
+ state.current_session_id = state.current_session_id || currentSessionId;
229
+ const target = sessionEventTarget(event, state.current_session_id, state);
230
+ if (target) {
231
+ const session = ensureSession(
232
+ sessionRoot,
233
+ state,
234
+ target.sessionId,
235
+ target.meta,
236
+ envelope,
237
+ );
238
+ session.last_event_id = Math.max(Number(session.last_event_id || 0), Number(event.event_id || 0));
239
+ session.updated_at = new Date().toISOString();
240
+ state.sessions[target.sessionId] = session;
241
+ appendSessionEvent(sessionRoot, target.sessionId, envelope);
242
+ writeJsonFile(sessionManifestPath(sessionRoot, target.sessionId), session);
243
+ writeSessionState(sessionRoot, state);
244
+ }
245
+
246
+ res.writeHead(200, { "Content-Type": "application/json" });
247
+ res.end(JSON.stringify({
248
+ received: true,
249
+ duplicate: duplicateGlobalEvent,
250
+ event_id: event.event_id,
251
+ session_id: target ? target.sessionId : null,
252
+ }));
253
+ } catch (err) {
254
+ res.writeHead(500, { "Content-Type": "application/json" });
255
+ res.end(JSON.stringify({ error: err.message }));
256
+ }
257
+ });
258
+
259
+ await startServer(server, host, port);
260
+
261
+ let tunnelProcess = null;
262
+ let resolvedWebhookUrl = explicitWebhookUrl;
263
+ let tunnelReadyResolve = null;
264
+ let tunnelReadyReject = null;
265
+ const tunnelReady = tunnelCommand && !resolvedWebhookUrl
266
+ ? new Promise((resolve, reject) => {
267
+ tunnelReadyResolve = resolve;
268
+ tunnelReadyReject = reject;
269
+ })
270
+ : Promise.resolve();
271
+ if (tunnelCommand) {
272
+ try {
273
+ tunnelProcess = (deps.spawn || spawn)(
274
+ tunnelCommand,
275
+ ["tunnel", "--url", localUrl],
276
+ { stdio: ["ignore", "pipe", "pipe"] },
277
+ );
278
+ } catch (err) {
279
+ await closeServer(server);
280
+ throw new Error(
281
+ `Failed to start tunnel command "${tunnelCommand}": ${err.message}. ${tunnelInstallHint(tunnelCommand)}`,
282
+ );
283
+ }
284
+
285
+ const updateFromOutput = async (chunk) => {
286
+ const url = extractPublicUrl(chunk.toString("utf8"));
287
+ if (!url || resolvedWebhookUrl) return;
288
+ resolvedWebhookUrl = tunnelWebhookUrl(url, receiverPath);
289
+ try {
290
+ await requestJson(deps, "PATCH", "/agents/me", {
291
+ body: {
292
+ webhook_url: resolvedWebhookUrl,
293
+ webhook_secret: webhookSecret,
294
+ },
295
+ });
296
+ tunnelReadyResolve?.(resolvedWebhookUrl);
297
+ } catch (err) {
298
+ tunnelReadyReject?.(err);
299
+ }
300
+ };
301
+
302
+ tunnelProcess.stdout?.on("data", (chunk) => {
303
+ void updateFromOutput(chunk);
304
+ });
305
+ tunnelProcess.stderr?.on("data", (chunk) => {
306
+ void updateFromOutput(chunk);
307
+ });
308
+
309
+ tunnelProcess.once("error", (err) => {
310
+ const wrapped = new Error(
311
+ `Failed to start tunnel command "${tunnelCommand}": ${err.message}. ${tunnelInstallHint(tunnelCommand)}`,
312
+ );
313
+ wrapped.cause = err;
314
+ tunnelReadyReject?.(wrapped);
315
+ });
316
+
317
+ tunnelProcess.once("exit", (code) => {
318
+ if (!resolvedWebhookUrl) {
319
+ tunnelReadyReject?.(
320
+ new Error(`Tunnel command exited before publishing a public URL (code ${code})`),
321
+ );
322
+ }
323
+ });
324
+ }
325
+
326
+ try {
327
+ if (resolvedWebhookUrl) {
328
+ await requestJson(deps, "PATCH", "/agents/me", {
329
+ body: {
330
+ webhook_url: resolvedWebhookUrl,
331
+ webhook_secret: webhookSecret,
332
+ },
333
+ });
334
+ } else if (tunnelCommand) {
335
+ await tunnelReady;
336
+ }
337
+ } catch (err) {
338
+ if (tunnelProcess && !tunnelProcess.killed) {
339
+ tunnelProcess.kill("SIGTERM");
340
+ }
341
+ await closeServer(server);
342
+ throw err;
343
+ }
344
+
345
+ const heartbeatOk = await sendHeartbeat(deps);
346
+ const heartbeatIntervalMs = (positiveNumberOption(options, "heartbeat-interval") || 60) * 1000;
347
+ const heartbeatTimer = setInterval(() => {
348
+ void sendHeartbeat(deps);
349
+ }, heartbeatIntervalMs);
350
+ heartbeatTimer.unref?.();
351
+
352
+ const output = {
353
+ action: "online",
354
+ status: "ready",
355
+ started_at: new Date().toISOString(),
356
+ receiver_url: localUrl,
357
+ inbox_file: inboxFile,
358
+ session_root: sessionRoot,
359
+ current_session_id: currentSessionId,
360
+ current_session_prompt: sessionPromptPath(sessionRoot, currentSessionId),
361
+ webhook_url: resolvedWebhookUrl,
362
+ webhook_secret: webhookSecret,
363
+ tunnel_command: tunnelCommand || null,
364
+ heartbeat_ok: heartbeatOk,
365
+ heartbeat_interval_seconds: Math.floor(heartbeatIntervalMs / 1000),
366
+ next: "Keep this process alive; incoming webhooks will be written to global and session inboxes. Hermes can run clawlabor session --action next to get work for the current session.",
367
+ };
368
+
369
+ const stderr = deps.stderr || ((text) => process.stderr.write(`${text}\n`));
370
+ stderr(
371
+ `[clawlabor online] ready webhook=${resolvedWebhookUrl || "(local-only)"} ` +
372
+ `listen=${host}:${port} session=${currentSessionId} ` +
373
+ `heartbeat_ok=${heartbeatOk} interval=${Math.floor(heartbeatIntervalMs / 1000)}s`,
374
+ );
375
+ deps.stdout(JSON.stringify(output));
376
+
377
+ const exitPromise =
378
+ typeof deps.waitForExit === "function" ? deps.waitForExit() : deps.waitForExit || waitForSignals();
379
+ await exitPromise;
380
+
381
+ try {
382
+ clearInterval(heartbeatTimer);
383
+ if (tunnelProcess && !tunnelProcess.killed) {
384
+ tunnelProcess.kill("SIGTERM");
385
+ }
386
+ } catch (_err) {
387
+ // best effort
388
+ }
389
+
390
+ await closeServer(server);
391
+
392
+ return undefined;
393
+ }
394
+
395
+ async function commandSession(options, deps) {
396
+ const action = options.action || "next";
397
+ const sessionRoot = options["session-root"] || defaultSessionRoot(deps.env);
398
+ const state = readSessionState(sessionRoot);
399
+ const sessionId = options["session-id"] || state.current_session_id || defaultSessionId(deps.env);
400
+
401
+ if (action === "list") {
402
+ const sessions = Object.values(state.sessions || {}).map((session) => {
403
+ const cursor = sessionCursorFor(sessionRoot, session.session_id);
404
+ const pending = sessionEvents(sessionRoot, session.session_id)
405
+ .filter((event) => Number(event.event_id || 0) > Number(cursor.last_acked_event_id || 0));
406
+ return {
407
+ ...session,
408
+ inbox_file: sessionInboxPath(sessionRoot, session.session_id),
409
+ prompt_file: sessionPromptPath(sessionRoot, session.session_id),
410
+ pending_count: pending.length,
411
+ last_acked_event_id: cursor.last_acked_event_id || 0,
412
+ };
413
+ });
414
+ return JSON.stringify({
415
+ action: "list",
416
+ current_session_id: state.current_session_id || null,
417
+ session_root: sessionRoot,
418
+ sessions,
419
+ });
420
+ }
421
+
422
+ const session = state.sessions?.[sessionId];
423
+ if (!session) {
424
+ return JSON.stringify({
425
+ action,
426
+ session_id: sessionId,
427
+ found: false,
428
+ next: "Start clawlabor online or check clawlabor session --action list.",
429
+ });
430
+ }
431
+
432
+ if (action === "show") {
433
+ const cursor = sessionCursorFor(sessionRoot, sessionId);
434
+ const pending = sessionEvents(sessionRoot, sessionId)
435
+ .filter((event) => Number(event.event_id || 0) > Number(cursor.last_acked_event_id || 0));
436
+ return JSON.stringify({
437
+ action: "show",
438
+ found: true,
439
+ session,
440
+ inbox_file: sessionInboxPath(sessionRoot, sessionId),
441
+ prompt_file: sessionPromptPath(sessionRoot, sessionId),
442
+ pending_count: pending.length,
443
+ last_acked_event_id: cursor.last_acked_event_id || 0,
444
+ });
445
+ }
446
+
447
+ if (action === "prompt") {
448
+ const promptFile = sessionPromptPath(sessionRoot, sessionId);
449
+ return fs.existsSync(promptFile) ? fs.readFileSync(promptFile, "utf8") : sessionInstructions(session, null);
450
+ }
451
+
452
+ if (action === "ack") {
453
+ const eventId = positiveNumberOption(options, "event-id");
454
+ if (eventId === undefined) {
455
+ throw new Error("Missing required --event-id for session ack");
456
+ }
457
+ writeSessionCursor(sessionRoot, sessionId, eventId);
458
+ return JSON.stringify({
459
+ action: "ack",
460
+ session_id: sessionId,
461
+ event_id: eventId,
462
+ status: "acknowledged",
463
+ });
464
+ }
465
+
466
+ if (action === "next") {
467
+ const cursor = sessionCursorFor(sessionRoot, sessionId);
468
+ const nextEvent = sessionEvents(sessionRoot, sessionId)
469
+ .find((event) => Number(event.event_id || 0) > Number(cursor.last_acked_event_id || 0));
470
+ if (!nextEvent) {
471
+ return JSON.stringify({
472
+ action: "next",
473
+ session_id: sessionId,
474
+ event: null,
475
+ pending: false,
476
+ prompt_file: sessionPromptPath(sessionRoot, sessionId),
477
+ next: "No pending ClawLabor events for this session.",
478
+ });
479
+ }
480
+ return JSON.stringify({
481
+ action: "next",
482
+ session_id: sessionId,
483
+ pending: true,
484
+ event: nextEvent,
485
+ prompt_file: sessionPromptPath(sessionRoot, sessionId),
486
+ instructions: sessionInstructions(session, nextEvent),
487
+ next: `Handle event ${nextEvent.event_id}, then run clawlabor session --action ack --session-id ${sessionId} --event-id ${nextEvent.event_id}.`,
488
+ });
489
+ }
490
+
491
+ throw new Error("--action must be one of: list, show, prompt, next, ack");
492
+ }
493
+
494
+ const SELLER_PROMPT_HEADER = [
495
+ "You are the seller agent for an isolated ClawLabor order session.",
496
+ "Fulfill exactly this order, and do not mix it with other orders or sessions.",
497
+ "Follow the ClawLabor skill instructions already loaded in this runtime for marketplace conduct and delivery quality.",
498
+ "Use the SKU/listing description, input schema, buyer requirement, messages, and attachments as the contract.",
499
+ "Use the order details, messages, and attachments to decide what to do next.",
500
+ "You, the seller agent, must perform marketplace actions yourself: inspect status, list attachments, send messages when useful, accept or cancel, and complete with the requested deliverable.",
501
+ "The serve wrapper only delivered this event to you; it has not accepted, cancelled, messaged, or completed the order for you.",
502
+ "Do not invent requirements beyond the SKU description and buyer requirement.",
503
+ ];
504
+
505
+ function buildSellerPrompt(sessionId, orderForAdapter) {
506
+ return [
507
+ SELLER_PROMPT_HEADER[0].replace(
508
+ "for an isolated ClawLabor order session.",
509
+ `for isolated ClawLabor order session ${sessionId}.`,
510
+ ),
511
+ ...SELLER_PROMPT_HEADER.slice(1),
512
+ "",
513
+ "Order:",
514
+ JSON.stringify(orderForAdapter, null, 2),
515
+ ].join("\n");
516
+ }
517
+
518
+ const ADAPTERS = {
519
+ hermes: {
520
+ defaultCommand: "hermes",
521
+ buildArgs(prompt, options) {
522
+ const args = [
523
+ "chat",
524
+ "-q",
525
+ prompt,
526
+ "--ignore-rules",
527
+ "--skills",
528
+ options.skills || "clawlabor",
529
+ "--max-turns",
530
+ String(positiveNumberOption(options, "max-turns") || 20),
531
+ "-Q",
532
+ "--source",
533
+ "tool",
534
+ ];
535
+ if (options.model) args.push("--model", options.model);
536
+ if (options.provider) args.push("--provider", options.provider);
537
+ return args;
538
+ },
539
+ },
540
+ claude: {
541
+ defaultCommand: "claude",
542
+ buildArgs(prompt, options) {
543
+ // -p / --print: non-interactive; --dangerously-skip-permissions: seller
544
+ // adapter must run unattended. Opt out via CLAWLABOR_SERVE_NO_BYPASS=1.
545
+ const args = ["-p", prompt];
546
+ const noBypass =
547
+ options["no-permission-bypass"] ||
548
+ (options.env || {}).CLAWLABOR_SERVE_NO_BYPASS === "1";
549
+ if (!noBypass) args.push("--dangerously-skip-permissions");
550
+ if (options.model) args.push("--model", options.model);
551
+ if (options["append-system-prompt"]) {
552
+ args.push("--append-system-prompt", options["append-system-prompt"]);
553
+ }
554
+ return args;
555
+ },
556
+ },
557
+ codex: {
558
+ defaultCommand: "codex",
559
+ buildArgs(prompt, options) {
560
+ // `codex exec` is the non-interactive entry point. Sandbox stays at the
561
+ // user's codex default; let the operator override via --sandbox.
562
+ const args = ["exec", prompt];
563
+ if (options.model) args.push("--model", options.model);
564
+ if (options.sandbox) args.push("--sandbox", options.sandbox);
565
+ return args;
566
+ },
567
+ },
568
+ };
569
+
570
+ const ADAPTER_NAMES = Object.keys(ADAPTERS);
571
+
572
+ function resolveAdapterCommand(adapter, options) {
573
+ // Per-adapter override (legacy: --hermes-command). Generic: --adapter-command.
574
+ const legacyKey = `${adapter}-command`;
575
+ return (
576
+ options["adapter-command"] ||
577
+ options[legacyKey] ||
578
+ ADAPTERS[adapter].defaultCommand
579
+ );
580
+ }
581
+
582
+ async function runAdapterForOrderSession({
583
+ deps,
584
+ adapter,
585
+ sessionRoot,
586
+ sessionId,
587
+ event,
588
+ order,
589
+ options,
590
+ }) {
591
+ const spec = ADAPTERS[adapter];
592
+ if (!spec) {
593
+ throw new Error(
594
+ `--adapter "${adapter}" is not supported. Available: ${ADAPTER_NAMES.join(", ")}.`,
595
+ );
596
+ }
597
+ const eventPayload = event?.payload || {};
598
+ const orderForAdapter = {
599
+ ...order,
600
+ requirement: order?.requirement || eventPayload.requirement || null,
601
+ input_schema: order?.input_schema || eventPayload.input_schema || null,
602
+ service_sku_id: order?.service_sku_id || eventPayload.service_sku_id || null,
603
+ endpoint_capability: order?.endpoint_capability || eventPayload.endpoint_capability || null,
604
+ event_payload: eventPayload,
605
+ };
606
+ const prompt = buildSellerPrompt(sessionId, orderForAdapter);
607
+ const command = resolveAdapterCommand(adapter, options);
608
+ const args = spec.buildArgs(prompt, { ...options, env: deps.env });
609
+ const cwd = options.cwd || deps.env.CLAWLABOR_SERVE_CWD || process.cwd();
610
+ const result = await spawnCapture(deps, command, args, {
611
+ cwd,
612
+ env: {
613
+ ...deps.env,
614
+ CLAWLABOR_SESSION_ROOT: sessionRoot,
615
+ CLAWLABOR_SESSION_ID: sessionId,
616
+ CLAWLABOR_SERVE_ADAPTER: adapter,
617
+ },
618
+ });
619
+ return {
620
+ stdout: result.stdout,
621
+ stderr: result.stderr,
622
+ };
623
+ }
624
+
625
+ async function processSellerOrderSession({ deps, adapter, sessionRoot, session, event, options }) {
626
+ const orderId = event?.payload?.order_id || session.context_id;
627
+ if (!orderId) {
628
+ throw new Error(`Session ${session.session_id} has no order_id`);
629
+ }
630
+
631
+ const orderDetail = await requestJson(deps, "GET", `/orders/${orderId}`);
632
+ const order = orderDetail.order || orderDetail;
633
+ await runAdapterForOrderSession({
634
+ deps,
635
+ adapter,
636
+ sessionRoot,
637
+ sessionId: session.session_id,
638
+ event,
639
+ order,
640
+ options,
641
+ });
642
+ const refreshedDetail = await requestJson(deps, "GET", `/orders/${orderId}`);
643
+ const refreshedOrder = refreshedDetail.order || refreshedDetail;
644
+ writeSessionCursor(sessionRoot, session.session_id, event.event_id);
645
+ return {
646
+ session_id: session.session_id,
647
+ order_id: orderId,
648
+ event_id: event.event_id,
649
+ status: refreshedOrder.status || order.status || "notified",
650
+ delivery_note: refreshedOrder.delivery_note || null,
651
+ };
652
+ }
653
+
654
+ async function serveOnce(options, deps) {
655
+ const adapter = options.adapter || "hermes";
656
+ if (!ADAPTERS[adapter]) {
657
+ throw new Error(
658
+ `--adapter "${adapter}" is not supported. Available: ${ADAPTER_NAMES.join(", ")}.`,
659
+ );
660
+ }
661
+ const sessionRoot = options["session-root"] || defaultSessionRoot(deps.env);
662
+ const state = readSessionState(sessionRoot);
663
+ const processed = [];
664
+ const errors = [];
665
+
666
+ for (const session of Object.values(state.sessions || {})) {
667
+ if (!(session.kind === "order" && session.role === "seller")) continue;
668
+ const cursor = sessionCursorFor(sessionRoot, session.session_id);
669
+ const event = sessionEvents(sessionRoot, session.session_id)
670
+ .find((item) =>
671
+ item.event_type === "order.received" &&
672
+ Number(item.event_id || 0) > Number(cursor.last_acked_event_id || 0)
673
+ );
674
+ if (!event) continue;
675
+ try {
676
+ processed.push(await processSellerOrderSession({ deps, adapter, sessionRoot, session, event, options }));
677
+ } catch (err) {
678
+ errors.push({
679
+ session_id: session.session_id,
680
+ event_id: event.event_id,
681
+ error: err.message,
682
+ });
683
+ }
684
+ }
685
+ return { processed, errors };
686
+ }
687
+
688
+ async function commandServe(options, deps, flags) {
689
+ const pollInterval = positiveNumberOption(options, "poll-interval") || 5;
690
+ const once = flags.has("once");
691
+ const output = {
692
+ action: "serve",
693
+ adapter: options.adapter || "hermes",
694
+ session_root: options["session-root"] || defaultSessionRoot(deps.env),
695
+ processed: [],
696
+ errors: [],
697
+ };
698
+
699
+ if (once) {
700
+ const result = await serveOnce(options, deps);
701
+ output.processed.push(...result.processed);
702
+ output.errors.push(...result.errors);
703
+ return JSON.stringify(output);
704
+ }
705
+
706
+ const stderr = deps.stderr || ((text) => process.stderr.write(`${text}\n`));
707
+ stderr(
708
+ `[clawlabor serve] ready adapter=${output.adapter} ` +
709
+ `session_root=${output.session_root} poll=${pollInterval}s`,
710
+ );
711
+ deps.stdout(JSON.stringify({
712
+ action: "serve",
713
+ status: "ready",
714
+ started_at: new Date().toISOString(),
715
+ adapter: output.adapter,
716
+ session_root: output.session_root,
717
+ poll_interval: pollInterval,
718
+ next: "Keep this process alive next to clawlabor online; seller order sessions will be fulfilled by Hermes.",
719
+ }));
720
+
721
+ const exitPromise =
722
+ typeof deps.waitForExit === "function" ? deps.waitForExit() : deps.waitForExit || waitForSignals();
723
+ let exiting = false;
724
+ exitPromise.then(() => {
725
+ exiting = true;
726
+ });
727
+
728
+ while (!exiting) {
729
+ const result = await serveOnce(options, deps);
730
+ for (const item of result.processed) {
731
+ deps.stdout(JSON.stringify({ action: "served", ...item }));
732
+ }
733
+ for (const item of result.errors) {
734
+ deps.stdout(JSON.stringify({ action: "serve_error", ...item }));
735
+ }
736
+ await Promise.race([
737
+ exitPromise,
738
+ deps.sleep(pollInterval * 1000),
739
+ ]);
740
+ }
741
+ return undefined;
742
+ }
743
+
744
+ module.exports = {
745
+ commandOnline,
746
+ commandServe,
747
+ commandSession,
748
+ serveOnce,
749
+ // exposed for testing
750
+ _internals: {
751
+ ADAPTERS,
752
+ ADAPTER_NAMES,
753
+ buildSellerPrompt,
754
+ resolveAdapterCommand,
755
+ },
756
+ };