@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
@@ -12,14 +12,22 @@ import type { PluginLogger } from "../../api.js";
12
12
  import { generateId } from "../protocol.js";
13
13
  import {
14
14
  resolveDefaultOpenClawConfigPath,
15
+ resolveDefaultOpenClawStateDir,
15
16
  resolveDefaultTeamClawRuntimeRootDir,
17
+ resolveDefaultAgentDir,
18
+ resolveTeamClawAgentDir,
19
+ resolveTeamClawAgentWorkspaceRootDir,
20
+ resolveTeamClawWorkspaceDir,
21
+ TEAMCLAW_AGENT_ID,
16
22
  } from "../openclaw-workspace.js";
17
23
  import { ROLES } from "../roles.js";
24
+ import { inferTaskRole } from "./role-inference.js";
18
25
  import type {
19
26
  PluginConfig,
20
27
  ProvisionedWorkerRecord,
21
28
  ProvisionedWorkerStatus,
22
29
  RoleId,
30
+ StartupProvisioningReadiness,
23
31
  TaskInfo,
24
32
  TeamProvisioningState,
25
33
  TeamState,
@@ -33,6 +41,8 @@ const DEFAULT_DOCKER_BUNDLED_TEAMCLAW_PLUGIN_DIR = "/app/extensions/teamclaw";
33
41
  const PROVISIONING_RECORD_RETENTION_MS = 6 * 60 * 60 * 1000;
34
42
  const PROVISIONING_FAILURE_COOLDOWN_MS = 30_000;
35
43
  const PROCESS_TERMINATION_TIMEOUT_MS = 10_000;
44
+ const STARTUP_READINESS_POLL_INTERVAL_MS = 1_000;
45
+ const STARTUP_READINESS_RETRY_INTERVAL_MS = 5_000;
36
46
  const DOCKER_API_VERSION = resolveDockerApiVersion();
37
47
 
38
48
  export type WorkerProvisioningManagerDeps = {
@@ -40,6 +50,8 @@ export type WorkerProvisioningManagerDeps = {
40
50
  logger: PluginLogger;
41
51
  getTeamState: () => TeamState | null;
42
52
  updateTeamState: (updater: (state: TeamState) => void) => TeamState;
53
+ /** Actual HTTP port once the controller server has bound (may differ from config.port). */
54
+ actualControllerPort?: number;
43
55
  };
44
56
 
45
57
  type LaunchSpec = {
@@ -49,6 +61,7 @@ type LaunchSpec = {
49
61
  controllerUrl: string;
50
62
  workerPort: number;
51
63
  gatewayPort: number;
64
+ publishedHostPort?: number;
52
65
  workspaceDir?: string;
53
66
  env: Record<string, string>;
54
67
  configJson: string;
@@ -68,7 +81,7 @@ interface WorkerProvisionerBackend {
68
81
  }
69
82
 
70
83
  export class WorkerProvisioningManager {
71
- private readonly deps: WorkerProvisioningManagerDeps;
84
+ private deps: WorkerProvisioningManagerDeps;
72
85
  private readonly backend: WorkerProvisionerBackend | null;
73
86
  private baseConfigPromise: Promise<Record<string, unknown>> | null = null;
74
87
  private reconcilePromise: Promise<void> | null = null;
@@ -80,6 +93,11 @@ export class WorkerProvisioningManager {
80
93
  this.backend = createProvisionerBackend(deps.config, deps.logger);
81
94
  }
82
95
 
96
+ /** Update the actual controller port after the HTTP server binds. */
97
+ setActualPort(port: number): void {
98
+ this.deps = { ...this.deps, actualControllerPort: port };
99
+ }
100
+
83
101
  isEnabled(): boolean {
84
102
  return this.backend !== null;
85
103
  }
@@ -88,6 +106,12 @@ export class WorkerProvisioningManager {
88
106
  return Boolean(this.deps.getTeamState()?.provisioning?.workers?.[workerId]);
89
107
  }
90
108
 
109
+ /** Returns true when the worker was provisioned as a local process (shared filesystem). */
110
+ isSharedWorkspaceWorker(workerId: string): boolean {
111
+ const record = this.deps.getTeamState()?.provisioning?.workers?.[workerId];
112
+ return Boolean(record && record.provider === "process");
113
+ }
114
+
91
115
  syncState(state: TeamState): boolean {
92
116
  if (!this.backend) {
93
117
  return false;
@@ -95,6 +119,23 @@ export class WorkerProvisioningManager {
95
119
  return this.refreshProvisioningState(state, Date.now());
96
120
  }
97
121
 
122
+ primeStartupReadiness(): void {
123
+ if (!this.backend || this.stopped) {
124
+ return;
125
+ }
126
+ const now = Date.now();
127
+ const previousAttempts = this.deps.getTeamState()?.provisioning?.startupReadiness?.attempts ?? 0;
128
+ this.updateStartupReadiness({
129
+ status: "checking",
130
+ startedAt: now,
131
+ checkedAt: now,
132
+ attempts: previousAttempts,
133
+ requiredRoles: this.determineStartupReadinessRoles(),
134
+ readyWorkerIds: [],
135
+ message: "Controller started; waiting for startup provisioning warm-up.",
136
+ });
137
+ }
138
+
98
139
  validateRegistration(
99
140
  workerId: string,
100
141
  role: RoleId,
@@ -207,6 +248,82 @@ export class WorkerProvisioningManager {
207
248
  return this.reconcilePromise;
208
249
  }
209
250
 
251
+ async runStartupReadinessCheck(): Promise<void> {
252
+ if (!this.backend || this.stopped) {
253
+ return;
254
+ }
255
+
256
+ const requiredRoles = this.determineStartupReadinessRoles();
257
+ const previousAttempts = this.deps.getTeamState()?.provisioning?.startupReadiness?.attempts ?? 0;
258
+ const startedAt = Date.now();
259
+ this.updateStartupReadiness({
260
+ status: "checking",
261
+ startedAt,
262
+ checkedAt: startedAt,
263
+ attempts: previousAttempts + 1,
264
+ requiredRoles,
265
+ readyWorkerIds: [],
266
+ message: `Warming startup workers for ${requiredRoles.join(", ")}.`,
267
+ });
268
+
269
+ try {
270
+ await this.ensureStartupProvisioningPrerequisites();
271
+ await this.ensureWarmWorkersForRoles(requiredRoles, "startup readiness warmup");
272
+ } catch (err) {
273
+ const message = err instanceof Error ? err.message : String(err);
274
+ this.updateStartupReadiness({
275
+ status: "degraded",
276
+ startedAt,
277
+ checkedAt: Date.now(),
278
+ attempts: previousAttempts + 1,
279
+ requiredRoles,
280
+ readyWorkerIds: [],
281
+ message,
282
+ });
283
+ this.deps.logger.warn(`Provisioner: startup readiness prerequisites failed: ${message}`);
284
+ return;
285
+ }
286
+
287
+ const deadline = Date.now() + this.deps.config.workerProvisioningStartupTimeoutMs;
288
+ let lastRetryAt = 0;
289
+ while (!this.stopped && Date.now() < deadline) {
290
+ const readiness = this.collectRoleReadiness(requiredRoles);
291
+ if (readiness.missingRoles.length === 0) {
292
+ this.updateStartupReadiness({
293
+ status: "ready",
294
+ startedAt,
295
+ checkedAt: Date.now(),
296
+ attempts: previousAttempts + 1,
297
+ requiredRoles,
298
+ readyWorkerIds: readiness.readyWorkerIds,
299
+ message: `Startup provisioning ready with ${readiness.readyWorkerIds.length} warm worker(s).`,
300
+ });
301
+ return;
302
+ }
303
+
304
+ if (Date.now() - lastRetryAt >= STARTUP_READINESS_RETRY_INTERVAL_MS) {
305
+ lastRetryAt = Date.now();
306
+ await this.ensureWarmWorkersForRoles(readiness.missingRoles, "startup readiness retry");
307
+ await this.requestReconcile("startup readiness poll");
308
+ }
309
+
310
+ await sleep(STARTUP_READINESS_POLL_INTERVAL_MS);
311
+ }
312
+
313
+ const finalReadiness = this.collectRoleReadiness(requiredRoles);
314
+ const failureDetails = this.describeStartupReadinessFailure(finalReadiness.missingRoles);
315
+ this.updateStartupReadiness({
316
+ status: "degraded",
317
+ startedAt,
318
+ checkedAt: Date.now(),
319
+ attempts: previousAttempts + 1,
320
+ requiredRoles,
321
+ readyWorkerIds: finalReadiness.readyWorkerIds,
322
+ message: failureDetails,
323
+ });
324
+ this.deps.logger.warn(`Provisioner: startup readiness degraded: ${failureDetails}`);
325
+ }
326
+
210
327
  async stop(): Promise<void> {
211
328
  if (!this.backend) {
212
329
  return;
@@ -286,6 +403,38 @@ export class WorkerProvisioningManager {
286
403
  await this.scaleDownIdleWorkers(now);
287
404
  }
288
405
 
406
+ private determineStartupReadinessRoles(): RoleId[] {
407
+ const configured = this.deps.config.workerProvisioningRoles;
408
+ if (configured.length > 0) {
409
+ return [...new Set(configured)];
410
+ }
411
+ return ["developer"];
412
+ }
413
+
414
+ private async ensureStartupProvisioningPrerequisites(): Promise<void> {
415
+ await ensureWritableDirectory(resolveTeamClawAgentWorkspaceRootDir());
416
+ await ensureWritableDirectory(resolveTeamClawWorkspaceDir());
417
+ await ensureWritableDirectory(resolveDefaultTeamClawRuntimeRootDir());
418
+ if (this.deps.config.workerProvisioningType === "process") {
419
+ await ensureWritableDirectory(resolveTeamClawAgentDir());
420
+ }
421
+ }
422
+
423
+ private async ensureWarmWorkersForRoles(roles: RoleId[], reason: string): Promise<void> {
424
+ const state = this.deps.getTeamState();
425
+ for (const role of roles) {
426
+ if (this.hasActiveOrLaunchingWorkerForRole(state, role)) {
427
+ continue;
428
+ }
429
+ if (this.hasRecentProvisioningFailure(role)) {
430
+ this.deps.logger.warn(`Provisioner: startup warmup skipped ${role} due to recent failure`);
431
+ continue;
432
+ }
433
+ this.deps.logger.info(`Provisioner: warming ${role} worker (${reason})`);
434
+ await this.launchWorker(role);
435
+ }
436
+ }
437
+
289
438
  private async expireStalledLaunches(now: number): Promise<void> {
290
439
  const state = this.deps.getTeamState();
291
440
  if (!state?.provisioning) {
@@ -355,6 +504,54 @@ export class WorkerProvisioningManager {
355
504
  );
356
505
  }
357
506
 
507
+ private hasActiveOrLaunchingWorkerForRole(state: TeamState | null, role: RoleId): boolean {
508
+ if (!state) {
509
+ return false;
510
+ }
511
+ const activeWorker = Object.values(state.workers).some((worker) =>
512
+ worker.role === role && worker.status !== "offline"
513
+ );
514
+ if (activeWorker) {
515
+ return true;
516
+ }
517
+ return Object.values(state.provisioning?.workers ?? {}).some((record) =>
518
+ record.role === role && (record.status === "launching" || record.status === "registered")
519
+ );
520
+ }
521
+
522
+ private collectRoleReadiness(roles: RoleId[]): { readyWorkerIds: string[]; missingRoles: RoleId[] } {
523
+ const state = this.deps.getTeamState();
524
+ const readyWorkerIds: string[] = [];
525
+ const missingRoles: RoleId[] = [];
526
+ for (const role of roles) {
527
+ const worker = Object.values(state?.workers ?? {}).find((candidate) =>
528
+ candidate.role === role && (candidate.status === "idle" || candidate.status === "busy")
529
+ );
530
+ if (worker) {
531
+ readyWorkerIds.push(worker.id);
532
+ } else {
533
+ missingRoles.push(role);
534
+ }
535
+ }
536
+ return { readyWorkerIds, missingRoles };
537
+ }
538
+
539
+ private describeStartupReadinessFailure(missingRoles: RoleId[]): string {
540
+ const records = Object.values(this.deps.getTeamState()?.provisioning?.workers ?? {});
541
+ const relevant = records.filter((record) => missingRoles.includes(record.role));
542
+ const detail = relevant
543
+ .map((record) => `${record.role}:${record.status}${record.lastError ? ` (${record.lastError})` : ""}`)
544
+ .join(", ");
545
+ const suffix = detail ? ` Latest records: ${detail}` : "";
546
+ return `Startup worker readiness failed for roles: ${missingRoles.join(", ")}.${suffix}`;
547
+ }
548
+
549
+ private updateStartupReadiness(readiness: StartupProvisioningReadiness): void {
550
+ this.deps.updateTeamState((state) => {
551
+ ensureProvisioningState(state).startupReadiness = readiness;
552
+ });
553
+ }
554
+
358
555
  private async launchWorker(role: RoleId): Promise<void> {
359
556
  if (!this.backend) {
360
557
  return;
@@ -373,6 +570,12 @@ export class WorkerProvisioningManager {
373
570
  const gatewayPort = requiresDedicatedHostPorts
374
571
  ? await reserveEphemeralPort()
375
572
  : DEFAULT_CONTAINER_GATEWAY_PORT;
573
+ // Docker bridge networks need a host port to publish so the controller
574
+ // (running on the host) can reach the worker container.
575
+ const needsPublishedPort =
576
+ this.backend.type === "docker" &&
577
+ !isDockerHostNetwork(this.deps.config.workerProvisioningDockerNetwork);
578
+ const publishedHostPort = needsPublishedPort ? await reserveEphemeralPort() : undefined;
376
579
  const now = Date.now();
377
580
 
378
581
  this.deps.updateTeamState((state) => {
@@ -403,6 +606,7 @@ export class WorkerProvisioningManager {
403
606
  controllerUrl,
404
607
  workerPort,
405
608
  gatewayPort,
609
+ publishedHostPort,
406
610
  workspaceDir: getConfiguredWorkerWorkspaceDir(workerConfig),
407
611
  env: this.buildForwardedEnv(controllerUrl),
408
612
  configJson: `${JSON.stringify(workerConfig, null, 2)}\n`,
@@ -539,29 +743,7 @@ export class WorkerProvisioningManager {
539
743
  }
540
744
 
541
745
  private inferTaskRole(task: TaskInfo): RoleId | null {
542
- if (task.assignedRole) {
543
- return task.assignedRole;
544
- }
545
-
546
- const text = `${task.title} ${task.description}`.toLowerCase();
547
- let bestRole: RoleId | null = null;
548
- let bestScore = 0;
549
-
550
- for (const role of ROLES) {
551
- const roleTokens = [
552
- role.id,
553
- role.label,
554
- ...role.capabilities,
555
- ].flatMap((entry) => entry.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length > 2));
556
- const uniqueTokens = [...new Set(roleTokens)];
557
- const score = uniqueTokens.reduce((count, token) => count + (text.includes(token) ? 1 : 0), 0);
558
- if (score > bestScore) {
559
- bestScore = score;
560
- bestRole = role.id;
561
- }
562
- }
563
-
564
- return bestScore > 0 ? bestRole : null;
746
+ return inferTaskRole(task);
565
747
  }
566
748
 
567
749
  private computeRoleDemand(state: TeamState, role: RoleId): number {
@@ -588,12 +770,15 @@ export class WorkerProvisioningManager {
588
770
  }
589
771
 
590
772
  private resolveControllerUrl(): string {
773
+ if (this.backend?.type === "process") {
774
+ // Process workers run on the same host — always use loopback regardless
775
+ // of workerProvisioningControllerUrl (which may target Docker DNS).
776
+ const port = this.deps.actualControllerPort ?? this.deps.config.port;
777
+ return `http://127.0.0.1:${port}`;
778
+ }
591
779
  if (this.deps.config.workerProvisioningControllerUrl) {
592
780
  return this.deps.config.workerProvisioningControllerUrl;
593
781
  }
594
- if (this.backend?.type === "process") {
595
- return `http://127.0.0.1:${this.deps.config.port}`;
596
- }
597
782
  throw new Error(
598
783
  `workerProvisioningControllerUrl is required when workerProvisioningType=${this.backend?.type}`,
599
784
  );
@@ -702,6 +887,7 @@ class ProcessProvisioner implements WorkerProvisionerBackend {
702
887
  OPENCLAW_SKIP_CANVAS_HOST: "1",
703
888
  TEAMCLAW_WORKER_ID: spec.workerId,
704
889
  TEAMCLAW_LAUNCH_TOKEN: spec.launchToken,
890
+ ...(spec.workspaceDir ? { TEAMCLAW_WORKSPACE_DIR: spec.workspaceDir } : {}),
705
891
  },
706
892
  stdio: ["ignore", "pipe", "pipe"],
707
893
  });
@@ -773,7 +959,7 @@ class DockerProvisioner implements WorkerProvisionerBackend {
773
959
  }
774
960
 
775
961
  const instanceName = buildManagedInstanceName(this.config.teamName, spec.role, spec.workerId);
776
- const env = {
962
+ const env: Record<string, string> = {
777
963
  ...spec.env,
778
964
  HOME: "/home/node",
779
965
  OPENCLAW_HOME: "/home/node",
@@ -786,25 +972,52 @@ class DockerProvisioner implements WorkerProvisionerBackend {
786
972
  ...(spec.workspaceDir ? { TEAMCLAW_WORKSPACE_DIR: spec.workspaceDir } : {}),
787
973
  };
788
974
 
789
- const script = buildContainerBootstrapScript();
975
+ // When workers share a Docker network with the controller container, advertise
976
+ // the container name and internal worker port so sibling containers can reach it
977
+ // directly. When the controller runs on the host, fall back to publishing a host
978
+ // port and advertising localhost for host-side callbacks.
979
+ const usePortPublishing = spec.publishedHostPort !== undefined;
980
+ if (this.config.workerProvisioningDockerNetwork) {
981
+ env.TEAMCLAW_ADVERTISE_HOST = instanceName;
982
+ env.TEAMCLAW_ADVERTISE_PORT = String(spec.workerPort);
983
+ } else if (usePortPublishing) {
984
+ env.TEAMCLAW_ADVERTISE_HOST = "localhost";
985
+ env.TEAMCLAW_ADVERTISE_PORT = String(spec.publishedHostPort);
986
+ }
987
+
988
+ const hostConfig: Record<string, unknown> = {
989
+ Binds: buildDockerBinds(this.config),
990
+ NetworkMode: this.config.workerProvisioningDockerNetwork || undefined,
991
+ };
992
+
993
+ if (usePortPublishing) {
994
+ hostConfig.PortBindings = {
995
+ [`${spec.workerPort}/tcp`]: [{ HostPort: String(spec.publishedHostPort) }],
996
+ };
997
+ }
998
+
999
+ const containerConfig: Record<string, unknown> = {
1000
+ Image: this.config.workerProvisioningImage,
1001
+ Cmd: ["sh", "-lc", buildContainerBootstrapScript()],
1002
+ Env: Object.entries(env).map(([key, value]) => `${key}=${value}`),
1003
+ User: "root",
1004
+ Labels: {
1005
+ "teamclaw.managed": "true",
1006
+ "teamclaw.team": this.config.teamName,
1007
+ "teamclaw.role": spec.role,
1008
+ "teamclaw.worker_id": spec.workerId,
1009
+ },
1010
+ HostConfig: hostConfig,
1011
+ };
1012
+
1013
+ if (usePortPublishing) {
1014
+ containerConfig.ExposedPorts = { [`${spec.workerPort}/tcp`]: {} };
1015
+ }
1016
+
790
1017
  const response = await this.client.requestJson<{ Id?: string }>(
791
1018
  "POST",
792
1019
  `/containers/create?name=${encodeURIComponent(instanceName)}`,
793
- {
794
- Image: this.config.workerProvisioningImage,
795
- Cmd: ["sh", "-lc", script],
796
- Env: Object.entries(env).map(([key, value]) => `${key}=${value}`),
797
- Labels: {
798
- "teamclaw.managed": "true",
799
- "teamclaw.team": this.config.teamName,
800
- "teamclaw.role": spec.role,
801
- "teamclaw.worker_id": spec.workerId,
802
- },
803
- HostConfig: {
804
- Binds: buildDockerBinds(this.config),
805
- NetworkMode: this.config.workerProvisioningDockerNetwork || undefined,
806
- },
807
- },
1020
+ containerConfig,
808
1021
  [201],
809
1022
  );
810
1023
 
@@ -831,14 +1044,109 @@ class DockerProvisioner implements WorkerProvisionerBackend {
831
1044
  }
832
1045
  }
833
1046
 
1047
+ /**
1048
+ * Lightweight Kubernetes API client using in-cluster service account or
1049
+ * kubeconfig for out-of-cluster (via kubectl proxy or direct API).
1050
+ * Avoids requiring kubectl binary in the container image.
1051
+ */
1052
+ class K8sApiClient {
1053
+ private token: string | undefined;
1054
+ private caCert: Buffer | undefined;
1055
+ private apiServer: string;
1056
+ private contextArgs: string;
1057
+
1058
+ constructor(context: string, private readonly logger: PluginLogger) {
1059
+ this.contextArgs = context;
1060
+ // In-cluster detection: K8s injects these env vars and mounts SA token
1061
+ const host = process.env.KUBERNETES_SERVICE_HOST;
1062
+ const port = process.env.KUBERNETES_SERVICE_PORT;
1063
+ if (host && port) {
1064
+ this.apiServer = `https://${host}:${port}`;
1065
+ try {
1066
+ const fs = require("node:fs");
1067
+ this.token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8").trim();
1068
+ this.caCert = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
1069
+ } catch {
1070
+ logger.warn("K8sApiClient: in-cluster but cannot read service account token — falling back to kubectl");
1071
+ }
1072
+ } else {
1073
+ // Out-of-cluster: delegate to kubectl
1074
+ this.apiServer = "";
1075
+ }
1076
+ }
1077
+
1078
+ private get inCluster(): boolean {
1079
+ return Boolean(this.apiServer && this.token);
1080
+ }
1081
+
1082
+ async createPod(namespace: string, manifest: unknown): Promise<void> {
1083
+ if (this.inCluster) {
1084
+ await this.apiRequest("POST", `/api/v1/namespaces/${namespace}/pods`, manifest, [200, 201]);
1085
+ } else {
1086
+ await runCommand("kubectl", [
1087
+ ...buildKubectlContextArgs(this.contextArgs),
1088
+ "apply", "-f", "-",
1089
+ ], JSON.stringify(manifest));
1090
+ }
1091
+ }
1092
+
1093
+ async deletePod(namespace: string, podName: string): Promise<void> {
1094
+ if (this.inCluster) {
1095
+ await this.apiRequest("DELETE", `/api/v1/namespaces/${namespace}/pods/${podName}?gracePeriodSeconds=0`, undefined, [200, 202, 404]);
1096
+ } else {
1097
+ await runCommand("kubectl", [
1098
+ ...buildKubectlContextArgs(this.contextArgs),
1099
+ "-n", namespace,
1100
+ "delete", "pod", podName,
1101
+ "--ignore-not-found=true", "--grace-period=0", "--force",
1102
+ ]);
1103
+ }
1104
+ }
1105
+
1106
+ private async apiRequest(method: string, apiPath: string, body: unknown, okStatuses: number[]): Promise<string> {
1107
+ const payload = body !== undefined ? JSON.stringify(body) : undefined;
1108
+ return new Promise<string>((resolve, reject) => {
1109
+ const url = new URL(apiPath, this.apiServer);
1110
+ const options: https.RequestOptions = {
1111
+ method,
1112
+ hostname: url.hostname,
1113
+ port: url.port,
1114
+ path: url.pathname + url.search,
1115
+ headers: {
1116
+ "Authorization": `Bearer ${this.token}`,
1117
+ ...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {}),
1118
+ },
1119
+ ...(this.caCert ? { ca: this.caCert } : { rejectUnauthorized: false }),
1120
+ };
1121
+
1122
+ const req = https.request(options, (res) => {
1123
+ let data = "";
1124
+ res.on("data", (chunk: string) => { data += chunk; });
1125
+ res.on("end", () => {
1126
+ if (okStatuses.includes(res.statusCode ?? 0)) {
1127
+ resolve(data);
1128
+ } else {
1129
+ reject(new Error(`K8s API ${method} ${apiPath} returned ${res.statusCode}: ${data.slice(0, 500)}`));
1130
+ }
1131
+ });
1132
+ });
1133
+ req.on("error", reject);
1134
+ if (payload) req.write(payload);
1135
+ req.end();
1136
+ });
1137
+ }
1138
+ }
1139
+
834
1140
  class KubernetesProvisioner implements WorkerProvisionerBackend {
835
1141
  readonly type = "kubernetes" as const;
836
1142
  private readonly config: PluginConfig;
837
1143
  private readonly logger: PluginLogger;
1144
+ private readonly k8sApi: K8sApiClient;
838
1145
 
839
1146
  constructor(config: PluginConfig, logger: PluginLogger) {
840
1147
  this.config = config;
841
1148
  this.logger = logger;
1149
+ this.k8sApi = new K8sApiClient(config.workerProvisioningKubernetesContext, logger);
842
1150
  }
843
1151
 
844
1152
  async launch(spec: LaunchSpec): Promise<LaunchResult> {
@@ -847,6 +1155,7 @@ class KubernetesProvisioner implements WorkerProvisionerBackend {
847
1155
  }
848
1156
 
849
1157
  const instanceName = buildManagedInstanceName(this.config.teamName, spec.role, spec.workerId);
1158
+ const ns = this.config.workerProvisioningKubernetesNamespace;
850
1159
  const env = {
851
1160
  ...spec.env,
852
1161
  HOME: "/home/node",
@@ -863,13 +1172,17 @@ class KubernetesProvisioner implements WorkerProvisionerBackend {
863
1172
  const hasPersistentWorkspace = Boolean(
864
1173
  workspaceRoot && this.config.workerProvisioningKubernetesWorkspacePersistentVolumeClaim,
865
1174
  );
1175
+ const imagePullSecrets = this.config.workerProvisioningKubernetesImagePullSecrets
1176
+ .map((name) => name.trim())
1177
+ .filter((name) => name.length > 0)
1178
+ .map((name) => ({ name }));
866
1179
 
867
1180
  const manifest = {
868
1181
  apiVersion: "v1",
869
1182
  kind: "Pod",
870
1183
  metadata: {
871
1184
  name: instanceName,
872
- namespace: this.config.workerProvisioningKubernetesNamespace,
1185
+ namespace: ns,
873
1186
  labels: {
874
1187
  app: "teamclaw-worker",
875
1188
  "teamclaw.managed": "true",
@@ -885,6 +1198,7 @@ class KubernetesProvisioner implements WorkerProvisionerBackend {
885
1198
  restartPolicy: "Never",
886
1199
  hostname: buildManagedHostname(this.config.teamName, spec.role, spec.workerId),
887
1200
  serviceAccountName: this.config.workerProvisioningKubernetesServiceAccount || undefined,
1201
+ imagePullSecrets: imagePullSecrets.length > 0 ? imagePullSecrets : undefined,
888
1202
  securityContext: hasPersistentWorkspace
889
1203
  ? {
890
1204
  runAsUser: 1000,
@@ -922,16 +1236,7 @@ class KubernetesProvisioner implements WorkerProvisionerBackend {
922
1236
  },
923
1237
  };
924
1238
 
925
- await runCommand(
926
- "kubectl",
927
- [
928
- ...buildKubectlContextArgs(this.config.workerProvisioningKubernetesContext),
929
- "apply",
930
- "-f",
931
- "-",
932
- ],
933
- JSON.stringify(manifest),
934
- );
1239
+ await this.k8sApi.createPod(ns, manifest);
935
1240
  this.logger.info(`Provisioner: applied kubernetes pod ${instanceName}`);
936
1241
 
937
1242
  return {
@@ -945,18 +1250,8 @@ class KubernetesProvisioner implements WorkerProvisionerBackend {
945
1250
  if (!podName) {
946
1251
  return;
947
1252
  }
948
-
949
- await runCommand("kubectl", [
950
- ...buildKubectlContextArgs(this.config.workerProvisioningKubernetesContext),
951
- "-n",
952
- this.config.workerProvisioningKubernetesNamespace,
953
- "delete",
954
- "pod",
955
- podName,
956
- "--ignore-not-found=true",
957
- "--grace-period=0",
958
- "--force",
959
- ]);
1253
+ const ns = this.config.workerProvisioningKubernetesNamespace;
1254
+ await this.k8sApi.deletePod(ns, podName);
960
1255
  }
961
1256
  }
962
1257
 
@@ -1084,7 +1379,13 @@ function buildProvisionedWorkerConfig(
1084
1379
 
1085
1380
  const gateway = ensureRecord(config.gateway);
1086
1381
  gateway.mode = "local";
1087
- gateway.bind = "loopback";
1382
+ // Workers running inside Docker/K8s containers must bind on all
1383
+ // interfaces so the controller (in a sibling container) can reach them.
1384
+ gateway.bind =
1385
+ controllerConfig.workerProvisioningType === "docker" ||
1386
+ controllerConfig.workerProvisioningType === "kubernetes"
1387
+ ? "lan"
1388
+ : "loopback";
1088
1389
  gateway.port = spec.gatewayPort;
1089
1390
  delete gateway.remote;
1090
1391
  config.gateway = gateway;
@@ -1099,6 +1400,7 @@ function buildProvisionedWorkerConfig(
1099
1400
  teamclawEntry.enabled = true;
1100
1401
  const teamclawConfig = ensureRecord(teamclawEntry.config);
1101
1402
  teamclawConfig.mode = "worker";
1403
+ teamclawConfig.processModel = "multi";
1102
1404
  teamclawConfig.role = spec.role;
1103
1405
  teamclawConfig.port = spec.workerPort;
1104
1406
  teamclawConfig.controllerUrl = spec.controllerUrl;
@@ -1110,7 +1412,6 @@ function buildProvisionedWorkerConfig(
1110
1412
  teamclawConfig.gitDefaultBranch = controllerConfig.gitDefaultBranch;
1111
1413
  teamclawConfig.gitAuthorName = controllerConfig.gitAuthorName;
1112
1414
  teamclawConfig.gitAuthorEmail = controllerConfig.gitAuthorEmail;
1113
- teamclawConfig.localRoles = [];
1114
1415
  teamclawConfig.workerProvisioningType = "none";
1115
1416
  teamclawConfig.workerProvisioningControllerUrl = "";
1116
1417
  teamclawConfig.workerProvisioningRoles = [];
@@ -1128,6 +1429,7 @@ function buildProvisionedWorkerConfig(
1128
1429
  teamclawConfig.workerProvisioningKubernetesNamespace = "default";
1129
1430
  teamclawConfig.workerProvisioningKubernetesContext = "";
1130
1431
  teamclawConfig.workerProvisioningKubernetesServiceAccount = "";
1432
+ teamclawConfig.workerProvisioningKubernetesImagePullSecrets = [];
1131
1433
  teamclawConfig.workerProvisioningKubernetesWorkspacePersistentVolumeClaim = "";
1132
1434
  teamclawConfig.workerProvisioningKubernetesLabels = {};
1133
1435
  teamclawConfig.workerProvisioningKubernetesAnnotations = {};
@@ -1149,6 +1451,17 @@ function ensureProvisioningState(state: TeamState): TeamProvisioningState {
1149
1451
  return state.provisioning;
1150
1452
  }
1151
1453
 
1454
+ async function ensureWritableDirectory(dir: string): Promise<void> {
1455
+ await fs.mkdir(dir, { recursive: true });
1456
+ const probePath = path.join(dir, `.teamclaw-write-probe-${process.pid}-${Date.now()}`);
1457
+ await fs.writeFile(probePath, "ok\n", "utf8");
1458
+ await fs.rm(probePath, { force: true });
1459
+ }
1460
+
1461
+ function sleep(ms: number): Promise<void> {
1462
+ return new Promise((resolve) => setTimeout(resolve, ms));
1463
+ }
1464
+
1152
1465
  async function loadOpenClawConfig(configPath: string): Promise<Record<string, unknown>> {
1153
1466
  const raw = await fs.readFile(configPath, "utf8");
1154
1467
  return parseLooseJsonObject(raw, configPath);
@@ -1159,11 +1472,45 @@ async function prepareProcessRuntimeExtensions(stateDir: string): Promise<void>
1159
1472
  const controllerExtensionsDir = path.join(path.dirname(resolveDefaultOpenClawConfigPath()), "extensions");
1160
1473
  if (await pathExists(controllerExtensionsDir)) {
1161
1474
  await fs.symlink(controllerExtensionsDir, runtimeExtensionsDir, "dir");
1162
- return;
1475
+ } else {
1476
+ await fs.mkdir(runtimeExtensionsDir, { recursive: true });
1477
+ await fs.symlink(resolveCurrentTeamClawPluginRootDir(), path.join(runtimeExtensionsDir, "teamclaw"), "dir");
1163
1478
  }
1164
1479
 
1165
- await fs.mkdir(runtimeExtensionsDir, { recursive: true });
1166
- await fs.symlink(resolveCurrentTeamClawPluginRootDir(), path.join(runtimeExtensionsDir, "teamclaw"), "dir");
1480
+ await copyControllerAuthProfiles(stateDir);
1481
+ }
1482
+
1483
+ async function copyControllerAuthProfiles(stateDir: string): Promise<void> {
1484
+ const candidateControllerStateDirs = [
1485
+ path.dirname(resolveDefaultOpenClawConfigPath()),
1486
+ resolveDefaultOpenClawStateDir(),
1487
+ ];
1488
+ const candidateAuthProfilePaths = [
1489
+ path.join(resolveTeamClawAgentDir(), "auth-profiles.json"),
1490
+ path.join(resolveDefaultAgentDir(), "auth-profiles.json"),
1491
+ ...candidateControllerStateDirs.flatMap((controllerStateDir) => [
1492
+ path.join(controllerStateDir, "agents", TEAMCLAW_AGENT_ID, "agent", "auth-profiles.json"),
1493
+ path.join(controllerStateDir, "agents", "main", "agent", "auth-profiles.json"),
1494
+ ]),
1495
+ ].filter((value, index, values) => values.indexOf(value) === index);
1496
+ let controllerAuthProfilesPath: string | undefined;
1497
+ for (const candidatePath of candidateAuthProfilePaths) {
1498
+ if (await pathExists(candidatePath)) {
1499
+ controllerAuthProfilesPath = candidatePath;
1500
+ break;
1501
+ }
1502
+ }
1503
+ if (!controllerAuthProfilesPath) {
1504
+ return;
1505
+ }
1506
+ const runtimeAuthProfilePaths = [
1507
+ path.join(stateDir, "agents", TEAMCLAW_AGENT_ID, "agent", "auth-profiles.json"),
1508
+ path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"),
1509
+ ];
1510
+ for (const runtimeAuthProfilesPath of runtimeAuthProfilePaths) {
1511
+ await fs.mkdir(path.dirname(runtimeAuthProfilesPath), { recursive: true });
1512
+ await fs.copyFile(controllerAuthProfilesPath, runtimeAuthProfilesPath);
1513
+ }
1167
1514
  }
1168
1515
 
1169
1516
  function cloneJson<T>(value: T): T {
@@ -1299,7 +1646,7 @@ function resolveGatewayEntrypoint(): string {
1299
1646
  }
1300
1647
 
1301
1648
  function resolveCurrentTeamClawPluginRootDir(): string {
1302
- return path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
1649
+ return process.env.TEAMCLAW_BAKED_IN === "true" ? "" : path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
1303
1650
  }
1304
1651
 
1305
1652
  function attachChildLogs(child: ChildProcess, logger: PluginLogger, prefix: string): void {
@@ -1422,7 +1769,8 @@ function buildContainerBootstrapScript(): string {
1422
1769
  function buildDockerBinds(config: PluginConfig): string[] {
1423
1770
  const binds = [...config.workerProvisioningDockerMounts];
1424
1771
  if (!binds.some((bind) => extractDockerBindTarget(bind) === DEFAULT_DOCKER_BUNDLED_TEAMCLAW_PLUGIN_DIR)) {
1425
- binds.unshift(`${resolveCurrentTeamClawPluginRootDir()}:${DEFAULT_DOCKER_BUNDLED_TEAMCLAW_PLUGIN_DIR}:ro`);
1772
+ const _pd = resolveCurrentTeamClawPluginRootDir();
1773
+ if (_pd) binds.unshift(`${_pd}:${DEFAULT_DOCKER_BUNDLED_TEAMCLAW_PLUGIN_DIR}:ro`);
1426
1774
  }
1427
1775
  if (config.workerProvisioningDockerWorkspaceVolume && config.workerProvisioningWorkspaceRoot) {
1428
1776
  binds.unshift(`${config.workerProvisioningDockerWorkspaceVolume}:${config.workerProvisioningWorkspaceRoot}`);
@@ -1436,6 +1784,13 @@ function buildProvisionedWorkspaceDir(
1436
1784
  role: RoleId,
1437
1785
  workerId: string,
1438
1786
  ): string {
1787
+ // Process-type workers run on the same host — share the controller's workspace
1788
+ // so that file artifacts (previews, deliverables) are immediately visible to
1789
+ // the controller without requiring git sync or file transfer.
1790
+ if (provider === "process") {
1791
+ return resolveTeamClawAgentWorkspaceRootDir();
1792
+ }
1793
+
1439
1794
  if (
1440
1795
  (provider !== "docker" && provider !== "kubernetes") ||
1441
1796
  !config.workerProvisioningWorkspaceRoot