@teamclaws/teamclaw 2026.3.26-2 → 2026.4.2-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 (46) hide show
  1. package/README.md +52 -8
  2. package/cli.mjs +538 -224
  3. package/index.ts +76 -27
  4. package/openclaw.plugin.json +53 -28
  5. package/package.json +5 -2
  6. package/skills/teamclaw/SKILL.md +213 -0
  7. package/skills/teamclaw/references/api-quick-ref.md +117 -0
  8. package/skills/teamclaw-setup/SKILL.md +81 -0
  9. package/skills/teamclaw-setup/references/install-modes.md +136 -0
  10. package/skills/teamclaw-setup/references/validation-checklist.md +73 -0
  11. package/src/config.ts +44 -16
  12. package/src/controller/controller-capacity.ts +2 -2
  13. package/src/controller/controller-service.ts +193 -47
  14. package/src/controller/controller-tools.ts +102 -2
  15. package/src/controller/delivery-report.ts +563 -0
  16. package/src/controller/http-server.ts +1907 -172
  17. package/src/controller/kickoff-orchestrator.ts +292 -0
  18. package/src/controller/managed-gateway-process.ts +330 -0
  19. package/src/controller/orchestration-manifest.ts +69 -1
  20. package/src/controller/preview-manager.ts +676 -0
  21. package/src/controller/prompt-injector.ts +116 -67
  22. package/src/controller/role-inference.ts +41 -0
  23. package/src/controller/websocket.ts +3 -1
  24. package/src/controller/worker-provisioning.ts +429 -74
  25. package/src/discovery.ts +1 -1
  26. package/src/git-collaboration.ts +198 -47
  27. package/src/identity.ts +12 -2
  28. package/src/interaction-contracts.ts +179 -3
  29. package/src/networking.ts +99 -0
  30. package/src/openclaw-workspace.ts +478 -11
  31. package/src/prompt-policy.ts +381 -0
  32. package/src/roles.ts +37 -36
  33. package/src/state.ts +40 -1
  34. package/src/task-executor.ts +282 -78
  35. package/src/types.ts +150 -7
  36. package/src/ui/app.js +1403 -175
  37. package/src/ui/assets/teamclaw-app-icon.png +0 -0
  38. package/src/ui/index.html +122 -40
  39. package/src/ui/style.css +829 -143
  40. package/src/worker/http-handler.ts +40 -4
  41. package/src/worker/prompt-injector.ts +9 -38
  42. package/src/worker/skill-installer.ts +2 -2
  43. package/src/worker/tools.ts +31 -5
  44. package/src/worker/worker-service.ts +49 -8
  45. package/src/workspace-browser.ts +20 -7
  46. package/src/controller/local-worker-manager.ts +0 -533
package/src/discovery.ts CHANGED
@@ -66,7 +66,7 @@ export class MDnsBrowser {
66
66
 
67
67
  browser.on("up", (service) => {
68
68
  const txtRecord = service.txt as Record<string, string> | undefined;
69
- const svcTeamName = txtRecord?.teamName ?? "default";
69
+ const svcTeamName = txtRecord?.teamName ?? "TeamClaw";
70
70
  const host = Array.isArray(service.addresses) && service.addresses.length > 0
71
71
  ? service.addresses[0]
72
72
  : (service.host ?? "localhost");
@@ -4,9 +4,11 @@ import path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
5
  import type { PluginLogger } from "../api.js";
6
6
  import type { GitRepoState, PluginConfig, RepoSyncInfo } from "./types.js";
7
- import { resolveDefaultOpenClawWorkspaceDir } from "./openclaw-workspace.js";
7
+ import { resolveTeamClawWorkspaceDir } from "./openclaw-workspace.js";
8
8
 
9
9
  const TEAMCLAW_IMPORT_REF_PREFIX = "refs/teamclaw/imports";
10
+ const BUNDLE_IMPORT_MAX_RETRIES = 3;
11
+ const BUNDLE_IMPORT_RETRY_DELAY_MS = 2_000;
10
12
  const TEAMCLAW_RUNTIME_EXCLUDES = [
11
13
  ".openclaw/",
12
14
  ".clawhub/",
@@ -51,7 +53,7 @@ export async function ensureControllerGitRepo(
51
53
  config: PluginConfig,
52
54
  logger: PluginLogger,
53
55
  ): Promise<GitRepoState | null> {
54
- const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
56
+ const workspaceDir = resolveTeamClawWorkspaceDir();
55
57
  return await withRepoLock(workspaceDir, async () => ensureControllerGitRepoUnlocked(config, logger, workspaceDir));
56
58
  }
57
59
 
@@ -135,7 +137,7 @@ export async function exportControllerGitBundle(
135
137
  config: PluginConfig,
136
138
  logger: PluginLogger,
137
139
  ): Promise<{ repo: GitRepoState; data: Buffer; filename: string }> {
138
- const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
140
+ const workspaceDir = resolveTeamClawWorkspaceDir();
139
141
  return await withRepoLock(workspaceDir, async () => {
140
142
  const repo = await ensureControllerGitRepoUnlocked(config, logger, workspaceDir);
141
143
  if (!repo?.enabled) {
@@ -170,7 +172,7 @@ export async function importControllerGitBundle(
170
172
  workerId?: string;
171
173
  } = {},
172
174
  ): Promise<RepoImportResult> {
173
- const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
175
+ const workspaceDir = resolveTeamClawWorkspaceDir();
174
176
  return await withRepoLock(workspaceDir, async () => {
175
177
  const repo = await ensureControllerGitRepoUnlocked(config, logger, workspaceDir);
176
178
  if (!repo?.enabled) {
@@ -178,14 +180,21 @@ export async function importControllerGitBundle(
178
180
  }
179
181
 
180
182
  const refreshedBeforeImport = await readGitRepoState(config, repo.remoteReady);
183
+ let hadStashed = false;
181
184
  if (refreshedBeforeImport.dirty) {
182
- return {
183
- merged: false,
184
- fastForwarded: false,
185
- alreadyUpToDate: false,
186
- repo: refreshedBeforeImport,
187
- message: "Controller workspace has uncommitted changes; refusing bundle import until the shared repo is clean.",
188
- };
185
+ logger.info("Controller workspace has uncommitted changes; stashing before bundle import");
186
+ const stashResult = await tryGit(["stash", "--include-untracked"], { cwd: workspaceDir });
187
+ if (stashResult.exitCode === 0) {
188
+ hadStashed = true;
189
+ } else {
190
+ return {
191
+ merged: false,
192
+ fastForwarded: false,
193
+ alreadyUpToDate: false,
194
+ repo: refreshedBeforeImport,
195
+ message: "Controller workspace has uncommitted changes that cannot be stashed; refusing bundle import.",
196
+ };
197
+ }
189
198
  }
190
199
 
191
200
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "teamclaw-import-"));
@@ -215,15 +224,21 @@ export async function importControllerGitBundle(
215
224
  fastForwarded = false;
216
225
  const mergeResult = await tryGit(["merge", "--no-edit", importRef], { cwd: workspaceDir });
217
226
  if (mergeResult.exitCode !== 0) {
227
+ // Both ff-only and regular merge failed — worker history diverged from controller.
228
+ // Fall back to "theirs" strategy to preserve worker changes (the agent just finished work).
218
229
  await abortMergeIfNeeded(workspaceDir);
219
- const currentRepo = await readGitRepoState(config, repo.remoteReady);
220
- return {
221
- merged: false,
222
- fastForwarded: false,
223
- alreadyUpToDate: false,
224
- repo: currentRepo,
225
- message: `Failed to merge worker bundle for task ${meta.taskId ?? "unknown"}: ${formatCommandError("git merge", mergeResult)}`,
226
- };
230
+ const theirsResult = await tryGit(["merge", "--no-edit", "-X", "theirs", importRef], { cwd: workspaceDir });
231
+ if (theirsResult.exitCode !== 0) {
232
+ await abortMergeIfNeeded(workspaceDir);
233
+ const currentRepo = await readGitRepoState(config, repo.remoteReady);
234
+ return {
235
+ merged: false,
236
+ fastForwarded: false,
237
+ alreadyUpToDate: false,
238
+ repo: currentRepo,
239
+ message: `Failed to merge worker bundle for task ${meta.taskId ?? "unknown"} even with theirs strategy: ${formatCommandError("git merge", theirsResult)}`,
240
+ };
241
+ }
227
242
  }
228
243
  }
229
244
 
@@ -238,6 +253,12 @@ export async function importControllerGitBundle(
238
253
  : `Imported worker bundle from ${meta.workerId ?? "worker"} with a merge commit.`,
239
254
  };
240
255
  } finally {
256
+ if (hadStashed) {
257
+ const popResult = await tryGit(["stash", "pop"], { cwd: workspaceDir });
258
+ if (popResult.exitCode !== 0) {
259
+ logger.warn(`Failed to restore stashed changes after bundle import: ${popResult.stderr?.trim() || "unknown error"}`);
260
+ }
261
+ }
241
262
  await tryGit(["update-ref", "-d", importRef], { cwd: workspaceDir });
242
263
  await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {
243
264
  // best-effort temp cleanup
@@ -252,7 +273,7 @@ export async function syncWorkerRepo(
252
273
  controllerUrl: string,
253
274
  repoInfo: RepoSyncInfo,
254
275
  ): Promise<RepoSyncResult> {
255
- const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
276
+ const workspaceDir = resolveTeamClawWorkspaceDir();
256
277
  return await withRepoLock(workspaceDir, async () => syncWorkerRepoUnlocked(config, logger, controllerUrl, repoInfo, workspaceDir));
257
278
  }
258
279
 
@@ -285,7 +306,23 @@ async function syncWorkerRepoUnlocked(
285
306
 
286
307
  const localRepo = await readGitRepoState(config, false);
287
308
  if (localRepo.dirty) {
288
- throw new Error("Worker workspace has uncommitted changes; refusing repo sync until the checkout is clean");
309
+ // Auto-commit leftover changes (e.g. from a prior failed task) instead of
310
+ // blocking sync. This mirrors publishWorkerRepoUnlocked's auto-commit and
311
+ // the controller's stash logic in importControllerGitBundle.
312
+ logger.info("Worker workspace has uncommitted changes; auto-committing before sync");
313
+ await runGit(["add", "-A"], { cwd: workspaceDir });
314
+ const commitResult = await tryGit(
315
+ ["commit", "-m", "chore(teamclaw): auto-commit uncommitted changes before sync"],
316
+ { cwd: workspaceDir },
317
+ );
318
+ if (commitResult.exitCode !== 0) {
319
+ // Commit can fail on a repo with no HEAD yet — fall back to stash
320
+ const stashResult = await tryGit(["stash", "--include-untracked"], { cwd: workspaceDir });
321
+ if (stashResult.exitCode !== 0) {
322
+ throw new Error("Worker workspace has uncommitted changes that cannot be committed or stashed");
323
+ }
324
+ logger.info("Stashed uncommitted changes (no HEAD commit yet)");
325
+ }
289
326
  }
290
327
 
291
328
  if (repoInfo.mode === "remote") {
@@ -300,7 +337,17 @@ async function syncWorkerRepoUnlocked(
300
337
  await checkoutTrackingBranch(workspaceDir, repoInfo.defaultBranch, `refs/remotes/origin/${repoInfo.defaultBranch}`);
301
338
  const mergeResult = await tryGit(["merge", "--ff-only", `refs/remotes/origin/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
302
339
  if (mergeResult.exitCode !== 0) {
303
- throw new Error(`Failed to fast-forward worker checkout from origin/${repoInfo.defaultBranch}: ${formatCommandError("git merge", mergeResult)}`);
340
+ // Worker has local commits that diverge from origin fall back to merge.
341
+ const regularMerge = await tryGit(["merge", "--no-edit", `refs/remotes/origin/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
342
+ if (regularMerge.exitCode !== 0) {
343
+ await abortMergeIfNeeded(workspaceDir);
344
+ const theirsMerge = await tryGit(["merge", "--no-edit", "-X", "theirs", `refs/remotes/origin/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
345
+ if (theirsMerge.exitCode !== 0) {
346
+ await abortMergeIfNeeded(workspaceDir);
347
+ logger.info("Worker repo has unrelated history; resetting to remote branch");
348
+ await runGit(["reset", "--hard", `refs/remotes/origin/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
349
+ }
350
+ }
304
351
  }
305
352
  } else {
306
353
  if (!repoInfo.bundleUrl) {
@@ -321,7 +368,21 @@ async function syncWorkerRepoUnlocked(
321
368
  await checkoutTrackingBranch(workspaceDir, repoInfo.defaultBranch, `refs/remotes/teamclaw/${repoInfo.defaultBranch}`);
322
369
  const mergeResult = await tryGit(["merge", "--ff-only", `refs/remotes/teamclaw/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
323
370
  if (mergeResult.exitCode !== 0) {
324
- throw new Error(`Failed to fast-forward worker checkout from the controller bundle: ${formatCommandError("git merge", mergeResult)}`);
371
+ // Worker has local commits that diverge from controller fall back to merge.
372
+ const regularMerge = await tryGit(["merge", "--no-edit", `refs/remotes/teamclaw/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
373
+ if (regularMerge.exitCode !== 0) {
374
+ await abortMergeIfNeeded(workspaceDir);
375
+ const theirsMerge = await tryGit(["merge", "--no-edit", "-X", "theirs", `refs/remotes/teamclaw/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
376
+ if (theirsMerge.exitCode !== 0) {
377
+ await abortMergeIfNeeded(workspaceDir);
378
+ // Final fallback: worker history is completely unrelated (e.g. fresh
379
+ // container with auto-committed workspace files). Adopt the
380
+ // controller's branch wholesale — this is safe because the
381
+ // controller is the source of truth for repo state.
382
+ logger.info("Worker repo has unrelated history; resetting to controller branch");
383
+ await runGit(["reset", "--hard", `refs/remotes/teamclaw/${repoInfo.defaultBranch}`], { cwd: workspaceDir });
384
+ }
385
+ }
325
386
  }
326
387
  } finally {
327
388
  await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {
@@ -348,7 +409,7 @@ export async function publishWorkerRepo(
348
409
  role?: string;
349
410
  },
350
411
  ): Promise<RepoPublishResult> {
351
- const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
412
+ const workspaceDir = resolveTeamClawWorkspaceDir();
352
413
  return await withRepoLock(workspaceDir, async () => publishWorkerRepoUnlocked(config, logger, controllerUrl, repoInfo, meta, workspaceDir));
353
414
  }
354
415
 
@@ -421,32 +482,51 @@ async function publishWorkerRepoUnlocked(
421
482
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "teamclaw-worker-publish-"));
422
483
  const bundlePath = path.join(tempDir, "worker.bundle");
423
484
  try {
424
- await runGit(["bundle", "create", bundlePath, repoInfo.defaultBranch], { cwd: workspaceDir });
425
- const bundle = await fs.readFile(bundlePath);
426
- const importUrl = new URL(resolveApiUrl(repoInfo.importUrl, controllerUrl));
427
- importUrl.searchParams.set("taskId", meta.taskId);
428
- importUrl.searchParams.set("workerId", meta.workerId);
429
- if (meta.role) {
430
- importUrl.searchParams.set("role", meta.role);
431
- }
485
+ for (let attempt = 0; attempt <= BUNDLE_IMPORT_MAX_RETRIES; attempt++) {
486
+ if (attempt > 0) {
487
+ // Before retry, rebase local worker commits onto the latest controller state
488
+ // so the next bundle is based on the current controller HEAD.
489
+ await new Promise<void>((resolve) => setTimeout(resolve, BUNDLE_IMPORT_RETRY_DELAY_MS * attempt));
490
+ await tryRebaseWorkerOntoController(workspaceDir, repoInfo, controllerUrl, config, logger);
491
+ }
432
492
 
433
- const res = await fetch(importUrl.toString(), {
434
- method: "POST",
435
- headers: { "Content-Type": "application/octet-stream" },
436
- body: bundle,
437
- });
493
+ await runGit(["bundle", "create", bundlePath, repoInfo.defaultBranch], { cwd: workspaceDir });
494
+ const bundle = await fs.readFile(bundlePath);
495
+ const importUrl = new URL(resolveApiUrl(repoInfo.importUrl, controllerUrl));
496
+ importUrl.searchParams.set("taskId", meta.taskId);
497
+ importUrl.searchParams.set("workerId", meta.workerId);
498
+ if (meta.role) {
499
+ importUrl.searchParams.set("role", meta.role);
500
+ }
501
+
502
+ const res = await fetch(importUrl.toString(), {
503
+ method: "POST",
504
+ headers: { "Content-Type": "application/octet-stream" },
505
+ body: bundle,
506
+ });
507
+
508
+ if (res.ok) {
509
+ const payload = await res.json() as { repo?: GitRepoState; message?: string };
510
+ return {
511
+ repo: payload.repo ?? await readGitRepoState(config, false),
512
+ published: true,
513
+ message: payload.message ?? `Imported bundle for task ${meta.taskId}.`,
514
+ };
515
+ }
438
516
 
439
- if (!res.ok) {
440
- const text = await res.text();
441
- throw new Error(`Bundle import failed with status ${res.status}: ${text}`);
517
+ // Only retry on 409 Conflict — other errors are not worth retrying.
518
+ if (res.status !== 409 || attempt === BUNDLE_IMPORT_MAX_RETRIES) {
519
+ const text = await res.text();
520
+ throw new Error(`Bundle import failed with status ${res.status}: ${text}`);
521
+ }
522
+
523
+ logger.warn(
524
+ `Worker: bundle import for task ${meta.taskId} returned 409 (attempt ${attempt + 1}/${BUNDLE_IMPORT_MAX_RETRIES}); rebasing onto controller HEAD and retrying.`,
525
+ );
442
526
  }
443
527
 
444
- const payload = await res.json() as { repo?: GitRepoState; message?: string };
445
- return {
446
- repo: payload.repo ?? await readGitRepoState(config, false),
447
- published: true,
448
- message: payload.message ?? `Imported bundle for task ${meta.taskId}.`,
449
- };
528
+ // Should not reach here, but satisfy TypeScript.
529
+ throw new Error("Bundle import failed after all retries");
450
530
  } finally {
451
531
  await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {
452
532
  // best-effort temp cleanup
@@ -458,7 +538,7 @@ export async function readGitRepoState(
458
538
  config: PluginConfig,
459
539
  remoteReady: boolean,
460
540
  ): Promise<GitRepoState> {
461
- const workspaceDir = resolveDefaultOpenClawWorkspaceDir();
541
+ const workspaceDir = resolveTeamClawWorkspaceDir();
462
542
  const headCommit = await revParseOrEmpty(workspaceDir, "HEAD");
463
543
  const headSummary = headCommit
464
544
  ? (await runGit(["log", "-1", "--pretty=%s"], { cwd: workspaceDir })).stdout.trim() || undefined
@@ -599,6 +679,77 @@ async function abortMergeIfNeeded(workspaceDir: string): Promise<void> {
599
679
  await tryGit(["merge", "--abort"], { cwd: workspaceDir });
600
680
  }
601
681
 
682
+ /**
683
+ * Fetch the latest controller bundle and rebase local commits on top.
684
+ * Used as a recovery step when a bundle import fails with 409 Conflict.
685
+ */
686
+ async function tryRebaseWorkerOntoController(
687
+ workspaceDir: string,
688
+ repoInfo: RepoSyncInfo,
689
+ controllerUrl: string,
690
+ config: PluginConfig,
691
+ logger: PluginLogger,
692
+ ): Promise<void> {
693
+ try {
694
+ if (repoInfo.mode === "shared") {
695
+ return;
696
+ }
697
+
698
+ // Build the bundle URL based on mode
699
+ const bundleUrlSuffix = repoInfo.mode === "remote" ? repoInfo.remoteUrl : repoInfo.bundleUrl;
700
+ if (!bundleUrlSuffix) {
701
+ return;
702
+ }
703
+
704
+ if (repoInfo.mode === "remote") {
705
+ // Remote mode: fetch from origin and rebase
706
+ await runGit(["fetch", "origin", repoInfo.defaultBranch], { cwd: workspaceDir });
707
+ const remoteRef = `refs/remotes/origin/${repoInfo.defaultBranch}`;
708
+ if (!await revParseOrEmpty(workspaceDir, remoteRef)) {
709
+ return;
710
+ }
711
+ const rebaseResult = await tryGit(["rebase", remoteRef], { cwd: workspaceDir });
712
+ if (rebaseResult.exitCode !== 0) {
713
+ await tryGit(["rebase", "--abort"], { cwd: workspaceDir });
714
+ // Fall back to merge if rebase fails
715
+ const mergeResult = await tryGit(["merge", "--no-edit", "-X", "theirs", remoteRef], { cwd: workspaceDir });
716
+ if (mergeResult.exitCode !== 0) {
717
+ await abortMergeIfNeeded(workspaceDir);
718
+ logger.warn("Worker: failed to rebase or merge onto controller state; will retry bundle as-is.");
719
+ }
720
+ }
721
+ } else {
722
+ // Bundle mode: download controller bundle, fetch it, and rebase
723
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "teamclaw-worker-rebase-"));
724
+ const tempBundlePath = path.join(tempDir, "controller.bundle");
725
+ try {
726
+ const res = await fetch(resolveApiUrl(bundleUrlSuffix, controllerUrl));
727
+ if (!res.ok) {
728
+ logger.warn(`Worker: failed to download controller bundle for rebase (status ${res.status}); will retry as-is.`);
729
+ return;
730
+ }
731
+ const buffer = Buffer.from(await res.arrayBuffer());
732
+ await fs.writeFile(tempBundlePath, buffer);
733
+ const remoteBranch = `refs/remotes/teamclaw/${repoInfo.defaultBranch}`;
734
+ await runGit(["fetch", tempBundlePath, `refs/heads/${repoInfo.defaultBranch}:${remoteBranch}`], { cwd: workspaceDir });
735
+ const rebaseResult = await tryGit(["rebase", remoteBranch], { cwd: workspaceDir });
736
+ if (rebaseResult.exitCode !== 0) {
737
+ await tryGit(["rebase", "--abort"], { cwd: workspaceDir });
738
+ const mergeResult = await tryGit(["merge", "--no-edit", "-X", "theirs", remoteBranch], { cwd: workspaceDir });
739
+ if (mergeResult.exitCode !== 0) {
740
+ await abortMergeIfNeeded(workspaceDir);
741
+ logger.warn("Worker: failed to rebase or merge onto controller state; will retry bundle as-is.");
742
+ }
743
+ }
744
+ } finally {
745
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
746
+ }
747
+ }
748
+ } catch (err) {
749
+ logger.warn(`Worker: rebase onto controller failed: ${err instanceof Error ? err.message : String(err)}; will retry bundle as-is.`);
750
+ }
751
+ }
752
+
602
753
  async function pathExists(filePath: string): Promise<boolean> {
603
754
  try {
604
755
  await fs.access(filePath);
package/src/identity.ts CHANGED
@@ -89,8 +89,18 @@ export class IdentityManager {
89
89
 
90
90
  const roleDef = getRole(this.config.role);
91
91
  const workerId = requestedWorkerId ?? generateId();
92
- const localIp = getLocalIp(new URL(controllerUrl).hostname);
93
- const workerUrl = `http://${localIp}:${this.config.port}`;
92
+ const controllerHost = new URL(controllerUrl).hostname;
93
+ // Prefer explicit override (set by provisioner for Docker/K8s workers),
94
+ // then detected IP (works for bridge-network containers and real hosts),
95
+ // then os.hostname() as last resort.
96
+ const advertisedHost =
97
+ process.env.TEAMCLAW_ADVERTISE_HOST?.trim() ||
98
+ getLocalIp(controllerHost) ||
99
+ os.hostname();
100
+ const advertisedPort = process.env.TEAMCLAW_ADVERTISE_PORT?.trim()
101
+ ? Number(process.env.TEAMCLAW_ADVERTISE_PORT.trim())
102
+ : this.config.port;
103
+ const workerUrl = `http://${advertisedHost}:${advertisedPort}`;
94
104
 
95
105
  const registration = createRegistrationRequest(
96
106
  workerId,
@@ -223,6 +223,7 @@ export function normalizeWorkerTaskResultContract(raw: unknown): WorkerTaskResul
223
223
  followUps: normalizeResultFollowUps(input.followUps),
224
224
  questions: normalizeContractStringList(input.questions),
225
225
  notes: normalizeOptionalContractText(input.notes),
226
+ discoveredPatterns: normalizeContractStringList(input.discoveredPatterns),
226
227
  };
227
228
  }
228
229
 
@@ -315,7 +316,12 @@ function normalizeResultDeliverables(raw: unknown): WorkerTaskResultDeliverable[
315
316
  : "note";
316
317
  const value = typeof entry.value === "string" ? entry.value.trim() : "";
317
318
  const summary = normalizeOptionalContractText(entry.summary);
318
- return value ? { kind, value, summary } : null;
319
+ const artifactType = typeof entry.artifactType === "string" ? entry.artifactType as WorkerTaskResultDeliverable["artifactType"] : undefined;
320
+ const previewCommand = normalizeOptionalContractText(entry.previewCommand);
321
+ const previewCwd = normalizeOptionalContractText(entry.previewCwd);
322
+ const previewReadyPath = normalizeOptionalContractText(entry.previewReadyPath);
323
+ const liveUrl = normalizeOptionalContractText(entry.liveUrl);
324
+ return value ? { kind, value, summary, artifactType, previewCommand, previewCwd, previewReadyPath, liveUrl } : null;
319
325
  })
320
326
  .filter((entry): entry is WorkerTaskResultDeliverable => !!entry);
321
327
  }
@@ -426,6 +432,30 @@ function extractQuestionOrBulletLines(text: string, maxItems: number, matcher?:
426
432
  return Array.from(new Set(lines.map((line) => summarizeContractText(line, 220)))).slice(0, maxItems);
427
433
  }
428
434
 
435
+ const WEB_HTML_FILENAMES = new Set(["index.html", "app.html", "main.html", "home.html"]);
436
+
437
+ /**
438
+ * Default preview command used when the worker does not provide one.
439
+ * This is a minimal static file server — the worker (LLM) is responsible
440
+ * for providing the real, framework-appropriate command via previewCommand
441
+ * in its result contract.
442
+ */
443
+ function inferPreviewCommand(_cwd: string, _resultText: string, _filePaths: string[]): string {
444
+ return "npx -y serve -l {PORT}";
445
+ }
446
+
447
+ function isWebHtmlPath(filePath: string): boolean {
448
+ const segments = filePath.replace(/\\/gu, "/").split("/");
449
+ const filename = segments[segments.length - 1] ?? "";
450
+ if (!filename.toLowerCase().endsWith(".html") && !filename.toLowerCase().endsWith(".htm")) {
451
+ return false;
452
+ }
453
+ if (filename.startsWith(".") || filename.includes(".config.") || filename.includes(".test.") || filename.includes(".spec.")) {
454
+ return false;
455
+ }
456
+ return true;
457
+ }
458
+
429
459
  function inferResultDeliverables(result: string, error?: string): WorkerTaskResultDeliverable[] {
430
460
  if (error) {
431
461
  return [{
@@ -436,10 +466,31 @@ function inferResultDeliverables(result: string, error?: string): WorkerTaskResu
436
466
  }
437
467
 
438
468
  const deliverables: WorkerTaskResultDeliverable[] = [];
439
- const pathMatches = Array.from(result.matchAll(/(?:^|[\s:(])([A-Za-z0-9_./-]+\.[A-Za-z0-9_-]+)/g))
469
+ let webAppInferred = false;
470
+ const pathMatches = Array.from(result.matchAll(/(?:^|[\s:(`])([A-Za-z0-9_./-]+\.[A-Za-z0-9_-]+)/g))
440
471
  .map((match) => match[1])
441
472
  .filter(Boolean);
442
- for (const filePath of Array.from(new Set(pathMatches)).slice(0, 5)) {
473
+ const uniquePaths = Array.from(new Set(pathMatches)).slice(0, 5);
474
+ for (const filePath of uniquePaths) {
475
+ if (!webAppInferred && isWebHtmlPath(filePath)) {
476
+ const segments = filePath.replace(/\\/gu, "/").split("/");
477
+ const filename = segments[segments.length - 1] ?? "";
478
+ const dirname = segments.length > 1 ? segments.slice(0, -1).join("/") : ".";
479
+ const isTopLevelHtml = WEB_HTML_FILENAMES.has(filename);
480
+ if (isTopLevelHtml || filename.toLowerCase().endsWith(".html")) {
481
+ webAppInferred = true;
482
+ deliverables.push({
483
+ kind: "directory",
484
+ value: dirname,
485
+ summary: `Web application at ${dirname}`,
486
+ artifactType: "web-app",
487
+ previewCommand: inferPreviewCommand(dirname, result, uniquePaths),
488
+ previewCwd: dirname,
489
+ previewReadyPath: "/",
490
+ });
491
+ continue;
492
+ }
493
+ }
443
494
  deliverables.push({
444
495
  kind: "file",
445
496
  value: filePath,
@@ -457,3 +508,128 @@ function inferResultDeliverables(result: string, error?: string): WorkerTaskResu
457
508
  summary: "Backfilled from the worker's final reply.",
458
509
  }];
459
510
  }
511
+
512
+ /**
513
+ * Enrich existing deliverables with preview fields (artifactType, previewCommand, etc.)
514
+ * by inferring from the raw result text.
515
+ *
516
+ * This function only enriches when the worker did NOT already provide these fields.
517
+ * When the worker (LLM) submits a proper result contract with previewCommand and
518
+ * artifactType, we trust it completely — it knows its own code better than we do.
519
+ *
520
+ * Returns the contract with enriched deliverables if any enrichment happened, or null
521
+ * if no enrichment was needed.
522
+ */
523
+ export function enrichDeliverablesWithPreviewInference(
524
+ contract: WorkerTaskResultContract,
525
+ resultText: string,
526
+ ): WorkerTaskResultContract | null {
527
+ const { deliverables } = contract;
528
+ const existingWebApp = deliverables.find((d) => d.artifactType === "web-app");
529
+ if (existingWebApp) {
530
+ // Worker already provided a web-app with a real previewCommand — trust it.
531
+ if (existingWebApp.previewCommand?.trim()) {
532
+ return null;
533
+ }
534
+ // Worker declared web-app but no command — try to provide a fallback command.
535
+ const cwd = existingWebApp.previewCwd?.trim();
536
+ if (cwd && cwd !== "." && cwd !== "./") {
537
+ // Has a real cwd but no command — provide generic static serve fallback
538
+ existingWebApp.previewCommand = inferPreviewCommand(cwd, resultText, deliverables.map((d) => d.value ?? ""));
539
+ return { ...contract, deliverables };
540
+ }
541
+ }
542
+
543
+ // Strategy 1: Extract file paths from result text to detect HTML files
544
+ const allSources = [resultText, ...deliverables.map((d) => d.summary ?? "")];
545
+ const pathMatches = Array.from(
546
+ allSources.join("\n").matchAll(/(?:^|[\s:(,`])([A-Za-z0-9_./-]+\.[A-Za-z0-9_-]+)/g),
547
+ )
548
+ .map((match) => match[1])
549
+ .filter(Boolean);
550
+
551
+ for (const filePath of Array.from(new Set(pathMatches)).slice(0, 5)) {
552
+ if (isWebHtmlPath(filePath)) {
553
+ const segments = filePath.replace(/\\/gu, "/").split("/");
554
+ const filename = segments[segments.length - 1] ?? "";
555
+ const dirname = segments.length > 1 ? segments.slice(0, -1).join("/") : ".";
556
+ if (!filename.toLowerCase().endsWith(".html") && !filename.toLowerCase().endsWith(".htm")) {
557
+ continue;
558
+ }
559
+ const allDeliverablePaths = deliverables.map((d) => d.value ?? "");
560
+ const matchingIndex = deliverables.findIndex((d) =>
561
+ d.kind === "directory"
562
+ && filePath.replace(/\\/gu, "/").startsWith(
563
+ d.value?.replace(/\\/gu, "/").replace(/\/$/u, ""),
564
+ ),
565
+ );
566
+ if (matchingIndex >= 0) {
567
+ deliverables[matchingIndex] = {
568
+ ...deliverables[matchingIndex],
569
+ artifactType: "web-app",
570
+ previewCommand: inferPreviewCommand(dirname, resultText, allDeliverablePaths),
571
+ previewCwd: dirname,
572
+ previewReadyPath: "/",
573
+ summary: deliverables[matchingIndex].summary || `Web application at ${dirname}`,
574
+ };
575
+ } else {
576
+ deliverables.push({
577
+ kind: "directory",
578
+ value: dirname,
579
+ summary: `Web application at ${dirname}`,
580
+ artifactType: "web-app",
581
+ previewCommand: inferPreviewCommand(dirname, resultText, allDeliverablePaths),
582
+ previewCwd: dirname,
583
+ previewReadyPath: "/",
584
+ });
585
+ }
586
+ return { ...contract, deliverables };
587
+ }
588
+ }
589
+
590
+ // Strategy 2: If result text mentions HTML/web/blog/site keywords and there's a
591
+ // directory deliverable without artifactType, infer web-app from the directory itself.
592
+ const webKeywords = /\b(html|web\s*app|web\s*site|blog|index\.html|app\.html|homepage)\b/i;
593
+ const hasWebKeywords = webKeywords.test(resultText) || deliverables.some((d) => webKeywords.test(d.summary ?? ""));
594
+
595
+ if (hasWebKeywords) {
596
+ const dirDeliverable = deliverables.find(
597
+ (d) => d.kind === "directory" && !d.artifactType && d.value,
598
+ );
599
+ if (dirDeliverable) {
600
+ const cwd = dirDeliverable.value.replace(/\/$/u, "");
601
+ const allDeliverablePaths = deliverables.map((d) => d.value ?? "");
602
+ deliverables[deliverables.indexOf(dirDeliverable)] = {
603
+ ...dirDeliverable,
604
+ artifactType: "web-app",
605
+ previewCommand: inferPreviewCommand(cwd, resultText, allDeliverablePaths),
606
+ previewCwd: cwd,
607
+ previewReadyPath: "/",
608
+ summary: dirDeliverable.summary || `Web application at ${cwd}`,
609
+ };
610
+ return { ...contract, deliverables };
611
+ }
612
+ }
613
+
614
+ // Strategy 3: Extract directory paths that contain HTML files from result text
615
+ // (e.g. "blog/index.html" → directory "blog"). This handles cases where deliverables
616
+ // are all "note" type but result text references HTML files in directories.
617
+ const dirHtmlPattern = /([A-Za-z0-9_.-]+\/)[A-Za-z0-9_.-]+\.html\b/g;
618
+ const dirMatches = Array.from(resultText.matchAll(dirHtmlPattern)).map((m) => m[1].replace(/\/$/u, ""));
619
+ const uniqueDirs = Array.from(new Set(dirMatches));
620
+ if (uniqueDirs.length > 0 && hasWebKeywords) {
621
+ const dirname = uniqueDirs[0];
622
+ deliverables.push({
623
+ kind: "directory",
624
+ value: dirname,
625
+ summary: `Web application at ${dirname}`,
626
+ artifactType: "web-app",
627
+ previewCommand: inferPreviewCommand(dirname, resultText, [dirname]),
628
+ previewCwd: dirname,
629
+ previewReadyPath: "/",
630
+ });
631
+ return { ...contract, deliverables };
632
+ }
633
+
634
+ return null;
635
+ }