clawlabor 1.11.3 → 1.14.13

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.
@@ -0,0 +1,1886 @@
1
+ // Labor mode commands: hire a worker, serve a worker, chat with a hire.
2
+ //
3
+ // Unit convention: labor is sold BY THE DAY. The API schema is day-facing and
4
+ // converts to seconds at its service/DB boundary. See docs/2026-06-16-labor-technical-solution.md.
5
+ const { spawnSync } = require("node:child_process");
6
+ const {
7
+ resolveClaudeCodeAccount,
8
+ resolveClaudeCodeOauthToken,
9
+ } = require("../claude_auth");
10
+ const { apiBase, envWithApiKey, request, requestJson, resolveApiKey } = require("../http");
11
+ const { numberOption, positiveNumberOption, requiredOption, tokenCountOption } = require("../options");
12
+ const {
13
+ dockerContainerState,
14
+ dockerListHireContainers,
15
+ dockerListHireStateVolumes,
16
+ dockerName,
17
+ removeContainerByName,
18
+ dockerRemoveVolume,
19
+ dockerVolumeExists,
20
+ ensureDockerImage,
21
+ forceKillProcess,
22
+ hireIdFromContainerName,
23
+ hireIdFromVolumeName,
24
+ hireStateVolumeName,
25
+ removeContainerByNameAsync,
26
+ restartContainerByName,
27
+ runtimeStateInitCommand,
28
+ runtimeStateMounts,
29
+ sandboxUserCommand,
30
+ shellQuote,
31
+ startSandboxContainer,
32
+ startContainerByName,
33
+ stopContainerByName,
34
+ terminateChild,
35
+ terminateProcessGroup,
36
+ } = require("./labor-sandbox");
37
+ const {
38
+ TUNNEL_AVAILABILITY_TIMEOUT_MS,
39
+ createSandboxHealthProbe,
40
+ createTunnelAvailabilityState,
41
+ formatTunnelUnavailableWarning,
42
+ startCloudflareTunnel,
43
+ tunnelAvailabilityTimeoutSeconds,
44
+ } = require("./labor-tunnel");
45
+
46
+ const LABOR_STATUSES = new Set(["draft", "available", "occupied", "inactive", "all"]);
47
+ const ACTIVE_LABOR_RESOURCE_STATUSES = new Set(["draft", "available", "occupied"]);
48
+ const DEFAULT_DAILY_RATE_UAT = 50;
49
+ const PLAN_MONTHLY_COST_UAT = {
50
+ pro: 40 * 10, // $40/month = 400 UAT/month
51
+ business: 50 * 10, // $50/month = 500 UAT/month
52
+ team: 50 * 10, // $50/month = 500 UAT/month
53
+ enterprise: 200 * 10, // $200/month = 2000 UAT/month
54
+ };
55
+ // Default per-day raw totalTokens cap suggested for opencode labors.
56
+ // Enforcement is currently opencode-only (see docs/spec/2026-06-23-labor-daily-token-cap-spec.md);
57
+ // other runtimes have no per-prompt usage feed, so we don't suggest a cap for them.
58
+ const DEFAULT_DAILY_TOKEN_CAP = 1_000_000; // 1M tokens/day
59
+ const LABOR_CONTROL_TIMEOUT_MS = 10_000;
60
+ const SANDBOX_STARTUP_TIMEOUT_MS = 180_000;
61
+ const DEFAULT_SANDBOX_IMAGE = "ryanxdocker/sandbox-clawlabor:0.4.4";
62
+ const DEFAULT_GATEKEEPER_PROMPT = "Accept only safe, legal, well-scoped requests that can be completed by this local agent. Refuse requests requiring private credentials, illegal activity, or work outside the published description.";
63
+ const MAX_TUNNEL_RESTART_ATTEMPTS = 3;
64
+ const NANO_FACTOR = 1e9;
65
+ const CLAUDE_CODE_INSTALL_HINT = "Install Claude Code CLI, not Claude Desktop. See https://docs.anthropic.com/en/docs/claude-code/quickstart or run `npm install -g @anthropic-ai/claude-code`, then run `claude auth login`.";
66
+
67
+ function formatLogTimestamp(now = Date.now) {
68
+ const parts = new Intl.DateTimeFormat(undefined, {
69
+ year: "numeric",
70
+ month: "2-digit",
71
+ day: "2-digit",
72
+ hour: "2-digit",
73
+ minute: "2-digit",
74
+ second: "2-digit",
75
+ hourCycle: "h23",
76
+ timeZoneName: "shortOffset",
77
+ }).formatToParts(new Date(now()));
78
+ const valueByType = Object.fromEntries(
79
+ parts
80
+ .filter((part) => part.type !== "literal")
81
+ .map((part) => [part.type, part.value]),
82
+ );
83
+ const offset = formatLogTimezoneOffset(valueByType.timeZoneName);
84
+ return `${valueByType.year}-${valueByType.month}-${valueByType.day} ${valueByType.hour}:${valueByType.minute}:${valueByType.second} ${offset}`;
85
+ }
86
+
87
+ function formatLogTimezoneOffset(timeZoneName) {
88
+ if (!timeZoneName || timeZoneName === "GMT") return "GMT+00:00";
89
+ const match = /^GMT([+-])(\d{1,2})(?::?(\d{2}))?$/.exec(timeZoneName);
90
+ if (!match) return timeZoneName;
91
+ const [, sign, hour, minute = "00"] = match;
92
+ return `GMT${sign}${hour.padStart(2, "0")}:${minute}`;
93
+ }
94
+
95
+ function createTimestampedStdout(stdout, now = Date.now) {
96
+ const write = stdout || (() => {});
97
+ return (text) => {
98
+ const timestamp = formatLogTimestamp(now);
99
+ const linePrefix = `[${timestamp}] `;
100
+ const formatted = String(text)
101
+ .split("\n")
102
+ .map((line) => (line ? `${linePrefix}${line}` : line))
103
+ .join("\n");
104
+ write(formatted);
105
+ };
106
+ }
107
+
108
+ function processAlive(pid) {
109
+ if (!pid || Number.isNaN(pid)) return false;
110
+ try {
111
+ process.kill(pid, 0);
112
+ return true;
113
+ } catch (_err) {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ function laborServeLockPath(deps, port) {
119
+ const path = require("path");
120
+ const os = require("os");
121
+ const base = (deps.env && deps.env.XDG_STATE_HOME) ||
122
+ path.join(os.homedir(), ".local", "state");
123
+ return path.join(base, "clawlabor", `labor-serve-port-${port}.lock`);
124
+ }
125
+
126
+ function acquireLaborServeLock(deps, { runtime, laborId, port }) {
127
+ const fs = require("fs");
128
+ const path = require("path");
129
+ const lockPath = laborServeLockPath(deps, port);
130
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
131
+ try {
132
+ const raw = fs.readFileSync(lockPath, "utf8");
133
+ const existing = JSON.parse(raw);
134
+ if (processAlive(Number(existing.pid))) {
135
+ throw new Error(
136
+ `Another clawlabor labor-serve is already using local port ${existing.port || port} ` +
137
+ `(pid ${existing.pid}, runtime ${existing.runtime || "unknown"}, labor ${existing.labor_id || "unknown"}). ` +
138
+ "Stop that process before starting another one, or choose a different --port.",
139
+ );
140
+ }
141
+ } catch (err) {
142
+ if (err && err.code !== "ENOENT" && !(err instanceof SyntaxError)) throw err;
143
+ }
144
+ fs.writeFileSync(lockPath, JSON.stringify({
145
+ pid: process.pid,
146
+ labor_id: laborId,
147
+ runtime,
148
+ port,
149
+ started_at: new Date().toISOString(),
150
+ }));
151
+ return () => {
152
+ try {
153
+ const raw = fs.readFileSync(lockPath, "utf8");
154
+ const current = JSON.parse(raw);
155
+ if (Number(current.pid) === process.pid) fs.unlinkSync(lockPath);
156
+ } catch (_err) {
157
+ /* noop */
158
+ }
159
+ };
160
+ }
161
+
162
+ async function withTimeout(promise, ms, label) {
163
+ let timer;
164
+ try {
165
+ return await Promise.race([
166
+ promise,
167
+ new Promise((_, reject) => {
168
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
169
+ }),
170
+ ]);
171
+ } finally {
172
+ if (timer) clearTimeout(timer);
173
+ }
174
+ }
175
+
176
+ function opencodeAuthPath(env) {
177
+ const path = require("path");
178
+ const os = require("os");
179
+ const base = (env && env.XDG_DATA_HOME) || path.join((env && env.HOME) || os.homedir(), ".local", "share");
180
+ return path.join(base, "opencode", "auth.json");
181
+ }
182
+
183
+ function codexHomePath(env) {
184
+ const path = require("path");
185
+ const os = require("os");
186
+ return (env && env.CODEX_HOME) || path.join((env && env.HOME) || os.homedir(), ".codex");
187
+ }
188
+
189
+ function codexAuthPath(env) {
190
+ const path = require("path");
191
+ return path.join(codexHomePath(env), "auth.json");
192
+ }
193
+
194
+ function codexConfigPath(env) {
195
+ const path = require("path");
196
+ return path.join(codexHomePath(env), "config.toml");
197
+ }
198
+
199
+ function decodeJwtPayload(token) {
200
+ if (!token || typeof token !== "string") return null;
201
+ const parts = token.split(".");
202
+ if (parts.length < 2 || !parts[1]) return null;
203
+ try {
204
+ const normalized = parts[1].replace(/-/g, "+").replace(/_/g, "/");
205
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
206
+ return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
207
+ } catch (_err) {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ function codexAuthClaimFromToken(token) {
213
+ const claims = decodeJwtPayload(token);
214
+ if (!claims || typeof claims !== "object") return null;
215
+ const auth = claims["https://api.openai.com/auth"];
216
+ return auth && typeof auth === "object" ? auth : null;
217
+ }
218
+
219
+ function displayCodexPlan(rawPlan) {
220
+ if (!rawPlan) return null;
221
+ return String(rawPlan).toLowerCase() === "team" ? "business" : String(rawPlan).toLowerCase();
222
+ }
223
+
224
+ function displayCodexLabel(rawPlan) {
225
+ const plan = displayCodexPlan(rawPlan);
226
+ if (!plan) return "ChatGPT";
227
+ return `ChatGPT ${plan.charAt(0).toUpperCase()}${plan.slice(1)}`;
228
+ }
229
+
230
+ function resolveCodexChatGptAccount(deps) {
231
+ const fs = deps.fs || require("fs");
232
+ const authPath = codexAuthPath(deps.env);
233
+ if (!fs.existsSync(authPath) || typeof fs.readFileSync !== "function") {
234
+ return {
235
+ provider: "codex",
236
+ logged_in: false,
237
+ status: "auth_not_found",
238
+ auth_path: authPath,
239
+ };
240
+ }
241
+ try {
242
+ const authJson = JSON.parse(fs.readFileSync(authPath, "utf8"));
243
+ if (authJson.auth_mode !== "chatgpt") {
244
+ return {
245
+ provider: "codex",
246
+ logged_in: false,
247
+ status: authJson.auth_mode === "api" ? "api_key_auth" : "not_chatgpt_auth",
248
+ auth_mode: authJson.auth_mode || null,
249
+ };
250
+ }
251
+ const authClaim =
252
+ codexAuthClaimFromToken(authJson.tokens && authJson.tokens.id_token) ||
253
+ codexAuthClaimFromToken(authJson.tokens && authJson.tokens.access_token);
254
+ if (!authClaim) {
255
+ return {
256
+ provider: "codex",
257
+ logged_in: false,
258
+ status: "missing_chatgpt_claim",
259
+ auth_mode: "chatgpt",
260
+ };
261
+ }
262
+ return {
263
+ provider: "codex",
264
+ logged_in: true,
265
+ source: "local_jwt_claim",
266
+ auth_mode: "chatgpt",
267
+ plan: displayCodexPlan(authClaim.chatgpt_plan_type),
268
+ label: displayCodexLabel(authClaim.chatgpt_plan_type),
269
+ subscription_active_start: authClaim.chatgpt_subscription_active_start || null,
270
+ subscription_active_until: authClaim.chatgpt_subscription_active_until || null,
271
+ subscription_last_checked: authClaim.chatgpt_subscription_last_checked || null,
272
+ };
273
+ } catch (err) {
274
+ return {
275
+ provider: "codex",
276
+ logged_in: false,
277
+ status: "auth_read_failed",
278
+ auth_path: authPath,
279
+ error: err.message,
280
+ };
281
+ }
282
+ }
283
+
284
+ // What to inject into the per-hire `docker run` so the runtime can authenticate.
285
+ // Returns { env: {NAME: value}, mounts: [{host, container, ro}] }. Throws a clear
286
+ // error if the runtime's local credentials are missing. Never reads secret content.
287
+ async function resolveRuntimeSandboxCredentials(runtime, deps) {
288
+ if (runtime === "claude") {
289
+ const claudeOauth = await resolveClaudeCodeOauthToken(deps);
290
+ if (!claudeOauth.token) {
291
+ const authHint = claudeOauth.authStatusOk
292
+ ? "Claude Code is logged in, but the local claude.ai OAuth access token is missing or expired. Run `claude setup-token`, then retry `clawlabor labor-start --runtime claude`."
293
+ : "Run `claude auth status` and make sure it shows authMethod claude.ai with an active subscription.";
294
+ throw new Error(`labor-serve requires a working local Claude Code claude.ai subscription login. ${authHint}`);
295
+ }
296
+ return { env: { CLAUDE_CODE_OAUTH_TOKEN: claudeOauth.token }, mounts: [] };
297
+ }
298
+ if (runtime === "opencode") {
299
+ const fs = deps.fs || require("fs");
300
+ const authPath = opencodeAuthPath(deps.env);
301
+ if (!fs.existsSync(authPath)) {
302
+ throw new Error(`labor-serve --runtime opencode needs local OpenCode credentials at ${authPath}. Run \`opencode auth login\` first.`);
303
+ }
304
+ return {
305
+ env: {},
306
+ mounts: [{ host: authPath, container: "/home/sandbox/.local/share/opencode/auth.json", ro: true }],
307
+ };
308
+ }
309
+ if (runtime === "codex") {
310
+ const fs = deps.fs || require("fs");
311
+ const authPath = codexAuthPath(deps.env);
312
+ if (!fs.existsSync(authPath)) {
313
+ throw new Error(`labor-serve --runtime codex needs local Codex credentials at ${authPath}. Run \`codex login\` first.`);
314
+ }
315
+ const configPath = codexConfigPath(deps.env);
316
+ if (!fs.existsSync(configPath)) {
317
+ throw new Error(`labor-serve --runtime codex needs local Codex config at ${configPath}. Run \`codex login\` first, then verify \`codex --version\` works.`);
318
+ }
319
+ return {
320
+ env: {},
321
+ mounts: [
322
+ { host: authPath, container: "/home/sandbox/.codex/auth.json", ro: true },
323
+ { host: configPath, container: "/home/sandbox/.codex/config.toml", ro: true },
324
+ ],
325
+ };
326
+ }
327
+ throw new Error(`labor-serve does not support --runtime ${runtime}`);
328
+ }
329
+
330
+ function commandProbe(deps, command, args = ["--version"]) {
331
+ const run = deps.spawnSync || spawnSync;
332
+ const pathResult = run("sh", ["-c", 'command -v "$1"', "sh", command], {
333
+ encoding: "utf8",
334
+ stdio: ["ignore", "pipe", "pipe"],
335
+ });
336
+ const result = run(command, args, {
337
+ encoding: "utf8",
338
+ stdio: ["ignore", "pipe", "pipe"],
339
+ });
340
+ const onPath = pathResult.status === 0;
341
+ return {
342
+ status: result.status === 0 ? "pass" : "fail",
343
+ command,
344
+ on_path: onPath,
345
+ path: onPath ? pathResult.stdout.trim() || null : null,
346
+ version: result.status === 0
347
+ ? (result.stdout || result.stderr || "").trim() || null
348
+ : null,
349
+ error: result.status === 0
350
+ ? null
351
+ : (result.stderr || result.stdout || (result.error && result.error.message) || "").trim() || null,
352
+ };
353
+ }
354
+
355
+ function runtimeAgent({
356
+ hostPlan = null,
357
+ hostAccount = null,
358
+ id,
359
+ name,
360
+ runtime,
361
+ command,
362
+ probe,
363
+ readyToServe,
364
+ serveStatus,
365
+ requirements,
366
+ publishName,
367
+ defaultDailyTokenCap = null,
368
+ }) {
369
+ const suggestedDailyRate = hostPlan && PLAN_MONTHLY_COST_UAT[hostPlan?.toLowerCase()]
370
+ ? Math.ceil(PLAN_MONTHLY_COST_UAT[hostPlan.toLowerCase()] / 30)
371
+ : DEFAULT_DAILY_RATE_UAT;
372
+ const installed = probe.status === "pass";
373
+ const publishParts = [
374
+ "clawlabor labor-publish",
375
+ `--runtime ${runtime}`,
376
+ `--name ${shellQuote(publishName)}`,
377
+ `--description ${shellQuote(`${publishName}${hostPlan ? ` (${hostPlan} plan)` : ""} backed by the local ${name} runtime.`)}`,
378
+ `--daily-rate ${suggestedDailyRate}`,
379
+ ];
380
+ if (defaultDailyTokenCap) {
381
+ publishParts.push(`--daily-token-cap ${defaultDailyTokenCap}`);
382
+ }
383
+ return {
384
+ id,
385
+ name,
386
+ runtime,
387
+ command,
388
+ present_on_path: probe.on_path,
389
+ installed,
390
+ runnable: installed,
391
+ path: probe.path,
392
+ version: probe.version,
393
+ ready_to_publish: installed,
394
+ ready_to_serve: readyToServe,
395
+ serve_status: serveStatus,
396
+ host_account: hostAccount || null,
397
+ suggested_daily_rate_uat: suggestedDailyRate,
398
+ suggested_daily_token_cap: defaultDailyTokenCap,
399
+ requirements,
400
+ publish_command_template: publishParts.join(" "),
401
+ };
402
+ }
403
+
404
+ function shortRuntimeStatus(agent) {
405
+ if (agent.ready_to_serve) return "ready_to_serve";
406
+ if (agent.ready_to_publish) return "publish_only";
407
+ if (agent.present_on_path) return "needs_repair";
408
+ return "not_installed";
409
+ }
410
+
411
+ function missingRequirementNames(agent) {
412
+ return (agent.requirements || [])
413
+ .filter((item) => item.status !== "pass")
414
+ .map((item) => item.name);
415
+ }
416
+
417
+ function failedRequirements(agent) {
418
+ return (agent.requirements || [])
419
+ .filter((item) => item && item.status !== "pass");
420
+ }
421
+
422
+ function requirementSetupSteps(agent) {
423
+ return failedRequirements(agent)
424
+ .map((item) => item.next || item.detail)
425
+ .filter(Boolean);
426
+ }
427
+
428
+ function serveStatusStep(agent) {
429
+ if (agent.serve_status === "candidate_not_wired_to_labor_serve") {
430
+ return `${agent.name} is publish-only in this CLI version. Use a runtime with a start_command, such as claude or opencode.`;
431
+ }
432
+ if (agent.serve_status === "not_installed") {
433
+ return `Install ${agent.name}, then rerun \`clawlabor labor-agents\`.`;
434
+ }
435
+ return null;
436
+ }
437
+
438
+ function compactHostAccount(account, provider = "claude") {
439
+ if (!account || !account.logged_in) {
440
+ return {
441
+ provider: account && account.provider ? account.provider : provider,
442
+ status: account && account.status ? account.status : "not_logged_in",
443
+ };
444
+ }
445
+ const compact = {
446
+ provider: account.provider,
447
+ label: account.label || account.email || account.org_name || null,
448
+ plan: account.plan || null,
449
+ };
450
+ if (account.provider !== "codex" && account.source) {
451
+ compact.source = account.source;
452
+ }
453
+ if (account.provider !== "codex" && account.auth_mode) {
454
+ compact.auth_mode = account.auth_mode;
455
+ }
456
+ if (account.provider !== "codex" && account.subscription_active_start) {
457
+ compact.subscription_active_start = account.subscription_active_start;
458
+ }
459
+ if (account.subscription_active_until) {
460
+ compact.subscription_active_until = account.subscription_active_until;
461
+ }
462
+ if (account.provider !== "codex" && account.subscription_last_checked) {
463
+ compact.subscription_last_checked = account.subscription_last_checked;
464
+ }
465
+ if (account.quota) {
466
+ compact.quota = account.quota;
467
+ }
468
+ return compact;
469
+ }
470
+
471
+ function nanoToUatDisplay(nano) {
472
+ if (nano === null || nano === undefined) return null;
473
+ const whole = Math.trunc(Number(nano) / NANO_FACTOR);
474
+ const frac = Math.trunc((Number(nano) % NANO_FACTOR) * 100 / NANO_FACTOR);
475
+ return `${whole}.${String(frac).padStart(2, "0")}`;
476
+ }
477
+
478
+ function summarizeLaborAgent(agent, existingLaborByRuntime) {
479
+ const failed = failedRequirements(agent);
480
+ const missing = failed.map((item) => item.name);
481
+ const existing = existingLaborByRuntime[agent.runtime] || null;
482
+ const publishCommand = agent.publish_command_template;
483
+ const summary = {
484
+ runtime: agent.runtime,
485
+ name: agent.name,
486
+ status: shortRuntimeStatus(agent),
487
+ can_publish: agent.ready_to_publish,
488
+ suggested_daily_rate_uat: agent.suggested_daily_rate_uat,
489
+ can_serve: agent.ready_to_serve,
490
+ };
491
+ if (agent.suggested_daily_token_cap) {
492
+ summary.suggested_daily_token_cap = agent.suggested_daily_token_cap;
493
+ }
494
+ if (missing.length > 0) {
495
+ summary.needs = missing;
496
+ }
497
+ if (agent.ready_to_publish) {
498
+ summary.publish_command = publishCommand;
499
+ }
500
+ if (existing) {
501
+ summary.labor_id = existing.id;
502
+ summary.labor_status = existing.status;
503
+ }
504
+ if (agent.ready_to_serve) {
505
+ // When there is no existing labor, labor-start auto-publishes and forwards
506
+ // the same suggested rate / token cap. When a labor already exists, the
507
+ // cap is immutable post-publish (see labor-start guard), so we only surface
508
+ // --runtime here.
509
+ const startParts = [`clawlabor labor-start --runtime ${agent.runtime}`];
510
+ if (!existing) {
511
+ startParts.push(`--daily-rate ${agent.suggested_daily_rate_uat}`);
512
+ if (agent.suggested_daily_token_cap) {
513
+ startParts.push(`--daily-token-cap ${agent.suggested_daily_token_cap}`);
514
+ }
515
+ }
516
+ summary.start_command = startParts.join(" ");
517
+ summary.next_action = {
518
+ type: "start_labor",
519
+ ready: true,
520
+ command: summary.start_command,
521
+ };
522
+ } else {
523
+ const setupSteps = requirementSetupSteps(agent);
524
+ const statusStep = serveStatusStep(agent);
525
+ summary.next_action = {
526
+ type: agent.ready_to_publish ? "finish_runtime_setup" : "install_runtime",
527
+ ready: false,
528
+ blocked_by: missing.length > 0 ? missing : [agent.serve_status || "runtime_not_serveable"],
529
+ steps: setupSteps.length > 0 ? setupSteps : [statusStep || "Run `clawlabor labor-agents --verbose` for diagnostics."],
530
+ diagnostics_command: "clawlabor labor-agents --verbose",
531
+ };
532
+ }
533
+ return summary;
534
+ }
535
+
536
+ async function currentMarketplaceAgent(deps) {
537
+ try {
538
+ const me = await requestJson(deps, "GET", "/agents/me");
539
+ const agent = me.agent || me;
540
+ return {
541
+ status: "authenticated",
542
+ id: agent.id || null,
543
+ agent_id: agent.agent_id || null,
544
+ name: agent.name || null,
545
+ owner_email: agent.owner_email || null,
546
+ balance: agent.balance ?? null,
547
+ frozen: agent.frozen ?? null,
548
+ is_online: Boolean(agent.is_online),
549
+ };
550
+ } catch (err) {
551
+ return {
552
+ status: "unavailable",
553
+ id: null,
554
+ agent_id: null,
555
+ name: null,
556
+ owner_email: null,
557
+ balance: null,
558
+ frozen: null,
559
+ is_online: false,
560
+ api_base: apiBase(deps.env),
561
+ error: err.message,
562
+ error_code: err.errorCode || "cli_error",
563
+ };
564
+ }
565
+ }
566
+
567
+ function compactMarketplaceAgent(agent) {
568
+ if (!agent || agent.status !== "authenticated") {
569
+ return {
570
+ status: "unavailable",
571
+ api_base: agent && agent.api_base ? agent.api_base : null,
572
+ reason: agent && agent.error_code ? agent.error_code : "unknown",
573
+ next: "Run clawlabor auth status.",
574
+ };
575
+ }
576
+ const compact = {
577
+ status: "authenticated",
578
+ name: agent.name,
579
+ // /agents/me already returns balance/frozen as UAT 2-decimal strings
580
+ // (server-side nano_to_uat_display). Pass through; do NOT convert again.
581
+ balance: agent.balance,
582
+ online: agent.is_online,
583
+ };
584
+ if (agent.frozen !== null && agent.frozen !== undefined) {
585
+ compact.frozen = agent.frozen;
586
+ }
587
+ return compact;
588
+ }
589
+
590
+ async function activeLaborResourcesForRuntime(deps, runtime) {
591
+ const list = await requestJson(deps, "GET", "/labor/list?limit=100");
592
+ const me = await requestJson(deps, "GET", "/agents/me");
593
+ const owner = me.agent || me;
594
+ const ownerId = owner && owner.id ? String(owner.id) : null;
595
+ return (list.items || []).filter((item) =>
596
+ String(item.seller_agent_id) === ownerId &&
597
+ item.runtime === runtime &&
598
+ ACTIVE_LABOR_RESOURCE_STATUSES.has(item.status),
599
+ );
600
+ }
601
+
602
+ async function currentSellerLaborResources(deps, marketplaceAgent) {
603
+ if (!marketplaceAgent || marketplaceAgent.status !== "authenticated" || !marketplaceAgent.id) {
604
+ return [];
605
+ }
606
+ try {
607
+ const list = await requestJson(deps, "GET", "/labor/list?limit=100");
608
+ return (list.items || []).filter((item) =>
609
+ String(item.seller_agent_id) === String(marketplaceAgent.id) &&
610
+ ACTIVE_LABOR_RESOURCE_STATUSES.has(item.status),
611
+ );
612
+ } catch (_err) {
613
+ return [];
614
+ }
615
+ }
616
+
617
+ async function activeHiresForLabor(deps, laborId, { timeoutMs = null } = {}) {
618
+ const requestPromise = requestJson(deps, "GET", `/labor/${laborId}/hires?status=active`, {});
619
+ const result = timeoutMs === null
620
+ ? await requestPromise
621
+ : await withTimeout(requestPromise, timeoutMs, "labor active hire poll");
622
+ return result.items || [];
623
+ }
624
+
625
+ function existingLaborByRuntime(resources) {
626
+ const byRuntime = {};
627
+ for (const resource of resources) {
628
+ if (resource.runtime && !byRuntime[resource.runtime]) {
629
+ byRuntime[resource.runtime] = resource;
630
+ }
631
+ }
632
+ return byRuntime;
633
+ }
634
+
635
+ async function claudeRuntimeAgent(deps) {
636
+ const claudeOauth = await resolveClaudeCodeOauthToken(deps);
637
+ const claudeAccount = await resolveClaudeCodeAccount(deps);
638
+ const claude = commandProbe(deps, "claude");
639
+ const docker = commandProbe(deps, "docker");
640
+ const cloudflared = commandProbe(deps, "cloudflared");
641
+ const sharedServeRequirements = [
642
+ {
643
+ name: "docker",
644
+ status: docker.status,
645
+ command: "docker --version",
646
+ version: docker.version,
647
+ detail: docker.status === "pass"
648
+ ? "Docker CLI is available"
649
+ : "Install/start Docker Desktop before running labor-serve",
650
+ next: docker.status === "pass"
651
+ ? null
652
+ : "Install Docker Desktop, start it, then rerun `clawlabor labor-agents`.",
653
+ },
654
+ {
655
+ name: "cloudflared",
656
+ status: cloudflared.status,
657
+ command: "cloudflared --version",
658
+ version: cloudflared.version,
659
+ detail: cloudflared.status === "pass"
660
+ ? "cloudflared is available"
661
+ : "Install cloudflared before running labor-serve",
662
+ next: cloudflared.status === "pass"
663
+ ? null
664
+ : "Install cloudflared (`brew install cloudflared` on macOS), then rerun `clawlabor labor-agents`.",
665
+ },
666
+ ];
667
+ const claudeRequirements = [
668
+ {
669
+ name: "claude_cli",
670
+ status: claude.status,
671
+ command: "claude --version",
672
+ version: claude.version,
673
+ detail: claude.status === "pass"
674
+ ? "Claude Code CLI is available"
675
+ : CLAUDE_CODE_INSTALL_HINT,
676
+ next: claude.status === "pass" ? null : CLAUDE_CODE_INSTALL_HINT,
677
+ },
678
+ {
679
+ name: "claude_code_oauth",
680
+ status: claudeOauth.token ? "pass" : "fail",
681
+ detail: claudeOauth.token
682
+ ? "Claude Code claude.ai OAuth token is available"
683
+ : claudeOauth.authStatusOk
684
+ ? "Claude Code auth status passed, but the local claude.ai OAuth access token is missing or expired. Run `claude setup-token`, then retry `clawlabor labor-start --runtime claude`."
685
+ : "Run `claude auth status` and make sure it shows authMethod claude.ai with an active subscription.",
686
+ },
687
+ ...sharedServeRequirements,
688
+ ];
689
+ const claudeReadyToServe = claudeRequirements.every((item) => item.status === "pass");
690
+ return runtimeAgent({
691
+ id: "claude-code-sandbox",
692
+ name: "Claude Code Sandbox",
693
+ runtime: "claude",
694
+ command: "claude",
695
+ probe: claude,
696
+ readyToServe: claudeReadyToServe,
697
+ serveStatus: claudeReadyToServe
698
+ ? "supported"
699
+ : "missing_requirements",
700
+ requirements: claudeRequirements,
701
+ publishName: "Claude Code Labor",
702
+ hostAccount: claudeAccount,
703
+ hostPlan: claudeAccount.plan,
704
+ });
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // labor-agents — inspect local runtimes that can back a labor listing
709
+ // ---------------------------------------------------------------------------
710
+ async function commandLaborAgents(_options, deps, flags) {
711
+ const marketplaceAgent = await currentMarketplaceAgent(deps);
712
+ const existingLabor = existingLaborByRuntime(
713
+ await currentSellerLaborResources(deps, marketplaceAgent),
714
+ );
715
+ const claudeAgent = await claudeRuntimeAgent(deps);
716
+ const codexAccount = resolveCodexChatGptAccount(deps);
717
+ const codex = commandProbe(deps, "codex");
718
+ const opencode = commandProbe(deps, "opencode");
719
+ const fs = deps.fs || require("fs");
720
+ const codexAuthPresent = codex.status === "pass" && fs.existsSync(codexAuthPath(deps.env));
721
+ const codexConfigPresent = codex.status === "pass" && fs.existsSync(codexConfigPath(deps.env));
722
+ const codexReadyToServe = codexAuthPresent && codexConfigPresent;
723
+ const opencodeAuthPresent = opencode.status === "pass" && (deps.fs || require("fs")).existsSync(opencodeAuthPath(deps.env));
724
+ const codexAgent = runtimeAgent({
725
+ id: "codex-sandbox",
726
+ name: "Codex Sandbox",
727
+ runtime: "codex",
728
+ command: "codex",
729
+ probe: codex,
730
+ readyToServe: codexReadyToServe,
731
+ serveStatus: codexReadyToServe
732
+ ? "ready_to_serve"
733
+ : codex.status === "pass"
734
+ ? "needs_codex_auth"
735
+ : "not_installed",
736
+ requirements: [
737
+ {
738
+ name: "codex_cli",
739
+ status: codex.status,
740
+ command: "codex --version",
741
+ version: codex.version,
742
+ detail: codex.status === "pass"
743
+ ? "Codex CLI is installed locally"
744
+ : codex.on_path
745
+ ? "Codex CLI is on PATH but failed to run; repair the local Codex install before publishing a Codex-backed labor runtime"
746
+ : "Install Codex CLI before publishing a Codex-backed labor runtime",
747
+ next: codex.status === "pass"
748
+ ? null
749
+ : "Install or repair Codex CLI, then rerun `clawlabor labor-agents`.",
750
+ error: codex.error,
751
+ },
752
+ {
753
+ name: "codex_auth",
754
+ status: codexReadyToServe ? "pass" : "fail",
755
+ detail: codexReadyToServe
756
+ ? "Codex auth.json and config.toml found; labor-serve will mount them read-only into the sandbox"
757
+ : "Run `codex login` so labor-serve can pass your Codex credentials into the sandbox",
758
+ next: codexReadyToServe ? null : "Run `codex login`, then rerun `clawlabor labor-agents`.",
759
+ },
760
+ ],
761
+ publishName: "Codex Labor",
762
+ hostAccount: codexAccount,
763
+ hostPlan: codexAccount.plan,
764
+ });
765
+ const agents = [
766
+ claudeAgent,
767
+ codexAgent,
768
+ runtimeAgent({
769
+ id: "opencode-sandbox",
770
+ name: "OpenCode Sandbox",
771
+ runtime: "opencode",
772
+ command: "opencode",
773
+ probe: opencode,
774
+ readyToServe: opencodeAuthPresent,
775
+ serveStatus: opencodeAuthPresent
776
+ ? "ready_to_serve"
777
+ : opencode.status === "pass"
778
+ ? "needs_opencode_auth"
779
+ : "not_installed",
780
+ defaultDailyTokenCap: DEFAULT_DAILY_TOKEN_CAP,
781
+ requirements: [
782
+ {
783
+ name: "opencode_cli",
784
+ status: opencode.status,
785
+ command: "opencode --version",
786
+ version: opencode.version,
787
+ detail: opencode.status === "pass"
788
+ ? "OpenCode CLI is installed locally"
789
+ : opencode.on_path
790
+ ? "OpenCode CLI is on PATH but failed to run; repair the local OpenCode install before publishing an OpenCode-backed labor runtime"
791
+ : "Install OpenCode CLI before publishing an OpenCode-backed labor runtime",
792
+ next: opencode.status === "pass"
793
+ ? null
794
+ : "Install or repair OpenCode CLI, then rerun `clawlabor labor-agents`.",
795
+ error: opencode.error,
796
+ },
797
+ {
798
+ name: "opencode_auth",
799
+ status: opencodeAuthPresent ? "pass" : "fail",
800
+ detail: opencodeAuthPresent
801
+ ? "OpenCode auth.json found; labor-serve will mount it read-only into the sandbox"
802
+ : "Run `opencode auth login` so labor-serve can pass your provider credentials into the sandbox",
803
+ next: opencodeAuthPresent ? null : "Run `opencode auth login`, then rerun `clawlabor labor-agents`.",
804
+ },
805
+ ],
806
+ publishName: "OpenCode Labor",
807
+ }),
808
+ ];
809
+ const verbose = Boolean(_options.verbose) || Boolean(flags && flags.has && flags.has("verbose"));
810
+ if (verbose) {
811
+ return JSON.stringify(
812
+ {
813
+ action: "labor-agents",
814
+ agents,
815
+ marketplace_agent: marketplaceAgent,
816
+ },
817
+ null,
818
+ 2,
819
+ );
820
+ }
821
+ return JSON.stringify(
822
+ {
823
+ action: "labor-agents",
824
+ account: compactMarketplaceAgent(marketplaceAgent),
825
+ host: {
826
+ claude: compactHostAccount(claudeAgent.host_account),
827
+ codex: compactHostAccount(codexAgent.host_account, "codex"),
828
+ },
829
+ agents: agents.map((agent) => summarizeLaborAgent(agent, existingLabor)),
830
+ next_actions: [
831
+ "Use labor-publish to list a ready runtime.",
832
+ "Use labor-list to inspect existing labor.",
833
+ "Use labor-agents --verbose for diagnostics.",
834
+ ],
835
+ },
836
+ null,
837
+ 2,
838
+ );
839
+ }
840
+
841
+ function compactLaborResource(resource) {
842
+ const id = resource.id;
843
+ return {
844
+ id: resource.id,
845
+ name: resource.name,
846
+ status: resource.status,
847
+ serve_status: resource.serve_status,
848
+ daily_rate_uat: nanoToUatDisplay(resource.daily_rate_nano),
849
+ daily_token_cap: resource.daily_token_cap ?? null,
850
+ tier: resource.tier,
851
+ seller_agent_id: resource.seller_agent_id,
852
+ host_account_provider: resource.host_account_provider || null,
853
+ host_account_id: resource.host_account_id || null,
854
+ host_account_label: resource.host_account_label || null,
855
+ host_account_plan: resource.host_account_plan || null,
856
+ host_account_quota: resource.host_account_quota || null,
857
+ last_heartbeat_at: resource.last_heartbeat_at || null,
858
+ sandbox_base_url: resource.sandbox_base_url || null,
859
+ created_at: resource.created_at,
860
+ updated_at: resource.updated_at,
861
+ management_commands: {
862
+ serve_command: `clawlabor labor-serve --labor ${id}`,
863
+ unpublish_command: `clawlabor labor-unpublish --labor ${id}`,
864
+ },
865
+ };
866
+ }
867
+
868
+ // ---------------------------------------------------------------------------
869
+ // labor-list — list current seller's labor resources (or all public resources)
870
+ // ---------------------------------------------------------------------------
871
+ async function commandLaborList(options, deps, flags) {
872
+ const showAll = flags && flags.has && flags.has("all");
873
+ const status = options.status || "available";
874
+ if (!LABOR_STATUSES.has(status)) {
875
+ throw new Error(
876
+ `Invalid --status "${status}". Use draft, available, occupied, inactive, or all.`,
877
+ );
878
+ }
879
+ const limit = positiveNumberOption(options, "limit") || 100;
880
+ const params = new URLSearchParams();
881
+ params.set("limit", String(Math.min(limit, 100)));
882
+ if (options.cursor) params.set("cursor", options.cursor);
883
+ if (status !== "all") params.set("status", status);
884
+
885
+ const list = await requestJson(deps, "GET", `/labor/list?${params.toString()}`);
886
+ let owner = null;
887
+ if (!showAll) {
888
+ const me = await requestJson(deps, "GET", "/agents/me");
889
+ owner = me.agent || me;
890
+ }
891
+ const ownerId = owner && owner.id ? String(owner.id) : null;
892
+ const items = (list.items || [])
893
+ .filter((item) => showAll || String(item.seller_agent_id) === ownerId)
894
+ .map(compactLaborResource);
895
+
896
+ return JSON.stringify(
897
+ {
898
+ action: "labor-list",
899
+ scope: showAll ? "all" : "mine",
900
+ status,
901
+ count: items.length,
902
+ items,
903
+ management_commands: {
904
+ serve_command: "clawlabor labor-serve --labor <labor_resource_id>",
905
+ unpublish_command: "clawlabor labor-unpublish --labor <labor_resource_id>",
906
+ inspect_command: "clawlabor labor-list",
907
+ },
908
+ next_cursor: list.next_cursor || null,
909
+ },
910
+ null,
911
+ 2,
912
+ );
913
+ }
914
+
915
+ // ---------------------------------------------------------------------------
916
+ // hire — buy exclusive use of a labor resource for one day
917
+ // ---------------------------------------------------------------------------
918
+ async function commandHire(options, deps) {
919
+ const listing = requiredOption(options, "listing");
920
+ // v1: rentals are exactly one day (multi-day not yet supported).
921
+ const body = { labor_resource_id: listing, duration_days: 1 };
922
+ if (options.message) {
923
+ body.message = options.message;
924
+ }
925
+ const hire = await requestJson(deps, "POST", "/labor/hire", { body });
926
+ return JSON.stringify(
927
+ {
928
+ action: "hire",
929
+ hire_id: hire.id,
930
+ status: hire.status,
931
+ labor_resource_id: hire.labor_resource_id,
932
+ duration_days: hire.duration_days,
933
+ frozen_nano: hire.frozen_nano,
934
+ },
935
+ null,
936
+ 2,
937
+ );
938
+ }
939
+
940
+ // ---------------------------------------------------------------------------
941
+ // labor-publish — create a labor resource and publish it (available)
942
+ // ---------------------------------------------------------------------------
943
+ async function commandLaborPublish(options, deps) {
944
+ const name = requiredOption(options, "name");
945
+ const description = requiredOption(options, "description");
946
+ const dailyRate = positiveNumberOption(options, "daily-rate");
947
+ if (dailyRate === undefined) {
948
+ throw new Error("Missing required --daily-rate");
949
+ }
950
+ const dailyTokenCap = tokenCountOption(options, "daily-token-cap");
951
+ const runtime = options.runtime || "claude";
952
+ if (!["claude", "codex", "opencode"].includes(runtime)) {
953
+ throw new Error(`labor-publish supports --runtime claude, codex, or opencode; ${runtime} has no labor-serve support yet.`);
954
+ }
955
+ if (dailyTokenCap !== undefined && runtime !== "opencode") {
956
+ throw new Error(
957
+ `--daily-token-cap is currently opencode-only; ${runtime} hires do not report per-prompt token usage yet, so the cap would never trip. ` +
958
+ "Re-run with --runtime opencode, or omit --daily-token-cap.",
959
+ );
960
+ }
961
+ const existing = await activeLaborResourcesForRuntime(deps, runtime);
962
+ if (existing.length > 0) {
963
+ const ids = existing.map((item) => `${item.id}(${item.status})`).join(", ");
964
+ throw new Error(
965
+ `Already have an active ${runtime} labor: ${ids}. ` +
966
+ "Use `clawlabor labor-list` to inspect it or `clawlabor labor-unpublish --labor <id>` before publishing again.",
967
+ );
968
+ }
969
+ const hostAccount = runtime === "claude" ? await resolveClaudeCodeAccount(deps) : null;
970
+ const body = {
971
+ name,
972
+ description,
973
+ runtime,
974
+ daily_rate_uat: dailyRate,
975
+ min_duration_days: 1,
976
+ max_duration_days: 1,
977
+ tier: options.tier || "tier_1",
978
+ };
979
+ if (dailyTokenCap !== undefined) {
980
+ body.daily_token_cap = dailyTokenCap;
981
+ }
982
+ if (runtime === "claude" && hostAccount && hostAccount.provider === "claude" && hostAccount.logged_in && hostAccount.id) {
983
+ body.host_account_provider = hostAccount.provider;
984
+ body.host_account_id = hostAccount.id;
985
+ body.host_account_label = hostAccount.label;
986
+ body.host_account_plan = hostAccount.plan;
987
+ body.host_account_quota = hostAccount.quota;
988
+ }
989
+ body.gatekeeper_prompt = options.gatekeeper || DEFAULT_GATEKEEPER_PROMPT;
990
+ const created = await requestJson(deps, "POST", "/labor", { body });
991
+ const published = await requestJson(deps, "PUT", `/labor/${created.id}`, {
992
+ body: { status: "available" },
993
+ });
994
+ return JSON.stringify(
995
+ {
996
+ action: "labor-publish",
997
+ labor_resource_id: created.id,
998
+ status: published.status,
999
+ name: published.name,
1000
+ },
1001
+ null,
1002
+ 2,
1003
+ );
1004
+ }
1005
+
1006
+ // ---------------------------------------------------------------------------
1007
+ // labor-start — put a supported local runtime on duty: publish if needed, then serve
1008
+ // ---------------------------------------------------------------------------
1009
+ async function commandLaborStart(options, deps) {
1010
+ const runtime = options.runtime || "claude";
1011
+
1012
+ const marketplaceAgent = await currentMarketplaceAgent(deps);
1013
+ if (marketplaceAgent.status !== "authenticated") {
1014
+ throw new Error("Authenticate before starting labor. Run `clawlabor auth status`.");
1015
+ }
1016
+
1017
+ // Readiness: reuse the labor-agents inventory for the chosen runtime.
1018
+ const inventory = JSON.parse(await commandLaborAgents({ verbose: true }, deps));
1019
+ const agent = (inventory.agents || []).find((a) => a.runtime === runtime);
1020
+ const canServe = Boolean(agent && (agent.can_serve || agent.ready_to_serve));
1021
+ if (!agent || !canServe) {
1022
+ const failedRequirements = Array.isArray(agent && agent.requirements)
1023
+ ? agent.requirements.filter((item) => item && item.status !== "pass")
1024
+ : [];
1025
+ const needs = (agent && agent.needs) ? agent.needs.join(", ") : "runtime not serveable";
1026
+ const details = failedRequirements
1027
+ .map((item) => item.detail)
1028
+ .filter(Boolean)
1029
+ .join(" ");
1030
+ const hint = details ? ` ${details}` : " Run `clawlabor labor-agents --verbose` for diagnostics.";
1031
+ throw new Error(`Cannot start ${runtime} labor yet; ${needs}.${hint}`);
1032
+ }
1033
+
1034
+ const existing = existingLaborByRuntime(
1035
+ await currentSellerLaborResources(deps, marketplaceAgent),
1036
+ )[runtime];
1037
+ let laborId = existing && existing.id;
1038
+ if (!laborId) {
1039
+ const defaults = {
1040
+ claude: { name: "Claude Code Labor", description: "Claude Code Labor backed by the local Claude Code Sandbox runtime." },
1041
+ codex: { name: "Codex Labor", description: "Codex Labor backed by the local Codex Sandbox runtime." },
1042
+ opencode: { name: "OpenCode Labor", description: "OpenCode Labor backed by the local OpenCode Sandbox runtime." },
1043
+ }[runtime] || { name: `${runtime} Labor`, description: `${runtime} Labor backed by the local sandbox runtime.` };
1044
+ const publishOptions = {
1045
+ runtime,
1046
+ name: options.name || defaults.name,
1047
+ description: options.description || defaults.description,
1048
+ "daily-rate": options["daily-rate"] || String(DEFAULT_DAILY_RATE_UAT),
1049
+ tier: options.tier,
1050
+ };
1051
+ if (options["daily-token-cap"] !== undefined) {
1052
+ publishOptions["daily-token-cap"] = options["daily-token-cap"];
1053
+ }
1054
+ const publishOut = await commandLaborPublish(publishOptions, deps);
1055
+ laborId = JSON.parse(publishOut).labor_resource_id;
1056
+ } else if (options["daily-token-cap"] !== undefined) {
1057
+ const dailyRate = options["daily-rate"] || String(DEFAULT_DAILY_RATE_UAT);
1058
+ const unpublishCommand = `clawlabor labor-unpublish --labor ${laborId}`;
1059
+ const restartCommand = `clawlabor labor-start --runtime ${runtime} --daily-rate ${dailyRate} --daily-token-cap ${options["daily-token-cap"]}`;
1060
+ const err = new Error(
1061
+ `Cannot change --daily-token-cap on existing labor ${laborId}. A labor's cap is fixed at publish time; to change it you must unpublish the listing first and then run labor-start again. Execute next_steps in order.`,
1062
+ );
1063
+ err.errorCode = "labor_cap_immutable_on_existing_labor";
1064
+ err.labor_id = laborId;
1065
+ err.runtime = runtime;
1066
+ err.next_steps = [
1067
+ { step: 1, run: unpublishCommand, why: "Mark the existing listing inactive so labor-start can publish a new one." },
1068
+ { step: 2, run: restartCommand, why: "Re-publish with the new cap and serve in one go." },
1069
+ ];
1070
+ throw err;
1071
+ }
1072
+
1073
+ return commandLaborServe(
1074
+ {
1075
+ ...options,
1076
+ labor: laborId,
1077
+ runtime,
1078
+ },
1079
+ deps,
1080
+ );
1081
+ }
1082
+
1083
+ // ---------------------------------------------------------------------------
1084
+ // labor-unpublish — delist a resource (set it inactive; reversible via republish)
1085
+ // ---------------------------------------------------------------------------
1086
+ async function commandLaborUnpublish(options, deps) {
1087
+ const laborId = requiredOption(options, "labor");
1088
+ const updated = await requestJson(deps, "PUT", `/labor/${laborId}`, {
1089
+ body: { status: "inactive" },
1090
+ });
1091
+ return JSON.stringify(
1092
+ { action: "labor-unpublish", labor_resource_id: laborId, status: updated.status },
1093
+ null,
1094
+ 2,
1095
+ );
1096
+ }
1097
+
1098
+ // ---------------------------------------------------------------------------
1099
+ // labor-chat — send one message to a hire and print the streamed reply
1100
+ // ---------------------------------------------------------------------------
1101
+ function parseSseChunks(sse) {
1102
+ const chunks = [];
1103
+ let error = null;
1104
+ for (const block of sse.split("\n\n")) {
1105
+ let event = "message";
1106
+ let data = "";
1107
+ for (const line of block.split("\n")) {
1108
+ if (line.startsWith("event:")) event = line.slice(6).trim();
1109
+ else if (line.startsWith("data:")) data += line.slice(5).trim();
1110
+ }
1111
+ if (!data) continue;
1112
+ if (event === "chunk") {
1113
+ try {
1114
+ chunks.push(JSON.parse(data).text || "");
1115
+ } catch (_e) {
1116
+ /* ignore malformed chunk */
1117
+ }
1118
+ } else if (event === "error") {
1119
+ try {
1120
+ error = JSON.parse(data);
1121
+ } catch (_e) {
1122
+ error = { detail: data };
1123
+ }
1124
+ }
1125
+ }
1126
+ return { text: chunks.join(""), error };
1127
+ }
1128
+
1129
+ async function commandLaborChat(options, deps) {
1130
+ const hire = requiredOption(options, "hire");
1131
+ const message = requiredOption(options, "message");
1132
+ const sse = await request(deps, "POST", `/labor/${hire}/messages/stream`, {
1133
+ body: { content: message },
1134
+ });
1135
+ const { text, error } = parseSseChunks(sse);
1136
+ if (error) {
1137
+ return JSON.stringify({ action: "labor-chat", hire_id: hire, error }, null, 2);
1138
+ }
1139
+ return text;
1140
+ }
1141
+
1142
+ // ---------------------------------------------------------------------------
1143
+ // labor-serve — provision a platform tunnel, run the sandbox + cloudflared,
1144
+ // and heartbeat until interrupted. Seller-side control plane.
1145
+ // ---------------------------------------------------------------------------
1146
+ async function commandLaborServe(options, deps) {
1147
+ const laborId = requiredOption(options, "labor");
1148
+ const runtime = options.runtime || "claude";
1149
+ const port = numberOption(options, "port") || 2468;
1150
+ const image = options.image || DEFAULT_SANDBOX_IMAGE;
1151
+ const spawn = deps.spawn || require("child_process").spawn;
1152
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
1153
+ const now = deps.now || (() => Date.now());
1154
+ const stdout = createTimestampedStdout(deps.stdout, now);
1155
+ const sandboxStartupTimeoutMs = deps.sandboxStartupTimeoutMs || SANDBOX_STARTUP_TIMEOUT_MS;
1156
+ const sellerApiKey = resolveApiKey(deps.env);
1157
+
1158
+ stdout(`[1/7] Preparing to serve labor ${laborId}...`);
1159
+ if (!sellerApiKey) {
1160
+ throw new Error("Set CLAWLABOR_API_KEY or store api_key in ~/.config/clawlabor/credentials.json before calling clawlabor");
1161
+ }
1162
+ const releaseServeLock = acquireLaborServeLock(deps, { runtime, laborId, port });
1163
+ try {
1164
+ const sellerDeps = { ...deps, env: envWithApiKey(deps.env, sellerApiKey) };
1165
+ const stop = deps.waitForExit ? deps.waitForExit() : new Promise(() => {});
1166
+ let stopRequested = false;
1167
+ let shutdownRequested = false;
1168
+ let stopNoticePrinted = false;
1169
+ stdout(`[2/7] Resolving ${runtime} sandbox credentials...`);
1170
+ const sandboxCreds = await resolveRuntimeSandboxCredentials(runtime, deps);
1171
+ if (runtime === "opencode") {
1172
+ stdout("Note: your OpenCode credentials are mounted read-only into a sandbox that runs buyer requests. Use a scoped/limited provider key.");
1173
+ }
1174
+
1175
+ stdout("[3/7] Marking labor seat online...");
1176
+ await requestJson(sellerDeps, "POST", `/labor/${laborId}/serve`, {});
1177
+ let activeCleanupCurrentHire = null;
1178
+ let activeStopCleanupPromise = null;
1179
+ let laborSeatOfflinePromise = null;
1180
+
1181
+ function markLaborSeatOffline() {
1182
+ if (!laborSeatOfflinePromise) {
1183
+ laborSeatOfflinePromise = (async () => {
1184
+ stdout("Notifying platform of labor seat shutdown...");
1185
+ try {
1186
+ await withTimeout(
1187
+ requestJson(sellerDeps, "DELETE", `/labor/${laborId}/serve`, {}),
1188
+ 3000,
1189
+ "labor teardown",
1190
+ );
1191
+ stdout("Labor seat marked offline.");
1192
+ } catch (_err) {
1193
+ stdout("Labor seat shutdown notification timed out (best effort).");
1194
+ }
1195
+ })();
1196
+ }
1197
+ return laborSeatOfflinePromise;
1198
+ }
1199
+
1200
+ function requestActiveCleanup() {
1201
+ if (activeCleanupCurrentHire && !activeStopCleanupPromise) {
1202
+ activeStopCleanupPromise = activeCleanupCurrentHire();
1203
+ }
1204
+ return activeStopCleanupPromise;
1205
+ }
1206
+
1207
+ function requestStop() {
1208
+ stopRequested = true;
1209
+ shutdownRequested = true;
1210
+ if (!stopNoticePrinted) {
1211
+ stopNoticePrinted = true;
1212
+ stdout("\nReceived shutdown signal; stopping local serve/tunnel...");
1213
+ }
1214
+ requestActiveCleanup();
1215
+ markLaborSeatOffline();
1216
+ }
1217
+
1218
+ function interruptibleSleep(ms) {
1219
+ return Promise.race([
1220
+ stop.then(() => {
1221
+ requestStop();
1222
+ return activeStopCleanupPromise;
1223
+ }).then(() => {
1224
+ return true;
1225
+ }),
1226
+ sleep(ms).then(() => false),
1227
+ ]);
1228
+ }
1229
+
1230
+ async function waitForActiveHire() {
1231
+ while (!stopRequested) {
1232
+ const hires = await Promise.race([
1233
+ activeHiresForLabor(sellerDeps, laborId, { timeoutMs: LABOR_CONTROL_TIMEOUT_MS }),
1234
+ stop.then(() => {
1235
+ requestStop();
1236
+ return null;
1237
+ }),
1238
+ ]);
1239
+ if (!hires) return null;
1240
+ const active = hires[0] || null;
1241
+ if (active) return active;
1242
+ await interruptibleSleep(5000);
1243
+ }
1244
+ return null;
1245
+ }
1246
+
1247
+ async function cleanupRuntime({ hireId, containerName, container, ownsContainer, tunnel, tunnelRuntime, cleanedUpRef, preserveContainer = false }) {
1248
+ if (cleanedUpRef.value) return;
1249
+ cleanedUpRef.value = true;
1250
+ stdout(`Shutting down hire ${hireId} runtime...`);
1251
+
1252
+ stdout("Stopping Cloudflare tunnel...");
1253
+ tunnel = tunnelRuntime?.currentTunnel ? tunnelRuntime.currentTunnel() : tunnel;
1254
+ terminateProcessGroup(tunnel, "SIGTERM", deps);
1255
+ await forceKillProcess(tunnel, 3000, deps);
1256
+
1257
+ if (ownsContainer) {
1258
+ stdout("Stopping sandbox container...");
1259
+ if (container) {
1260
+ terminateChild(container);
1261
+ await forceKillProcess(container, 2000, deps);
1262
+ } else {
1263
+ stopContainerByName(containerName, deps);
1264
+ }
1265
+
1266
+ if (preserveContainer) {
1267
+ stdout("Sandbox container stopped and preserved for this active hire; rerun labor-start to resume with the same container filesystem.");
1268
+ } else {
1269
+ stdout("Removing docker container...");
1270
+ await removeContainerByNameAsync({ spawn, containerName });
1271
+ }
1272
+ } else {
1273
+ stdout("Leaving existing sandbox container running for the active hire.");
1274
+ }
1275
+ stdout(`Hire ${hireId} runtime stopped.`);
1276
+ }
1277
+
1278
+ async function activeHireStillPresent(hireId) {
1279
+ try {
1280
+ const hires = await activeHiresForLabor(sellerDeps, laborId, { timeoutMs: LABOR_CONTROL_TIMEOUT_MS });
1281
+ return hires.some((hire) => String(hire.id) === String(hireId));
1282
+ } catch (_err) {
1283
+ return true;
1284
+ }
1285
+ }
1286
+
1287
+ async function runHireSandbox(active) {
1288
+ const hireId = active.id;
1289
+ stdout(`[5/7] Provisioning isolated sandbox for hire ${hireId}...`);
1290
+ const provisioned = await requestJson(sellerDeps, "POST", `/labor/hires/${hireId}/serve`, {});
1291
+ const { tunnel_token, sandbox_token, hostname } = provisioned;
1292
+ stdout("[6/7] Tunnel provisioned, initializing local runtime...");
1293
+ const containerName = `clawlabor-hire-${dockerName(hireId)}`;
1294
+ const publicHealthUrl = `https://${hostname}/v1/health`;
1295
+ const localHealthUrl = `http://127.0.0.1:${port}/v1/health`;
1296
+ const runtimeEnv = {
1297
+ ...deps.env,
1298
+ CLAWLABOR_AGENT_RUNTIME: runtime,
1299
+ ...sandboxCreds.env,
1300
+ };
1301
+
1302
+ ensureDockerImage(image, deps, stdout, { logPrefix: "[6/7]" });
1303
+
1304
+ const probeHealth = createSandboxHealthProbe({
1305
+ deps: { ...deps, withTimeout },
1306
+ sandboxToken: sandbox_token,
1307
+ timeoutMs: LABOR_CONTROL_TIMEOUT_MS,
1308
+ });
1309
+ const spawnSandboxContainer = () => startSandboxContainer({
1310
+ spawn,
1311
+ stdout,
1312
+ image,
1313
+ port,
1314
+ runtime,
1315
+ hireId,
1316
+ containerName,
1317
+ sandboxToken: sandbox_token,
1318
+ sandboxCreds,
1319
+ runtimeEnv,
1320
+ logPrefix: "[6/7]",
1321
+ });
1322
+
1323
+ async function ensureSandboxContainerRunning() {
1324
+ const state = dockerContainerState(containerName, deps);
1325
+ if (state === "running") {
1326
+ if (await probeHealth(localHealthUrl)) {
1327
+ stdout(`[6/7] Reusing existing sandbox container ${containerName}.`);
1328
+ return { container: null, ownsContainer: true };
1329
+ }
1330
+ stdout(`[6/7] Existing sandbox container ${containerName} is running but unhealthy; restarting it.`);
1331
+ if (restartContainerByName(containerName, deps)) {
1332
+ await interruptibleSleep(2000);
1333
+ if (await probeHealth(localHealthUrl)) {
1334
+ stdout(`[6/7] Existing sandbox container ${containerName} recovered after restart.`);
1335
+ return { container: null, ownsContainer: true };
1336
+ }
1337
+ }
1338
+ stdout(`[6/7] Removing unhealthy sandbox container ${containerName}; container filesystem may be lost.`);
1339
+ removeContainerByName(containerName, deps);
1340
+ } else if (state) {
1341
+ stdout(`[6/7] Resuming stopped sandbox container ${containerName} (${state}).`);
1342
+ if (startContainerByName(containerName, deps)) {
1343
+ for (let i = 0; i < 15; i += 1) {
1344
+ await interruptibleSleep(1000);
1345
+ if (await probeHealth(localHealthUrl)) {
1346
+ stdout(`[6/7] Resumed sandbox container ${containerName}.`);
1347
+ return { container: null, ownsContainer: true };
1348
+ }
1349
+ }
1350
+ }
1351
+ stdout(`[6/7] Stopped sandbox container ${containerName} did not become healthy; removing it and rebuilding.`);
1352
+ removeContainerByName(containerName, deps);
1353
+ }
1354
+ return { container: spawnSandboxContainer(), ownsContainer: true };
1355
+ }
1356
+
1357
+ let { container, ownsContainer } = await ensureSandboxContainerRunning();
1358
+ const cleanedUpRef = { value: false };
1359
+ let tunnel = null;
1360
+ let tunnelLogs = [];
1361
+ let tunnelState = { exited: false, exitSummary: null };
1362
+
1363
+ let hireRunning = true;
1364
+ let warnedTunnelDown = false;
1365
+ let tunnelTimeoutReported = false;
1366
+ let tunnelGraceNoticePrinted = false;
1367
+ let healingSandbox = false;
1368
+ let tunnelAvailability = null;
1369
+ let tunnelRuntime = null;
1370
+ let tunnelRestartAttempts = 0;
1371
+
1372
+ async function cleanupCurrentHire() {
1373
+ await cleanupRuntime({
1374
+ hireId,
1375
+ containerName,
1376
+ container,
1377
+ ownsContainer,
1378
+ tunnel,
1379
+ tunnelRuntime,
1380
+ cleanedUpRef,
1381
+ preserveContainer: shutdownRequested && hireRunning,
1382
+ });
1383
+ }
1384
+ activeCleanupCurrentHire = cleanupCurrentHire;
1385
+ activeStopCleanupPromise = null;
1386
+
1387
+ async function selfHealSandbox() {
1388
+ if (healingSandbox) return false;
1389
+ healingSandbox = true;
1390
+ try {
1391
+ if (!(await activeHireStillPresent(hireId))) {
1392
+ hireRunning = false;
1393
+ return false;
1394
+ }
1395
+
1396
+ const state = dockerContainerState(containerName, deps);
1397
+ if (state === "running") {
1398
+ stdout(`\n⚠️ Sandbox container is unhealthy; restarting ${containerName}.\n`);
1399
+ if (restartContainerByName(containerName, deps)) {
1400
+ await interruptibleSleep(2000);
1401
+ if (await probeHealth(localHealthUrl)) {
1402
+ stdout("Sandbox container recovered after restart.");
1403
+ return true;
1404
+ }
1405
+ }
1406
+ stdout(`Sandbox container restart did not recover; rebuilding ${containerName}.`);
1407
+ removeContainerByName(containerName, deps);
1408
+ } else if (state) {
1409
+ stdout(`\n⚠️ Sandbox container ${containerName} is stopped (${state}); starting it for the active hire.\n`);
1410
+ if (startContainerByName(containerName, deps)) {
1411
+ await interruptibleSleep(2000);
1412
+ if (await probeHealth(localHealthUrl)) {
1413
+ stdout("Sandbox container recovered after start.");
1414
+ return true;
1415
+ }
1416
+ }
1417
+ stdout(`Sandbox container start did not recover; rebuilding ${containerName}.`);
1418
+ removeContainerByName(containerName, deps);
1419
+ } else {
1420
+ stdout(`\n⚠️ Sandbox container ${containerName} is not running; rebuilding it for the active hire.\n`);
1421
+ }
1422
+
1423
+ container = spawnSandboxContainer();
1424
+ ownsContainer = true;
1425
+ for (let i = 0; i < 15; i += 1) {
1426
+ await interruptibleSleep(1000);
1427
+ if (await probeHealth(localHealthUrl)) {
1428
+ stdout("Sandbox container rebuilt and healthy.");
1429
+ return true;
1430
+ }
1431
+ }
1432
+ stdout("Sandbox container rebuild did not become healthy before the next heartbeat.");
1433
+ return false;
1434
+ } finally {
1435
+ healingSandbox = false;
1436
+ }
1437
+ }
1438
+
1439
+ async function waitForInitialSandboxHealth() {
1440
+ stdout(`[6/7] Waiting for sandbox local health before starting tunnel...`);
1441
+ for (let i = 0; i < tunnelAvailabilityTimeoutSeconds(sandboxStartupTimeoutMs) && hireRunning; i += 1) {
1442
+ if (await probeHealth(localHealthUrl)) return true;
1443
+ if (stopRequested) return false;
1444
+ await interruptibleSleep(1000);
1445
+ }
1446
+ return false;
1447
+ }
1448
+
1449
+ async function reportInitialSandboxUnavailable() {
1450
+ const error = {
1451
+ reason: "sandbox_unhealthy",
1452
+ detail: `Sandbox local health check failed for ${localHealthUrl}`,
1453
+ local_health_url: localHealthUrl,
1454
+ startup_timeout_ms: sandboxStartupTimeoutMs,
1455
+ };
1456
+ try {
1457
+ await withTimeout(
1458
+ requestJson(sellerDeps, "POST", `/labor/hires/${hireId}/heartbeat`, { body: { healthy: false, error } }),
1459
+ LABOR_CONTROL_TIMEOUT_MS,
1460
+ "hire heartbeat",
1461
+ );
1462
+ } catch (_err) {
1463
+ /* best effort */
1464
+ }
1465
+ }
1466
+
1467
+ async function reportHireInterrupted() {
1468
+ const error = {
1469
+ reason: "seller_shutdown",
1470
+ detail: "Seller stopped the local labor runtime while this hire is still active.",
1471
+ };
1472
+ try {
1473
+ await withTimeout(
1474
+ requestJson(sellerDeps, "POST", `/labor/hires/${hireId}/heartbeat`, { body: { healthy: false, error } }),
1475
+ LABOR_CONTROL_TIMEOUT_MS,
1476
+ "hire shutdown heartbeat",
1477
+ );
1478
+ } catch (_err) {
1479
+ /* best effort */
1480
+ }
1481
+ }
1482
+
1483
+ async function reportTunnelUnavailable() {
1484
+ if (!(await activeHireStillPresent(hireId))) {
1485
+ hireRunning = false;
1486
+ return;
1487
+ }
1488
+ if (warnedTunnelDown) return;
1489
+ warnedTunnelDown = true;
1490
+ stdout(formatTunnelUnavailableWarning({ publicHealthUrl, laborId, tunnelState, tunnelLogs }));
1491
+ }
1492
+
1493
+ async function restartTunnelAfterTimeout() {
1494
+ if (!tunnelRuntime || typeof tunnelRuntime.restart !== "function") return false;
1495
+ if (tunnelRestartAttempts >= MAX_TUNNEL_RESTART_ATTEMPTS) return false;
1496
+ tunnelRestartAttempts += 1;
1497
+ stdout(
1498
+ `Public tunnel unreachable for more than ${tunnelAvailabilityTimeoutSeconds()}s; ` +
1499
+ `restarting Cloudflare tunnel (${tunnelRestartAttempts}/${MAX_TUNNEL_RESTART_ATTEMPTS}).`,
1500
+ );
1501
+ tunnel = await tunnelRuntime.restart("public tunnel unreachable");
1502
+ tunnelAvailability.reset();
1503
+ tunnelGraceNoticePrinted = false;
1504
+ tunnelTimeoutReported = false;
1505
+ warnedTunnelDown = false;
1506
+ return true;
1507
+ }
1508
+
1509
+ async function heartbeatOnce() {
1510
+ let healthy = await probeHealth(publicHealthUrl, { publicTunnel: true });
1511
+ let heartbeatBody = { healthy };
1512
+
1513
+ if (!healthy) {
1514
+ const localOk = await probeHealth(localHealthUrl);
1515
+ if (localOk) {
1516
+ tunnelAvailability.markUnavailable();
1517
+ if (hireRunning && tunnelAvailability.withinGracePeriod()) {
1518
+ healthy = true;
1519
+ heartbeatBody = { healthy: true };
1520
+ if (!tunnelGraceNoticePrinted) {
1521
+ tunnelGraceNoticePrinted = true;
1522
+ stdout(
1523
+ `Tunnel is not reachable yet; allowing up to ${tunnelAvailabilityTimeoutSeconds()}s ` +
1524
+ `for Cloudflare propagation before reporting OFFLINE (${tunnelAvailability.remainingSeconds()}s remaining).`,
1525
+ );
1526
+ }
1527
+ } else if (hireRunning) {
1528
+ if (await restartTunnelAfterTimeout()) {
1529
+ healthy = true;
1530
+ heartbeatBody = { healthy: true };
1531
+ tunnelAvailability.markUnavailable();
1532
+ } else {
1533
+ if (!tunnelTimeoutReported) {
1534
+ tunnelTimeoutReported = true;
1535
+ await reportTunnelUnavailable();
1536
+ stdout(
1537
+ `\n⚠️ Public tunnel has been unreachable for more than ` +
1538
+ `${tunnelAvailabilityTimeoutSeconds()}s; reporting OFFLINE to the platform.\n`,
1539
+ );
1540
+ }
1541
+ heartbeatBody = { healthy: false, error: tunnelAvailability.failurePayload() };
1542
+ }
1543
+ }
1544
+ } else {
1545
+ warnedTunnelDown = false;
1546
+ tunnelAvailability.reset();
1547
+ tunnelTimeoutReported = false;
1548
+ tunnelGraceNoticePrinted = false;
1549
+ const recovered = await selfHealSandbox();
1550
+ if (recovered) {
1551
+ healthy = await probeHealth(publicHealthUrl, { publicTunnel: true });
1552
+ heartbeatBody = { healthy };
1553
+ if (!healthy) {
1554
+ stdout(
1555
+ `\n⚠️ Sandbox recovered locally but is still unreachable over the public tunnel ` +
1556
+ `(${publicHealthUrl}); reporting current public health to the platform.\n`,
1557
+ );
1558
+ }
1559
+ } else if (hireRunning) {
1560
+ stdout(`\n⚠️ Sandbox container is not responding; reporting OFFLINE to the platform.\n`);
1561
+ }
1562
+ }
1563
+ } else {
1564
+ warnedTunnelDown = false;
1565
+ tunnelAvailability.reset();
1566
+ tunnelTimeoutReported = false;
1567
+ tunnelGraceNoticePrinted = false;
1568
+ tunnelRestartAttempts = 0;
1569
+ heartbeatBody = { healthy: true };
1570
+ }
1571
+
1572
+ try {
1573
+ await withTimeout(
1574
+ requestJson(sellerDeps, "POST", `/labor/hires/${hireId}/heartbeat`, { body: heartbeatBody }),
1575
+ LABOR_CONTROL_TIMEOUT_MS,
1576
+ "hire heartbeat",
1577
+ );
1578
+ } catch (_err) {
1579
+ /* best effort */
1580
+ }
1581
+ }
1582
+
1583
+ async function tick() {
1584
+ await heartbeatOnce();
1585
+ if (!hireRunning) return;
1586
+ if (!(await activeHireStillPresent(hireId))) {
1587
+ hireRunning = false;
1588
+ }
1589
+ }
1590
+
1591
+ if (!(await waitForInitialSandboxHealth())) {
1592
+ await reportInitialSandboxUnavailable();
1593
+ throw new Error(`Sandbox did not become locally healthy within ${tunnelAvailabilityTimeoutSeconds(sandboxStartupTimeoutMs)}s: ${localHealthUrl}`);
1594
+ }
1595
+
1596
+ tunnelRuntime = startCloudflareTunnel({
1597
+ spawn,
1598
+ stdout,
1599
+ tunnelToken: tunnel_token,
1600
+ cleanedUpRef,
1601
+ isStopRequested: () => stopRequested,
1602
+ stopTunnel: async (child) => {
1603
+ terminateProcessGroup(child, "SIGTERM", deps);
1604
+ await forceKillProcess(child, 3000, deps);
1605
+ },
1606
+ logPrefix: "[7/7]",
1607
+ });
1608
+ tunnel = tunnelRuntime.tunnel;
1609
+ tunnelLogs = tunnelRuntime.logs;
1610
+ tunnelState = tunnelRuntime.state;
1611
+ tunnelAvailability = createTunnelAvailabilityState({
1612
+ now,
1613
+ publicHealthUrl,
1614
+ localHealthUrl,
1615
+ tunnelState,
1616
+ tunnelLogs,
1617
+ timeoutMs: TUNNEL_AVAILABILITY_TIMEOUT_MS,
1618
+ });
1619
+
1620
+ stdout(`Hire ${hireId} public URL assigned: https://${hostname}`);
1621
+ stdout("Waiting for tunnel availability check...");
1622
+
1623
+ stdout("Checking public tunnel reachability...");
1624
+ let tunnelAvailable = false;
1625
+ for (let i = 0; i < tunnelAvailabilityTimeoutSeconds() && hireRunning; i += 1) {
1626
+ const localOk = await probeHealth(localHealthUrl);
1627
+ if (localOk && await probeHealth(publicHealthUrl, { publicTunnel: true })) {
1628
+ tunnelAvailable = true;
1629
+ stdout("Sandbox is healthy and the public tunnel is reachable; ready for work.");
1630
+ break;
1631
+ }
1632
+ if (localOk) {
1633
+ tunnelAvailability.markUnavailable();
1634
+ }
1635
+ await (stopRequested ? sleep(1000) : interruptibleSleep(1000));
1636
+ }
1637
+
1638
+ if (!tunnelAvailable && hireRunning && !stopRequested) {
1639
+ const localOk = await probeHealth(localHealthUrl);
1640
+ if (localOk) {
1641
+ tunnelAvailability.markUnavailable();
1642
+ } else {
1643
+ stdout(`\n⚠️ Sandbox is not locally healthy, so tunnel availability cannot be verified yet.\n`);
1644
+ }
1645
+ }
1646
+
1647
+ await tick();
1648
+ while (hireRunning) {
1649
+ if (stopRequested) break;
1650
+ const stopped = await interruptibleSleep(60000);
1651
+ if (stopped) break;
1652
+ if (stopRequested) break;
1653
+ if (!hireRunning) break;
1654
+ await tick();
1655
+ }
1656
+
1657
+ if (stopRequested) {
1658
+ stdout("Shutdown requested; stopping local tunnel and sandbox for the current hire.");
1659
+ requestActiveCleanup();
1660
+ }
1661
+ if (activeStopCleanupPromise) {
1662
+ await activeStopCleanupPromise;
1663
+ }
1664
+ if (!cleanedUpRef.value) {
1665
+ stdout("Cleaning up completed hire runtime...");
1666
+ await cleanupCurrentHire();
1667
+ }
1668
+ if (stopRequested) {
1669
+ // Ctrl+C path: the hire is still ACTIVE on the platform. Do NOT call
1670
+ // DELETE /labor/hires/<id>/serve — that would release the platform-side
1671
+ // tunnel and drop the hire's sandbox record while the buyer's hire is
1672
+ // still live. Report an unhealthy heartbeat instead so buyers see the
1673
+ // hire go offline immediately while the platform keeps the recovery
1674
+ // record. The hire's named state volume is also preserved so the seller
1675
+ // can resume with the same agent state on the next `labor-serve`.
1676
+ await reportHireInterrupted();
1677
+ stdout(`Hire ${hireId} interrupted while still active; leaving platform record and state volume intact.`);
1678
+ } else {
1679
+ stdout(`Notifying platform of hire ${hireId} shutdown...`);
1680
+ let teardownNotified = false;
1681
+ try {
1682
+ await withTimeout(
1683
+ requestJson(sellerDeps, "DELETE", `/labor/hires/${hireId}/serve`, {}),
1684
+ 3000,
1685
+ "hire teardown",
1686
+ );
1687
+ teardownNotified = true;
1688
+ stdout("Hire platform teardown notified.");
1689
+ } catch (_err) {
1690
+ stdout("Hire platform notification timed out (best effort).");
1691
+ }
1692
+ // Only reclaim the hire's named state volume once the platform has
1693
+ // accepted teardown — that guarantees no recovery path needs the
1694
+ // agent state any more. Best-effort: keep the volume on failure.
1695
+ if (teardownNotified) {
1696
+ const volumeName = hireStateVolumeName(hireId);
1697
+ if (dockerVolumeExists(volumeName, deps)) {
1698
+ if (dockerRemoveVolume(volumeName, deps)) {
1699
+ stdout(`Removed hire state volume ${volumeName}.`);
1700
+ } else {
1701
+ stdout(`Could not remove hire state volume ${volumeName} (in use?); leaving for manual cleanup.`);
1702
+ }
1703
+ }
1704
+ }
1705
+ }
1706
+ if (activeCleanupCurrentHire === cleanupCurrentHire) {
1707
+ activeCleanupCurrentHire = null;
1708
+ activeStopCleanupPromise = null;
1709
+ }
1710
+ return { hireId, hostname };
1711
+ }
1712
+
1713
+ stop.then(() => {
1714
+ requestStop();
1715
+ });
1716
+
1717
+ let lastRun = null;
1718
+ try {
1719
+ while (!stopRequested) {
1720
+ stdout("[4/7] Waiting for active hire...");
1721
+ const active = await waitForActiveHire();
1722
+ if (!active) break;
1723
+ lastRun = await runHireSandbox(active);
1724
+ if (!stopRequested) {
1725
+ stdout(`Hire ${lastRun.hireId} ended; labor seat remains online for the next hire.`);
1726
+ }
1727
+ }
1728
+ } catch (err) {
1729
+ stdout(`Labor serve failed; stopping local serve/tunnel before exiting. ${err.message || err}`);
1730
+ stopRequested = true;
1731
+ requestActiveCleanup();
1732
+ markLaborSeatOffline();
1733
+ if (activeStopCleanupPromise) {
1734
+ await activeStopCleanupPromise;
1735
+ }
1736
+ throw err;
1737
+ }
1738
+
1739
+ if (stopRequested && laborSeatOfflinePromise) {
1740
+ await laborSeatOfflinePromise;
1741
+ }
1742
+
1743
+ return JSON.stringify(
1744
+ { action: "labor-serve", labor_id: laborId, hostname: lastRun && lastRun.hostname, status: "stopped" },
1745
+ null,
1746
+ 2,
1747
+ );
1748
+ } finally {
1749
+ releaseServeLock();
1750
+ }
1751
+ }
1752
+
1753
+ // ---------------------------------------------------------------------------
1754
+ // labor-cleanup — reclaim stale hire state volumes left on this machine.
1755
+ //
1756
+ // `labor-serve` removes a hire's named state volume after the platform accepts
1757
+ // the hire teardown (the natural-end path). Volumes can still pile up when the
1758
+ // process is interrupted (Ctrl+C, crash, host reboot) before that teardown.
1759
+ // This command lists every `clawlabor-hire-<hireId>-state` volume on the host,
1760
+ // asks the platform which hires are still ACTIVE for the seller's labor
1761
+ // resources, and removes the rest. `--dry-run` reports without deleting.
1762
+ // ---------------------------------------------------------------------------
1763
+ async function commandLaborCleanup(_options, deps, flags) {
1764
+ const dryRun = !(flags && flags.has && flags.has("apply"));
1765
+ const volumes = dockerListHireStateVolumes(deps);
1766
+ const containers = dockerListHireContainers(deps);
1767
+ if (volumes.length === 0 && containers.length === 0) {
1768
+ return JSON.stringify(
1769
+ { action: "labor-cleanup", mode: dryRun ? "dry-run" : "apply", checked: 0, kept: [], removed: [], failed: [] },
1770
+ null,
1771
+ 2,
1772
+ );
1773
+ }
1774
+
1775
+ // Gather all hire IDs that are still ACTIVE across this seller's labor
1776
+ // resources. Volumes for those hires must never be removed — buyers are
1777
+ // still using them.
1778
+ const activeHireIds = new Set();
1779
+ let labors = [];
1780
+ try {
1781
+ let cursor = null;
1782
+ do {
1783
+ const params = new URLSearchParams({ limit: "100" });
1784
+ if (cursor) params.set("cursor", cursor);
1785
+ const page = await requestJson(deps, "GET", `/labor/list?${params.toString()}`);
1786
+ const me = await requestJson(deps, "GET", "/agents/me");
1787
+ const owner = me.agent || me;
1788
+ const ownerId = owner && owner.id ? String(owner.id) : null;
1789
+ const owned = (page.items || []).filter((it) => String(it.seller_agent_id) === ownerId);
1790
+ labors = labors.concat(owned);
1791
+ cursor = page.next_cursor || null;
1792
+ } while (cursor);
1793
+ } catch (err) {
1794
+ throw new Error(`labor-cleanup: could not list seller labors: ${err.message || err}`);
1795
+ }
1796
+ for (const labor of labors) {
1797
+ try {
1798
+ const hires = await activeHiresForLabor(deps, labor.id);
1799
+ for (const hire of hires) {
1800
+ if (hire && hire.id != null) activeHireIds.add(String(hire.id));
1801
+ }
1802
+ } catch (_err) {
1803
+ // If we cannot determine the active set for a labor, fail safe: skip
1804
+ // cleanup entirely by adding a sentinel that prevents any removal.
1805
+ throw new Error(
1806
+ `labor-cleanup: could not list active hires for labor ${labor.id}; aborting to avoid deleting a live hire's state.`,
1807
+ );
1808
+ }
1809
+ }
1810
+
1811
+ const kept = [];
1812
+ const removed = [];
1813
+ const failed = [];
1814
+ for (const containerName of containers) {
1815
+ const hireId = hireIdFromContainerName(containerName);
1816
+ if (!hireId) continue;
1817
+ if (activeHireIds.has(hireId)) {
1818
+ kept.push({ type: "container", container: containerName, reason: "active-hire" });
1819
+ continue;
1820
+ }
1821
+ if (dryRun) {
1822
+ removed.push({ type: "container", container: containerName, hire_id: hireId, dry_run: true });
1823
+ continue;
1824
+ }
1825
+ removeContainerByName(containerName, deps);
1826
+ removed.push({ type: "container", container: containerName, hire_id: hireId });
1827
+ }
1828
+ for (const volume of volumes) {
1829
+ const hireId = hireIdFromVolumeName(volume);
1830
+ if (!hireId) continue;
1831
+ if (activeHireIds.has(hireId)) {
1832
+ kept.push({ type: "volume", volume, reason: "active-hire" });
1833
+ continue;
1834
+ }
1835
+ if (dryRun) {
1836
+ removed.push({ type: "volume", volume, hire_id: hireId, dry_run: true });
1837
+ continue;
1838
+ }
1839
+ if (dockerRemoveVolume(volume, deps)) {
1840
+ removed.push({ type: "volume", volume, hire_id: hireId });
1841
+ } else {
1842
+ failed.push({ type: "volume", volume, hire_id: hireId, reason: "docker volume rm failed (in use?)" });
1843
+ }
1844
+ }
1845
+
1846
+ return JSON.stringify(
1847
+ {
1848
+ action: "labor-cleanup",
1849
+ mode: dryRun ? "dry-run" : "apply",
1850
+ checked: volumes.length + containers.length,
1851
+ active_hires: Array.from(activeHireIds),
1852
+ kept,
1853
+ removed,
1854
+ failed,
1855
+ hint: dryRun ? "Re-run with --apply to delete the listed volumes." : undefined,
1856
+ },
1857
+ null,
1858
+ 2,
1859
+ );
1860
+ }
1861
+
1862
+ module.exports = {
1863
+ commandLaborAgents,
1864
+ commandLaborList,
1865
+ commandHire,
1866
+ commandLaborChat,
1867
+ commandLaborPublish,
1868
+ commandLaborStart,
1869
+ commandLaborUnpublish,
1870
+ commandLaborServe,
1871
+ commandLaborCleanup,
1872
+ parseSseChunks,
1873
+ codexAuthPath,
1874
+ codexConfigPath,
1875
+ codexHomePath,
1876
+ decodeJwtPayload,
1877
+ opencodeAuthPath,
1878
+ resolveCodexChatGptAccount,
1879
+ runtimeStateMounts,
1880
+ runtimeStateInitCommand,
1881
+ sandboxUserCommand,
1882
+ resolveRuntimeSandboxCredentials,
1883
+ hireStateVolumeName,
1884
+ hireIdFromVolumeName,
1885
+ formatLogTimestamp,
1886
+ };