@teamclaws/teamclaw 2026.3.25 → 2026.3.26-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.
@@ -0,0 +1,105 @@
1
+ import type {
2
+ ControllerManifestCreatedTask,
3
+ ControllerManifestDeferredTask,
4
+ ControllerOrchestrationManifest,
5
+ RoleId,
6
+ } from "../types.js";
7
+
8
+ const TEAMCLAW_ROLE_IDS = new Set<RoleId>([
9
+ "pm",
10
+ "architect",
11
+ "developer",
12
+ "qa",
13
+ "release-engineer",
14
+ "infra-engineer",
15
+ "devops",
16
+ "security-engineer",
17
+ "designer",
18
+ "marketing",
19
+ ]);
20
+
21
+ export function normalizeManifestRoleList(raw: unknown): RoleId[] {
22
+ if (!Array.isArray(raw)) {
23
+ return [];
24
+ }
25
+ const roleIds: RoleId[] = [];
26
+ for (const entry of raw) {
27
+ if (typeof entry !== "string") {
28
+ continue;
29
+ }
30
+ const normalized = entry.trim() as RoleId;
31
+ if (!normalized || !TEAMCLAW_ROLE_IDS.has(normalized) || roleIds.includes(normalized)) {
32
+ continue;
33
+ }
34
+ roleIds.push(normalized);
35
+ }
36
+ return roleIds;
37
+ }
38
+
39
+ export function normalizeManifestStringList(raw: unknown): string[] {
40
+ if (!Array.isArray(raw)) {
41
+ return [];
42
+ }
43
+ return raw
44
+ .map((entry) => (typeof entry === "string" ? entry.trim() : ""))
45
+ .filter(Boolean);
46
+ }
47
+
48
+ export function normalizeOptionalManifestText(raw: unknown): string | undefined {
49
+ if (typeof raw !== "string") {
50
+ return undefined;
51
+ }
52
+ const normalized = raw.trim();
53
+ return normalized || undefined;
54
+ }
55
+
56
+ export function normalizeManifestCreatedTasks(raw: unknown): ControllerManifestCreatedTask[] {
57
+ if (!Array.isArray(raw)) {
58
+ return [];
59
+ }
60
+ return raw
61
+ .filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === "object")
62
+ .map((entry) => ({
63
+ title: typeof entry.title === "string" ? entry.title.trim() : "",
64
+ assignedRole: normalizeManifestRoleList([entry.assignedRole])[0],
65
+ expectedOutcome: typeof entry.expectedOutcome === "string" ? entry.expectedOutcome.trim() : "",
66
+ }))
67
+ .filter((entry) => entry.title && entry.expectedOutcome);
68
+ }
69
+
70
+ export function normalizeManifestDeferredTasks(raw: unknown): ControllerManifestDeferredTask[] {
71
+ if (!Array.isArray(raw)) {
72
+ return [];
73
+ }
74
+ return raw
75
+ .filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === "object")
76
+ .map((entry) => ({
77
+ title: typeof entry.title === "string" ? entry.title.trim() : "",
78
+ assignedRole: normalizeManifestRoleList([entry.assignedRole])[0],
79
+ blockedBy: typeof entry.blockedBy === "string" ? entry.blockedBy.trim() : "",
80
+ whenReady: typeof entry.whenReady === "string" ? entry.whenReady.trim() : "",
81
+ }))
82
+ .filter((entry) => entry.title && entry.blockedBy && entry.whenReady);
83
+ }
84
+
85
+ export function normalizeControllerManifest(raw: unknown): ControllerOrchestrationManifest | null {
86
+ if (!raw || typeof raw !== "object") {
87
+ return null;
88
+ }
89
+ const input = raw as Record<string, unknown>;
90
+ const requirementSummary = typeof input.requirementSummary === "string" ? input.requirementSummary.trim() : "";
91
+ if (!requirementSummary) {
92
+ return null;
93
+ }
94
+ return {
95
+ version: typeof input.version === "string" && input.version.trim() ? input.version.trim() : "1.0",
96
+ requirementSummary,
97
+ requiredRoles: normalizeManifestRoleList(input.requiredRoles),
98
+ clarificationsNeeded: Boolean(input.clarificationsNeeded),
99
+ clarificationQuestions: normalizeManifestStringList(input.clarificationQuestions),
100
+ createdTasks: normalizeManifestCreatedTasks(input.createdTasks),
101
+ deferredTasks: normalizeManifestDeferredTasks(input.deferredTasks),
102
+ handoffPlan: normalizeOptionalManifestText(input.handoffPlan),
103
+ notes: normalizeOptionalManifestText(input.notes),
104
+ };
105
+ }
@@ -1,5 +1,6 @@
1
1
  import type { PluginConfig, TeamState } from "../types.js";
2
2
  import { ROLES } from "../roles.js";
3
+ import { hasOnDemandWorkerProvisioning, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js";
3
4
 
4
5
  const TEAMCLAW_ROLE_IDS_TEXT = [
5
6
  "pm",
@@ -22,15 +23,13 @@ export type ControllerPromptDeps = {
22
23
  export function createControllerPromptInjector(deps: ControllerPromptDeps) {
23
24
  return () => {
24
25
  const state = deps.getTeamState();
25
- if (!state) return null;
26
-
27
- const workers = Object.values(state.workers);
28
- const tasks = Object.values(state.tasks);
26
+ const workers = Object.values(state?.workers ?? {});
27
+ const tasks = Object.values(state?.tasks ?? {});
29
28
  const pendingTasks = tasks.filter((t) => t.status === "pending");
30
29
  const activeTasks = tasks.filter((t) => t.status === "in_progress" || t.status === "assigned");
31
30
  const blockedTasks = tasks.filter((t) => t.status === "blocked");
32
31
  const completedTasks = tasks.filter((t) => t.status === "completed");
33
- const pendingClarifications = Object.values(state.clarifications).filter((c) => c.status === "pending");
32
+ const pendingClarifications = Object.values(state?.clarifications ?? {}).filter((c) => c.status === "pending");
34
33
 
35
34
  const parts: string[] = [
36
35
  "## TeamClaw Controller Mode",
@@ -39,6 +38,7 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
39
38
  "",
40
39
  "### Available Tools",
41
40
  "- teamclaw_create_task: Create a new task with role assignment",
41
+ "- teamclaw_submit_manifest: Submit the required structured orchestration manifest for this intake run",
42
42
  "- teamclaw_list_tasks: List all tasks with status filtering",
43
43
  "- teamclaw_assign_task: Assign a task to a specific worker",
44
44
  "- teamclaw_send_message: Send messages between team members",
@@ -46,8 +46,17 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
46
46
  "### Current Team Status",
47
47
  ];
48
48
 
49
- if (workers.length === 0) {
50
- parts.push("- No workers registered yet");
49
+ if (!state) {
50
+ parts.push("- Team state is not loaded yet; treat this as a fresh controller intake and establish execution-ready tasks from the human requirement.");
51
+ } else if (workers.length === 0) {
52
+ if (shouldBlockControllerWithoutWorkers(deps.config, state)) {
53
+ parts.push("- No workers are registered and on-demand provisioning is disabled.");
54
+ parts.push("- Blocking rule: you may analyze the requirement and identify the needed roles, but do not create TeamClaw tasks yet.");
55
+ parts.push("- Do not start doing the worker-role work yourself. Tell the human to bring workers online or enable process/docker/kubernetes provisioning first.");
56
+ } else {
57
+ parts.push("- No workers are registered yet, but on-demand provisioning is enabled.");
58
+ parts.push("- You may still create execution-ready TeamClaw tasks for the required roles; the controller will provision workers on demand.");
59
+ }
51
60
  } else {
52
61
  for (const w of workers) {
53
62
  const roleDef = ROLES.find((r) => r.id === w.role);
@@ -86,6 +95,24 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
86
95
  parts.push(`- ${role.icon} ${role.label}: ${role.description}.${skillLine}`);
87
96
  }
88
97
 
98
+ parts.push("");
99
+ parts.push("## Controller Workflow");
100
+ parts.push("- First determine which TeamClaw roles are needed for the human requirement.");
101
+ parts.push("- Then translate the requirement into the minimum execution-ready TeamClaw tasks owned by those roles.");
102
+ parts.push("- TeamClaw workers, not the controller, do the specialist work in the shared repo/workspace.");
103
+ parts.push("- After workers report progress, results, or handoffs, create only the next tasks whose prerequisites are now satisfied.");
104
+ parts.push("- A completed upstream task with a structured result contract, concrete deliverables, or an explicit handoff is strong evidence that its dependent downstream work can now be created.");
105
+
106
+ parts.push("");
107
+ parts.push("## Structured Orchestration Contract");
108
+ parts.push("- Freeform prose is not enough for TeamClaw scheduling decisions.");
109
+ parts.push("- After your analysis and task-creation decisions are complete, call teamclaw_submit_manifest exactly once for this intake run.");
110
+ parts.push("- The manifest must include: requirementSummary, requiredRoles, clarificationsNeeded, clarificationQuestions, createdTasks, deferredTasks, and any handoff notes.");
111
+ parts.push("- Use createdTasks for execution-ready tasks that this run activated now, including a deliberately reused existing TeamClaw task when you chose not to duplicate it.");
112
+ parts.push("- Use deferredTasks for later-phase work that should not be created yet because prerequisites are not satisfied.");
113
+ parts.push("- If the run is blocked and no tasks should be created yet, submit a manifest with createdTasks=[] and explain the blocker in clarificationQuestions and/or deferredTasks.");
114
+ parts.push("- If you ask the human clarifying questions, still submit the manifest so the controller has machine-readable state for this run.");
115
+
89
116
  parts.push("");
90
117
  parts.push("## Requirement Intake Rules");
91
118
  parts.push("- Human messages are the initial requirement, not an already-decomposed task tree.");
@@ -111,6 +138,14 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
111
138
  parts.push("- Do not let a worker task turn itself into a controller/coordinator workflow.");
112
139
  parts.push("- If the correct role is busy, prefer waiting, messaging, or explicit reassignment over routing core work to an unrelated role.");
113
140
  parts.push("- If a task is blocked by missing information, keep it in the clarification queue until the human answers; do not guess on the user's behalf.");
141
+ parts.push("- You are never a substitute worker. Do not personally perform architecture, implementation, QA, release, infra, design, marketing, research, or other specialist work.");
142
+ parts.push("- Your own reply must stay at the orchestration layer: clarification, role selection, task decomposition, assignment decisions, and concise status updates.");
143
+ parts.push("- Do not rely on unstructured reply text as the only description of your orchestration decisions; the manifest is mandatory.");
144
+ if (hasOnDemandWorkerProvisioning(deps.config)) {
145
+ parts.push("- If no workers are currently registered but on-demand provisioning is enabled, you may still create execution-ready tasks so the required roles can be provisioned.");
146
+ } else {
147
+ parts.push("- If no workers are registered, you may mention which roles would be needed, but stop there and report the worker-capacity block to the human.");
148
+ }
114
149
  parts.push("- Use the controller itself for requirement analysis; use the PM role only for PM-owned deliverables after intake is clear.");
115
150
  parts.push(`- Use exact TeamClaw role IDs only: ${TEAMCLAW_ROLE_IDS_TEXT}.`);
116
151
 
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import readline from "node:readline";
7
7
  import { createHash } from "node:crypto";
8
8
  import { spawn, type ChildProcess } from "node:child_process";
9
+ import { fileURLToPath } from "node:url";
9
10
  import JSON5 from "json5";
10
11
  import type { PluginLogger } from "../../api.js";
11
12
  import { generateId } from "../protocol.js";
@@ -28,10 +29,11 @@ import type {
28
29
 
29
30
  const DEFAULT_CONTAINER_WORKER_PORT = 9527;
30
31
  const DEFAULT_CONTAINER_GATEWAY_PORT = 18789;
32
+ const DEFAULT_DOCKER_BUNDLED_TEAMCLAW_PLUGIN_DIR = "/app/extensions/teamclaw";
31
33
  const PROVISIONING_RECORD_RETENTION_MS = 6 * 60 * 60 * 1000;
32
34
  const PROVISIONING_FAILURE_COOLDOWN_MS = 30_000;
33
35
  const PROCESS_TERMINATION_TIMEOUT_MS = 10_000;
34
- const DOCKER_API_VERSION = "v1.41";
36
+ const DOCKER_API_VERSION = resolveDockerApiVersion();
35
37
 
36
38
  export type WorkerProvisioningManagerDeps = {
37
39
  config: PluginConfig;
@@ -258,10 +260,18 @@ export class WorkerProvisioningManager {
258
260
  return;
259
261
  }
260
262
 
261
- const roles = this.getProvisionableRoles();
263
+ const roles = this.getProvisionableRoles(state);
262
264
  for (const role of roles) {
263
265
  const demand = this.computeRoleDemand(state, role);
264
266
  if (demand > 0) {
267
+ if (
268
+ this.deps.config.workerProvisioningRoles.length > 0 &&
269
+ !this.deps.config.workerProvisioningRoles.includes(role)
270
+ ) {
271
+ this.deps.logger.info(
272
+ `Provisioner: allowing role ${role} because pending task demand exists outside configured workerProvisioningRoles`,
273
+ );
274
+ }
265
275
  this.deps.logger.info(`Provisioner: role ${role} needs ${demand} additional worker(s) (${reason})`);
266
276
  }
267
277
  for (let i = 0; i < demand; i += 1) {
@@ -301,7 +311,7 @@ export class WorkerProvisioningManager {
301
311
  return;
302
312
  }
303
313
 
304
- const roles = this.getProvisionableRoles();
314
+ const roles = this.getProvisionableRoles(state);
305
315
  for (const role of roles) {
306
316
  const activeWorkers = Object.values(state.workers).filter(
307
317
  (worker) => worker.role === role && worker.status !== "offline",
@@ -353,10 +363,14 @@ export class WorkerProvisioningManager {
353
363
  const workerId = `provisioned-${role}-${generateId()}`;
354
364
  const launchToken = `${generateId()}-${generateId()}`;
355
365
  const controllerUrl = this.resolveControllerUrl();
356
- const workerPort = this.backend.type === "process"
366
+ const requiresDedicatedHostPorts = requiresDedicatedHostPortsForProvisioner(
367
+ this.backend.type,
368
+ this.deps.config,
369
+ );
370
+ const workerPort = requiresDedicatedHostPorts
357
371
  ? await reserveEphemeralPort()
358
372
  : DEFAULT_CONTAINER_WORKER_PORT;
359
- const gatewayPort = this.backend.type === "process"
373
+ const gatewayPort = requiresDedicatedHostPorts
360
374
  ? await reserveEphemeralPort()
361
375
  : DEFAULT_CONTAINER_GATEWAY_PORT;
362
376
  const now = Date.now();
@@ -390,7 +404,7 @@ export class WorkerProvisioningManager {
390
404
  workerPort,
391
405
  gatewayPort,
392
406
  workspaceDir: getConfiguredWorkerWorkspaceDir(workerConfig),
393
- env: this.buildForwardedEnv(),
407
+ env: this.buildForwardedEnv(controllerUrl),
394
408
  configJson: `${JSON.stringify(workerConfig, null, 2)}\n`,
395
409
  });
396
410
 
@@ -473,10 +487,35 @@ export class WorkerProvisioningManager {
473
487
  });
474
488
  }
475
489
 
476
- private getProvisionableRoles(): RoleId[] {
477
- return this.deps.config.workerProvisioningRoles.length > 0
478
- ? this.deps.config.workerProvisioningRoles
479
- : ROLES.map((role) => role.id);
490
+ private getProvisionableRoles(state: TeamState | null): RoleId[] {
491
+ const roleIds = new Set<RoleId>(
492
+ this.deps.config.workerProvisioningRoles.length > 0
493
+ ? this.deps.config.workerProvisioningRoles
494
+ : ROLES.map((role) => role.id),
495
+ );
496
+
497
+ if (!state) {
498
+ return [...roleIds];
499
+ }
500
+
501
+ for (const task of Object.values(state.tasks)) {
502
+ if (task.status !== "pending" && task.status !== "assigned") {
503
+ continue;
504
+ }
505
+ const taskRole = this.inferTaskRole(task);
506
+ if (taskRole) {
507
+ roleIds.add(taskRole);
508
+ }
509
+ }
510
+
511
+ for (const worker of Object.values(state.workers)) {
512
+ roleIds.add(worker.role);
513
+ }
514
+ for (const record of Object.values(state.provisioning?.workers ?? {})) {
515
+ roleIds.add(record.role);
516
+ }
517
+
518
+ return [...roleIds];
480
519
  }
481
520
 
482
521
  private countPendingTasksForRole(state: TeamState, role: RoleId): number {
@@ -493,6 +532,9 @@ export class WorkerProvisioningManager {
493
532
  return false;
494
533
  }
495
534
  }
535
+ if (task.assignedRole) {
536
+ return task.assignedRole === role;
537
+ }
496
538
  return this.inferTaskRole(task) === role;
497
539
  }
498
540
 
@@ -557,7 +599,7 @@ export class WorkerProvisioningManager {
557
599
  );
558
600
  }
559
601
 
560
- private buildForwardedEnv(): Record<string, string> {
602
+ private buildForwardedEnv(controllerUrl: string): Record<string, string> {
561
603
  const env: Record<string, string> = {
562
604
  ...this.deps.config.workerProvisioningExtraEnv,
563
605
  };
@@ -567,7 +609,7 @@ export class WorkerProvisioningManager {
567
609
  env[name] = value;
568
610
  }
569
611
  }
570
- return env;
612
+ return appendNoProxyEntries(env, controllerUrl);
571
613
  }
572
614
 
573
615
  private async loadBaseOpenClawConfig(): Promise<Record<string, unknown>> {
@@ -636,6 +678,7 @@ class ProcessProvisioner implements WorkerProvisionerBackend {
636
678
  const stateDir = path.join(runtimeHomeDir, ".openclaw");
637
679
  const configPath = path.join(stateDir, "openclaw.json");
638
680
  await fs.mkdir(stateDir, { recursive: true });
681
+ await prepareProcessRuntimeExtensions(stateDir);
639
682
  await fs.writeFile(configPath, spec.configJson, "utf8");
640
683
 
641
684
  const gatewayEntrypoint = resolveGatewayEntrypoint();
@@ -943,7 +986,8 @@ class DockerApiClient {
943
986
  okStatuses: number[],
944
987
  ): Promise<{ status: number; body: string }> {
945
988
  const payload = body === undefined ? undefined : JSON.stringify(body);
946
- const finalPath = `/${DOCKER_API_VERSION}${requestPath}`;
989
+ // Prefer the daemon's negotiated default unless the operator pins a version explicitly.
990
+ const finalPath = DOCKER_API_VERSION ? `/${DOCKER_API_VERSION}${requestPath}` : requestPath;
947
991
 
948
992
  return await new Promise<{ status: number; body: string }>((resolve, reject) => {
949
993
  const transport = this.endpoint.protocol === "https:" ? https : http;
@@ -989,6 +1033,14 @@ type DockerEndpoint = {
989
1033
  port?: number;
990
1034
  };
991
1035
 
1036
+ function resolveDockerApiVersion(): string | null {
1037
+ const configured = process.env.DOCKER_API_VERSION?.trim();
1038
+ if (!configured) {
1039
+ return null;
1040
+ }
1041
+ return configured.startsWith("v") ? configured : `v${configured}`;
1042
+ }
1043
+
992
1044
  function createProvisionerBackend(
993
1045
  config: PluginConfig,
994
1046
  logger: PluginLogger,
@@ -1018,6 +1070,7 @@ function buildProvisionedWorkerConfig(
1018
1070
  },
1019
1071
  ): Record<string, unknown> {
1020
1072
  const config = cloneJson(baseConfig);
1073
+ delete config.channels;
1021
1074
  const agents = ensureRecord(config.agents);
1022
1075
  const agentDefaults = ensureRecord(agents.defaults);
1023
1076
  delete agentDefaults.repoRoot;
@@ -1033,10 +1086,14 @@ function buildProvisionedWorkerConfig(
1033
1086
  gateway.mode = "local";
1034
1087
  gateway.bind = "loopback";
1035
1088
  gateway.port = spec.gatewayPort;
1089
+ delete gateway.remote;
1036
1090
  config.gateway = gateway;
1037
1091
 
1038
1092
  const plugins = ensureRecord(config.plugins);
1039
1093
  plugins.enabled = true;
1094
+ if (controllerConfig.workerProvisioningType === "docker" || controllerConfig.workerProvisioningType === "kubernetes") {
1095
+ delete plugins.load;
1096
+ }
1040
1097
  const entries = ensureRecord(plugins.entries);
1041
1098
  const teamclawEntry = ensureRecord(entries.teamclaw);
1042
1099
  teamclawEntry.enabled = true;
@@ -1097,10 +1154,104 @@ async function loadOpenClawConfig(configPath: string): Promise<Record<string, un
1097
1154
  return parseLooseJsonObject(raw, configPath);
1098
1155
  }
1099
1156
 
1157
+ async function prepareProcessRuntimeExtensions(stateDir: string): Promise<void> {
1158
+ const runtimeExtensionsDir = path.join(stateDir, "extensions");
1159
+ const controllerExtensionsDir = path.join(path.dirname(resolveDefaultOpenClawConfigPath()), "extensions");
1160
+ if (await pathExists(controllerExtensionsDir)) {
1161
+ await fs.symlink(controllerExtensionsDir, runtimeExtensionsDir, "dir");
1162
+ return;
1163
+ }
1164
+
1165
+ await fs.mkdir(runtimeExtensionsDir, { recursive: true });
1166
+ await fs.symlink(resolveCurrentTeamClawPluginRootDir(), path.join(runtimeExtensionsDir, "teamclaw"), "dir");
1167
+ }
1168
+
1100
1169
  function cloneJson<T>(value: T): T {
1101
1170
  return JSON.parse(JSON.stringify(value)) as T;
1102
1171
  }
1103
1172
 
1173
+ function requiresDedicatedHostPortsForProvisioner(
1174
+ provider: WorkerProvisioningType,
1175
+ config: PluginConfig,
1176
+ ): boolean {
1177
+ if (provider === "process") {
1178
+ return true;
1179
+ }
1180
+ return provider === "docker" && isDockerHostNetwork(config.workerProvisioningDockerNetwork);
1181
+ }
1182
+
1183
+ function isDockerHostNetwork(networkMode: string): boolean {
1184
+ return networkMode.trim().toLowerCase() === "host";
1185
+ }
1186
+
1187
+ function extractDockerBindTarget(bind: string): string | null {
1188
+ for (const part of bind.split(":").reverse()) {
1189
+ const trimmed = part.trim();
1190
+ if (trimmed.startsWith("/")) {
1191
+ return trimmed;
1192
+ }
1193
+ }
1194
+ return null;
1195
+ }
1196
+
1197
+ function appendNoProxyEntries(env: Record<string, string>, controllerUrl: string): Record<string, string> {
1198
+ const requiredEntries = resolveRequiredNoProxyEntries(controllerUrl);
1199
+ if (requiredEntries.length === 0) {
1200
+ return env;
1201
+ }
1202
+
1203
+ const upperBase = env.NO_PROXY ?? process.env.NO_PROXY ?? process.env.no_proxy ?? "";
1204
+ const lowerBase = env.no_proxy ?? process.env.no_proxy ?? env.NO_PROXY ?? process.env.NO_PROXY ?? "";
1205
+
1206
+ env.NO_PROXY = mergeNoProxyEntries(upperBase, requiredEntries);
1207
+ env.no_proxy = mergeNoProxyEntries(lowerBase, requiredEntries);
1208
+ return env;
1209
+ }
1210
+
1211
+ function resolveRequiredNoProxyEntries(controllerUrl: string): string[] {
1212
+ const required = new Set<string>(["localhost", "127.0.0.1", "::1", "[::1]"]);
1213
+ try {
1214
+ const host = new URL(controllerUrl).hostname.trim();
1215
+ if (host) {
1216
+ required.add(host);
1217
+ }
1218
+ } catch {
1219
+ // Ignore malformed controller URLs; worker registration will fail separately.
1220
+ }
1221
+ return Array.from(required);
1222
+ }
1223
+
1224
+ function mergeNoProxyEntries(existing: string, requiredEntries: string[]): string {
1225
+ const tokens = new Map<string, string>();
1226
+ for (const entry of existing.split(",")) {
1227
+ const trimmed = entry.trim();
1228
+ if (!trimmed) {
1229
+ continue;
1230
+ }
1231
+ tokens.set(trimmed.toLowerCase(), trimmed);
1232
+ }
1233
+ for (const entry of requiredEntries) {
1234
+ const trimmed = entry.trim();
1235
+ if (!trimmed) {
1236
+ continue;
1237
+ }
1238
+ tokens.set(trimmed.toLowerCase(), trimmed);
1239
+ }
1240
+ return Array.from(tokens.values()).join(",");
1241
+ }
1242
+
1243
+ async function pathExists(targetPath: string): Promise<boolean> {
1244
+ try {
1245
+ await fs.access(targetPath);
1246
+ return true;
1247
+ } catch (err) {
1248
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
1249
+ return false;
1250
+ }
1251
+ throw err;
1252
+ }
1253
+ }
1254
+
1104
1255
  function ensureRecord(value: unknown): Record<string, unknown> {
1105
1256
  return value && typeof value === "object" && !Array.isArray(value)
1106
1257
  ? { ...(value as Record<string, unknown>) }
@@ -1147,6 +1298,10 @@ function resolveGatewayEntrypoint(): string {
1147
1298
  return path.resolve(scriptPath);
1148
1299
  }
1149
1300
 
1301
+ function resolveCurrentTeamClawPluginRootDir(): string {
1302
+ return path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
1303
+ }
1304
+
1150
1305
  function attachChildLogs(child: ChildProcess, logger: PluginLogger, prefix: string): void {
1151
1306
  if (child.stdout) {
1152
1307
  const stdoutReader = readline.createInterface({ input: child.stdout });
@@ -1266,6 +1421,9 @@ function buildContainerBootstrapScript(): string {
1266
1421
 
1267
1422
  function buildDockerBinds(config: PluginConfig): string[] {
1268
1423
  const binds = [...config.workerProvisioningDockerMounts];
1424
+ if (!binds.some((bind) => extractDockerBindTarget(bind) === DEFAULT_DOCKER_BUNDLED_TEAMCLAW_PLUGIN_DIR)) {
1425
+ binds.unshift(`${resolveCurrentTeamClawPluginRootDir()}:${DEFAULT_DOCKER_BUNDLED_TEAMCLAW_PLUGIN_DIR}:ro`);
1426
+ }
1269
1427
  if (config.workerProvisioningDockerWorkspaceVolume && config.workerProvisioningWorkspaceRoot) {
1270
1428
  binds.unshift(`${config.workerProvisioningDockerWorkspaceVolume}:${config.workerProvisioningWorkspaceRoot}`);
1271
1429
  }
@@ -9,11 +9,13 @@ import { resolveDefaultOpenClawWorkspaceDir } from "./openclaw-workspace.js";
9
9
  const TEAMCLAW_IMPORT_REF_PREFIX = "refs/teamclaw/imports";
10
10
  const TEAMCLAW_RUNTIME_EXCLUDES = [
11
11
  ".openclaw/",
12
+ ".clawhub/",
12
13
  "AGENTS.md",
13
14
  "BOOTSTRAP.md",
14
15
  "HEARTBEAT.md",
15
16
  "IDENTITY.md",
16
17
  "SOUL.md",
18
+ "skills/",
17
19
  "TOOLS.md",
18
20
  "USER.md",
19
21
  ];