@teamclaws/teamclaw 2026.4.2-2 → 2026.4.2-3

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.
package/README.md CHANGED
@@ -6,7 +6,8 @@ It supports:
6
6
 
7
7
  - `controller` / `worker` modes
8
8
  - externally registered workers
9
- - clarifications, workspace browsing, and Web UI
9
+ - adaptive kickoff planning for medium/complex work, with per-role assessments before task creation
10
+ - clarifications, workspace browsing, controller UI visibility, and a matching desktop client
10
11
  - Git-based collaboration
11
12
  - on-demand worker provisioning with `process`, `docker`, and `kubernetes`
12
13
 
@@ -25,9 +26,12 @@ This installer can:
25
26
  - install/update the TeamClaw plugin in OpenClaw
26
27
  - detect your local `openclaw.json`
27
28
  - let you choose the installation mode
29
+ - support dedicated worker-only installs with `--install-mode worker`
28
30
  - let you choose a model from the models already defined in OpenClaw
29
31
  - let you choose the OpenClaw workspace directory
30
32
  - default TeamClaw to a dedicated `teamclaw` agent/workspace instead of reusing `main`
33
+ - copy the effective host model into the dedicated TeamClaw agent config when independent mode is used
34
+ - bootstrap TeamClaw auth profiles from the host OpenClaw auth store when available
31
35
  - prefill Docker/Kubernetes provisioning with the published TeamClaw runtime image
32
36
  - prefill Docker workspace persistence with a named volume and Kubernetes persistence with a PVC name
33
37
 
@@ -36,6 +40,8 @@ By default, guided install now uses an independent TeamClaw agent/workspace layo
36
40
  - `agent:teamclaw:*` sessions
37
41
  - sibling workspace such as `~/.openclaw/workspace-teamclaw`
38
42
 
43
+ In practice, this means TeamClaw keeps its own agent state, workspace, and auth-profile copy instead of reusing the host `main` agent. Use this default unless you explicitly need legacy compatibility with a shared `main` workspace.
44
+
39
45
  For advanced compatibility only, you can force the legacy shared-`main` layout:
40
46
 
41
47
  ```bash
package/cli.mjs CHANGED
@@ -63,8 +63,8 @@ const INSTALL_MODE_OPTIONS = [
63
63
  },
64
64
  {
65
65
  value: "controller-manual",
66
- label: "Controller only + external workers",
67
- hint: "Use separate OpenClaw installs for workers.",
66
+ label: "Local controller + default on-demand workers",
67
+ hint: "Lean same-host setup; TeamClaw launches local worker processes on demand with controller-decided defaults.",
68
68
  },
69
69
  {
70
70
  value: "controller-docker",
@@ -776,7 +776,10 @@ function isControllerInstallMode(installMode) {
776
776
  }
777
777
 
778
778
  function isOnDemandControllerInstallMode(installMode) {
779
- return installMode === "controller-process" || installMode === "controller-docker" || installMode === "controller-kubernetes";
779
+ return installMode === "controller-manual" ||
780
+ installMode === "controller-process" ||
781
+ installMode === "controller-docker" ||
782
+ installMode === "controller-kubernetes";
780
783
  }
781
784
 
782
785
  function describeProvisioningRoles(roles) {
@@ -1690,8 +1693,8 @@ function applyInstallerChoices(config, choices, configPath) {
1690
1693
  delete teamclawConfig.role;
1691
1694
 
1692
1695
  if (choices.installMode === "controller-manual") {
1693
- teamclawConfig.workerProvisioningType = "none";
1694
- teamclawConfig.workerProvisioningDisabled = true;
1696
+ teamclawConfig.workerProvisioningType = "process";
1697
+ teamclawConfig.workerProvisioningDisabled = false;
1695
1698
  teamclawConfig.workerProvisioningControllerUrl = "";
1696
1699
  teamclawConfig.workerProvisioningRoles = [];
1697
1700
  teamclawConfig.workerProvisioningMaxPerRole = 1;
@@ -1963,7 +1966,7 @@ async function runInstall(options) {
1963
1966
  } else if (choices.installMode === "controller-kubernetes") {
1964
1967
  prompter.note("Before using Kubernetes provisioning, make sure kubectl, namespace access, and the worker image are ready.");
1965
1968
  } else if (choices.installMode === "controller-manual") {
1966
- prompter.note("Next step: run this installer again on your worker nodes with the dedicated worker mode.");
1969
+ prompter.note("Next step: open the local TeamClaw UI and let it provision local workers on demand.");
1967
1970
  } else if (choices.installMode === "worker") {
1968
1971
  prompter.note("Next step: start this worker node so it can register with the controller.");
1969
1972
  }
@@ -2,7 +2,7 @@
2
2
  "id": "teamclaw",
3
3
  "name": "TeamClaw",
4
4
  "description": "Virtual team collaboration - multiple OpenClaw instances form a virtual software company with role-based task routing.",
5
- "version": "2026.4.2-2",
5
+ "version": "2026.4.2-3",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "processModel": {
63
63
  "label": "Process Model",
64
- "help": "TeamClaw runs workers as separate gateway processes"
64
+ "help": "TeamClaw runs workers as local or provisioned gateway processes"
65
65
  },
66
66
  "workerProvisioningType": {
67
67
  "label": "On-demand Worker Provider",
@@ -234,7 +234,7 @@
234
234
  "multi"
235
235
  ],
236
236
  "default": "multi",
237
- "description": "Worker execution model: TeamClaw runs workers as separate gateway processes"
237
+ "description": "Worker execution model: TeamClaw runs workers as local or provisioned gateway processes"
238
238
  },
239
239
  "workerProvisioningType": {
240
240
  "type": "string",
@@ -284,7 +284,7 @@
284
284
  },
285
285
  "workerProvisioningMaxPerRole": {
286
286
  "type": "number",
287
- "default": 3,
287
+ "default": 10,
288
288
  "description": "Maximum on-demand workers to launch per role"
289
289
  },
290
290
  "workerProvisioningIdleTtlMs": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamclaws/teamclaw",
3
- "version": "2026.4.2-2",
3
+ "version": "2026.4.2-3",
4
4
  "description": "OpenClaw virtual software team orchestration plugin",
5
5
  "private": false,
6
6
  "keywords": [
@@ -203,7 +203,7 @@ Mention this to users who prefer a visual overview.
203
203
  | Issue | Solution |
204
204
  |-------|----------|
205
205
  | Health check fails | Ensure TeamClaw plugin is enabled in controller mode: `openclaw plugins list` |
206
- | No workers available | Check processModel config; single-process creates workers automatically |
206
+ | No workers available | Check `workerProvisioningType` / `workerProvisioningDisabled`; local installs should usually use process provisioning |
207
207
  | Intake times out | Increase `taskTimeoutMs` in TeamClaw config; ensure AI model is responsive |
208
208
  | Task stuck in pending | No idle worker for the assigned role; check `GET /api/v1/workers` |
209
209
  | Clarification blocking | Answer pending clarifications via `POST /api/v1/clarifications/:id/answer` |
package/src/config.ts CHANGED
@@ -79,7 +79,7 @@ function buildConfigSchema() {
79
79
  type: "string" as const,
80
80
  enum: ["multi"],
81
81
  default: "multi",
82
- description: "Worker execution model: TeamClaw runs workers as separate gateway processes",
82
+ description: "Worker execution model: TeamClaw runs workers as local or provisioned gateway processes",
83
83
  },
84
84
  workerProvisioningType: {
85
85
  type: "string" as const,
@@ -113,7 +113,7 @@ function buildConfigSchema() {
113
113
  },
114
114
  workerProvisioningMaxPerRole: {
115
115
  type: "number" as const,
116
- default: 3,
116
+ default: 10,
117
117
  description: "Maximum on-demand workers to launch per role",
118
118
  },
119
119
  workerProvisioningIdleTtlMs: {
@@ -253,7 +253,7 @@ function buildConfigSchema() {
253
253
  },
254
254
  processModel: {
255
255
  label: "Process Model",
256
- help: "TeamClaw runs workers as separate gateway processes",
256
+ help: "TeamClaw runs workers as local or provisioned gateway processes",
257
257
  },
258
258
  workerProvisioningType: {
259
259
  label: "On-demand Worker Provider",
@@ -1,13 +1,13 @@
1
1
  import type { PluginConfig, TeamState } from "../types.js";
2
2
 
3
3
  export function hasOnDemandWorkerProvisioning(
4
- config: Pick<PluginConfig, "workerProvisioningType" | "processModel">,
4
+ config: Pick<PluginConfig, "workerProvisioningType" | "workerProvisioningDisabled" | "processModel">,
5
5
  ): boolean {
6
- return config.workerProvisioningType !== "none";
6
+ return config.workerProvisioningType !== "none" && config.workerProvisioningDisabled !== true;
7
7
  }
8
8
 
9
9
  export function shouldBlockControllerWithoutWorkers(
10
- config: Pick<PluginConfig, "workerProvisioningType" | "processModel">,
10
+ config: Pick<PluginConfig, "workerProvisioningType" | "workerProvisioningDisabled" | "processModel">,
11
11
  state: TeamState | null,
12
12
  ): boolean {
13
13
  return !!state && Object.keys(state.workers).length === 0 && !hasOnDemandWorkerProvisioning(config);
@@ -1427,6 +1427,9 @@ function buildControllerFollowUpMessage(task: TaskInfo, state: TeamState | null)
1427
1427
  "Continue orchestrating this same requirement.",
1428
1428
  "Review the current TeamClaw state before acting.",
1429
1429
  "Create only the next execution-ready task(s) whose prerequisites are now satisfied.",
1430
+ "For any large-scale requirement, prefer parallel fan-out over serial mega-phases: if multiple independent developer workstreams are ready, create all of them now instead of a single umbrella developer task.",
1431
+ "Decompose developer work by module or subsystem with clear file ownership and interfaces so multiple developer workers can collaborate safely in parallel.",
1432
+ "Use TeamClaw's available same-role capacity: create multiple developer tasks when the work can proceed concurrently rather than forcing one developer to carry the whole rewrite alone.",
1430
1433
  "Do not duplicate tasks that already exist, are active, or are already completed.",
1431
1434
  "If this task produced a web application with a live preview URL, include it in your reply so the human can verify the result.",
1432
1435
  "If all planned phases are complete and no follow-ups remain, set requirementFullyComplete=true in the manifest and provide a final delivery summary.",
@@ -1436,6 +1439,37 @@ function buildControllerFollowUpMessage(task: TaskInfo, state: TeamState | null)
1436
1439
  return parts.filter(Boolean).join("\n");
1437
1440
  }
1438
1441
 
1442
+ function buildControllerParallelHelpMessage(
1443
+ task: TaskInfo,
1444
+ state: TeamState | null,
1445
+ input: {
1446
+ requestedBy: string;
1447
+ requestedByRole?: RoleId;
1448
+ targetRole?: RoleId;
1449
+ reason: string;
1450
+ requestedWorkerCount?: number;
1451
+ suggestedWorkstreams: string[];
1452
+ },
1453
+ ): string {
1454
+ const targetRole = input.targetRole || input.requestedByRole || task.assignedRole || "developer";
1455
+ return [
1456
+ buildControllerFollowUpMessage(task, state),
1457
+ "",
1458
+ "## Parallel Help Request",
1459
+ `Current worker ${input.requestedBy} has asked TeamClaw to expand parallel help for role ${targetRole}.`,
1460
+ input.requestedByRole ? `Requesting worker role: ${input.requestedByRole}` : "",
1461
+ typeof input.requestedWorkerCount === "number"
1462
+ ? `Desired same-role worker capacity for this requirement: ${input.requestedWorkerCount}`
1463
+ : "",
1464
+ `Why more parallel help is needed: ${input.reason}`,
1465
+ input.suggestedWorkstreams.length > 0
1466
+ ? `Suggested parallel workstreams:\n${input.suggestedWorkstreams.map((item) => `- ${item}`).join("\n")}`
1467
+ : "",
1468
+ "If the suggested workstreams are genuinely independent, create multiple execution-ready tasks for that role now instead of keeping one giant serial task.",
1469
+ "Reuse active tasks when possible, but if no matching tasks already exist, fan out the work into distinct module- or subsystem-scoped tasks that can run concurrently.",
1470
+ ].filter(Boolean).join("\n");
1471
+ }
1472
+
1439
1473
  function buildControllerClarificationAnswerMessage(
1440
1474
  clarification: ClarificationRequest,
1441
1475
  answer: string,
@@ -2644,6 +2678,8 @@ function enrichWithFilesystemHtmlScan(
2644
2678
 
2645
2679
  const MEANINGFUL_PROJECT_CHANGE_EXTENSIONS = new Set([
2646
2680
  ".js", ".jsx", ".ts", ".tsx", ".json", ".html", ".css", ".scss", ".md", ".txt", ".yml", ".yaml",
2681
+ ".go", ".mod", ".sum", ".py", ".rb", ".rs", ".java", ".kt", ".swift", ".c", ".cc", ".cpp", ".h", ".hpp",
2682
+ ".cs", ".php", ".sh", ".bash", ".zsh", ".sql", ".toml", ".ini",
2647
2683
  ]);
2648
2684
 
2649
2685
  const IGNORED_PROJECT_CHANGE_DIRS = new Set([
@@ -2668,7 +2704,57 @@ function taskRequiresMeaningfulProjectChangeGate(task: TaskInfo): boolean {
2668
2704
  return /\b(implement|build|fix|rework|update|add|enhanc|deliver|write|create)\b/u.test(text);
2669
2705
  }
2670
2706
 
2671
- function projectHasMeaningfulFileChanges(task: TaskInfo): boolean {
2707
+ function projectHasMeaningfulDeliverableEvidence(
2708
+ task: TaskInfo,
2709
+ contract: WorkerTaskResultContract | undefined,
2710
+ ): boolean {
2711
+ if (!task.projectDir || !contract) {
2712
+ return false;
2713
+ }
2714
+ const projectRoot = path.join(resolveTeamClawProjectsDir(), task.projectDir);
2715
+ for (const deliverable of contract.deliverables) {
2716
+ if (deliverable.kind !== "file" && deliverable.kind !== "directory") {
2717
+ continue;
2718
+ }
2719
+ const rawValue = deliverable.value.trim().replace(/\\/g, "/");
2720
+ if (!rawValue) {
2721
+ continue;
2722
+ }
2723
+ const normalizedValue = rawValue.startsWith("projects/")
2724
+ ? rawValue.slice("projects/".length)
2725
+ : rawValue;
2726
+ if (
2727
+ normalizedValue !== task.projectDir
2728
+ && !normalizedValue.startsWith(`${task.projectDir}/`)
2729
+ ) {
2730
+ continue;
2731
+ }
2732
+ const relativePath = normalizedValue === task.projectDir
2733
+ ? ""
2734
+ : normalizedValue.slice(task.projectDir.length + 1);
2735
+ const fullPath = relativePath ? path.join(projectRoot, relativePath) : projectRoot;
2736
+ try {
2737
+ const stats = fs.statSync(fullPath);
2738
+ if (stats.isFile()) {
2739
+ return true;
2740
+ }
2741
+ if (stats.isDirectory()) {
2742
+ const entries = fs.readdirSync(fullPath, { withFileTypes: true });
2743
+ if (entries.some((entry) => entry.isFile() || entry.isDirectory())) {
2744
+ return true;
2745
+ }
2746
+ }
2747
+ } catch {
2748
+ continue;
2749
+ }
2750
+ }
2751
+ return false;
2752
+ }
2753
+
2754
+ function projectHasMeaningfulFileChanges(
2755
+ task: TaskInfo,
2756
+ contract?: WorkerTaskResultContract,
2757
+ ): boolean {
2672
2758
  if (!task.projectDir) {
2673
2759
  return false;
2674
2760
  }
@@ -2714,7 +2800,7 @@ function projectHasMeaningfulFileChanges(task: TaskInfo): boolean {
2714
2800
  }
2715
2801
  }
2716
2802
  }
2717
- return false;
2803
+ return projectHasMeaningfulDeliverableEvidence(task, contract);
2718
2804
  }
2719
2805
 
2720
2806
  function allowsNoChangeCompletion(
@@ -2982,6 +3068,25 @@ function ensureTaskResultContract(
2982
3068
  return contract;
2983
3069
  }
2984
3070
 
3071
+ function buildEffectiveTaskResultContract(
3072
+ task: TaskInfo,
3073
+ result: string,
3074
+ error: string | undefined,
3075
+ submittedContract?: WorkerTaskResultContract,
3076
+ ): WorkerTaskResultContract {
3077
+ let contract = submittedContract
3078
+ ? filterStaleDeliverables(submittedContract, task.projectDir)
3079
+ : backfillWorkerTaskResultContract(task, result, error);
3080
+ if (!error) {
3081
+ const enriched = enrichDeliverablesWithPreviewInference(contract, result)
3082
+ ?? enrichWithFilesystemHtmlScan(contract, task.projectDir);
3083
+ if (enriched) {
3084
+ contract = enriched;
3085
+ }
3086
+ }
3087
+ return contract;
3088
+ }
3089
+
2985
3090
  function buildResultContractSection(task: TaskInfo): string {
2986
3091
  const contract = task.resultContract;
2987
3092
  if (!contract) {
@@ -4078,20 +4183,40 @@ async function handleRequest(
4078
4183
  }, deps);
4079
4184
  }
4080
4185
 
4081
- if (!error && submittedContract?.outcome === "blocked") {
4186
+ const effectiveContract = buildEffectiveTaskResultContract(currentTask, result, error, submittedContract);
4187
+
4188
+ if (!error && effectiveContract.outcome === "blocked") {
4189
+ updateTeamState((teamState) => {
4190
+ const task = teamState.tasks[taskId];
4191
+ if (!task) {
4192
+ return;
4193
+ }
4194
+ task.resultContract = effectiveContract;
4195
+ task.updatedAt = Date.now();
4196
+ });
4197
+ if (!submittedContract) {
4198
+ recordTaskExecutionEvent(taskId, {
4199
+ type: "lifecycle",
4200
+ phase: "result_contract_backfilled",
4201
+ source: "controller",
4202
+ message: "Worker did not submit a structured result contract; TeamClaw backfilled one from the recorded task result.",
4203
+ workerId: currentTask.assignedWorkerId,
4204
+ role: currentTask.assignedRole,
4205
+ }, deps);
4206
+ }
4082
4207
  const requested = await requestTaskClarification({
4083
4208
  taskId,
4084
4209
  requestedBy: workerId ?? "worker",
4085
4210
  requestedByWorkerId: workerId,
4086
4211
  requestedByRole: currentTask.assignedRole,
4087
- question: submittedContract.questions[0]
4212
+ question: effectiveContract.questions[0]
4088
4213
  ?? "This task is blocked and needs a human decision before work can continue. What should TeamClaw do next?",
4089
- blockingReason: submittedContract.blockers[0] ?? submittedContract.summary,
4214
+ blockingReason: effectiveContract.blockers[0] ?? effectiveContract.summary,
4090
4215
  context: [
4091
- submittedContract.notes,
4216
+ effectiveContract.notes,
4092
4217
  result.trim(),
4093
- submittedContract.keyPoints.length > 0
4094
- ? `Worker-provided commands/details:\n${submittedContract.keyPoints.join("\n")}`
4218
+ effectiveContract.keyPoints.length > 0
4219
+ ? `Worker-provided commands/details:\n${effectiveContract.keyPoints.join("\n")}`
4095
4220
  : "",
4096
4221
  ].filter(Boolean).join("\n\n"),
4097
4222
  }, deps);
@@ -4116,7 +4241,7 @@ async function handleRequest(
4116
4241
  !error
4117
4242
  && gatedTask
4118
4243
  && taskRequiresMeaningfulProjectChangeGate(gatedTask)
4119
- && !projectHasMeaningfulFileChanges(gatedTask)
4244
+ && !projectHasMeaningfulFileChanges(gatedTask, effectiveContract)
4120
4245
  && !allowsNoChangeCompletion(gatedTask, submittedContract, result)
4121
4246
  ) {
4122
4247
  error = "Task reported completion but no meaningful project file changes were detected in the assigned project directory.";
@@ -4205,6 +4330,63 @@ async function handleRequest(
4205
4330
  return;
4206
4331
  }
4207
4332
 
4333
+ // POST /api/v1/tasks/:id/parallel-help
4334
+ if (req.method === "POST" && pathname.match(/^\/api\/v1\/tasks\/[^/]+\/parallel-help$/)) {
4335
+ const taskId = pathname.split("/")[4]!;
4336
+ const body = await parseJsonBody(req);
4337
+ const currentTask = getTeamState()?.tasks[taskId];
4338
+ if (!currentTask) {
4339
+ sendError(res, 404, "Task not found");
4340
+ return;
4341
+ }
4342
+ const requestedBy = typeof body.requestedBy === "string" ? body.requestedBy : "";
4343
+ const reason = typeof body.reason === "string" ? body.reason.trim() : "";
4344
+ const requestedByRole = typeof body.requestedByRole === "string" ? body.requestedByRole as RoleId : undefined;
4345
+ const targetRole = typeof body.targetRole === "string" ? body.targetRole as RoleId : undefined;
4346
+ const requestedWorkerCount = typeof body.requestedWorkerCount === "number"
4347
+ ? Math.max(2, Math.min(10, Math.floor(body.requestedWorkerCount)))
4348
+ : undefined;
4349
+ const suggestedWorkstreams = Array.isArray(body.suggestedWorkstreams)
4350
+ ? body.suggestedWorkstreams.map((entry: unknown) => String(entry ?? "").trim()).filter(Boolean)
4351
+ : [];
4352
+ if (!requestedBy || !reason) {
4353
+ sendError(res, 400, "requestedBy and reason are required");
4354
+ return;
4355
+ }
4356
+ const sessionKey = resolveControllerWorkflowSessionKey(currentTask, getTeamState());
4357
+ if (!sessionKey) {
4358
+ sendError(res, 409, "Task is not linked to a controller session");
4359
+ return;
4360
+ }
4361
+ recordTaskExecutionEvent(taskId, {
4362
+ type: "progress",
4363
+ phase: "parallel_help_requested",
4364
+ source: "worker",
4365
+ message: `Worker requested more ${targetRole || requestedByRole || currentTask.assignedRole || "developer"} capacity for parallel work: ${reason}`,
4366
+ workerId: requestedBy,
4367
+ role: requestedByRole,
4368
+ }, deps);
4369
+ const result = await runControllerIntake(
4370
+ buildControllerParallelHelpMessage(currentTask, getTeamState(), {
4371
+ requestedBy,
4372
+ requestedByRole,
4373
+ targetRole,
4374
+ reason,
4375
+ requestedWorkerCount,
4376
+ suggestedWorkstreams,
4377
+ }),
4378
+ sessionKey,
4379
+ deps,
4380
+ {
4381
+ source: "task_follow_up",
4382
+ sourceTaskId: currentTask.id,
4383
+ sourceTaskTitle: currentTask.title,
4384
+ },
4385
+ );
4386
+ sendJson(res, 201, result);
4387
+ return;
4388
+ }
4389
+
4208
4390
  // ==================== Message Routing ====================
4209
4391
 
4210
4392
  // POST /api/v1/controller/manifest
@@ -45,6 +45,7 @@ export function buildRoleOperatingRules(options: {
45
45
  "- You are a team member, not the controller. Complete the current task yourself.",
46
46
  "- Stay within your assigned role. Do not switch roles unless the task explicitly asks for cross-role analysis.",
47
47
  "- Do not create new tasks, parallel workstreams, or extra backlog items on your own.",
48
+ "- If the assigned task is clearly too large for one worker but can be split into independent same-role workstreams, ask the controller to expand parallel help instead of silently carrying the whole backlog alone.",
48
49
  "- Do not delegate the core work of your current task to another role.",
49
50
  "- Respect the requested deliverable shape: if the task asks for a brief, plan, matrix, review, or design artifact, do that artifact instead of expanding it into full implementation work.",
50
51
  "- If required information or a product/technical decision is missing, request clarification instead of guessing.",
@@ -81,6 +82,7 @@ export function buildWorkerSessionRules(): string[] {
81
82
  "1. Complete only the task assigned to this session.",
82
83
  "2. Pending team messages are context, not permission to widen scope.",
83
84
  "3. Do NOT create new tasks, duplicate an existing task, or start a parallel task tree.",
85
+ "3a. If the task is too large for one worker and can be safely split into independent same-role workstreams, use the controller-facing parallel-help tool instead of silently continuing as one giant serial task.",
84
86
  "4. If you are blocked by missing information, raise a clarification request and stop instead of guessing.",
85
87
  "5. If required infrastructure, credentials, or external tool access are unavailable in this runtime, raise a clarification request and stop instead of faking completion.",
86
88
  "6. Respect the task's requested deliverable: briefs, plans, matrices, reviews, and design artifacts are not implementation requests unless the task explicitly asks you to build code.",
package/src/state.ts CHANGED
@@ -20,6 +20,7 @@ function resolvePluginStateDir(): string {
20
20
  }
21
21
 
22
22
  const STATE_DIR = resolvePluginStateDir();
23
+ const writeQueues = new Map<string, Promise<void>>();
23
24
 
24
25
  function createEmptyProvisioningState(): TeamProvisioningState {
25
26
  return {
@@ -63,6 +64,25 @@ async function ensureDir(dir: string): Promise<void> {
63
64
  await fs.mkdir(dir, { recursive: true });
64
65
  }
65
66
 
67
+ async function writeFileAtomically(filePath: string, contents: string): Promise<void> {
68
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
69
+ await fs.writeFile(tmpPath, contents, "utf8");
70
+ await fs.rename(tmpPath, filePath);
71
+ }
72
+
73
+ function enqueueAtomicWrite(filePath: string, contents: string): Promise<void> {
74
+ const previous = writeQueues.get(filePath) ?? Promise.resolve();
75
+ const next = previous
76
+ .catch(() => {})
77
+ .then(() => writeFileAtomically(filePath, contents));
78
+ writeQueues.set(filePath, next);
79
+ return next.finally(() => {
80
+ if (writeQueues.get(filePath) === next) {
81
+ writeQueues.delete(filePath);
82
+ }
83
+ });
84
+ }
85
+
66
86
  async function loadTeamState(teamName: string): Promise<TeamState | null> {
67
87
  const filePath = path.join(STATE_DIR, `${teamName}-team-state.json`);
68
88
  try {
@@ -116,7 +136,7 @@ async function saveTeamState(state: TeamState): Promise<void> {
116
136
  state.controllerRuns = state.controllerRuns && typeof state.controllerRuns === "object"
117
137
  ? state.controllerRuns
118
138
  : {};
119
- await fs.writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
139
+ await enqueueAtomicWrite(filePath, `${JSON.stringify(state, null, 2)}\n`);
120
140
  }
121
141
 
122
142
  async function loadWorkerIdentity(): Promise<WorkerIdentity | null> {
@@ -141,7 +161,7 @@ async function loadWorkerIdentity(): Promise<WorkerIdentity | null> {
141
161
  async function saveWorkerIdentity(identity: WorkerIdentity): Promise<void> {
142
162
  await ensureDir(STATE_DIR);
143
163
  const filePath = path.join(STATE_DIR, "worker-identity.json");
144
- await fs.writeFile(filePath, `${JSON.stringify(identity, null, 2)}\n`, "utf8");
164
+ await enqueueAtomicWrite(filePath, `${JSON.stringify(identity, null, 2)}\n`);
145
165
  }
146
166
 
147
167
  async function clearWorkerIdentity(): Promise<void> {
package/src/types.ts CHANGED
@@ -562,7 +562,7 @@ export function parsePluginConfig(raw: Record<string, unknown> = {}): PluginConf
562
562
  ? raw.heartbeatIntervalMs
563
563
  : 10000;
564
564
 
565
- const processModel: ProcessModel = "multi";
565
+ const processModel = parseProcessModel(raw.processModel);
566
566
 
567
567
  const taskTimeoutMs = typeof raw.taskTimeoutMs === "number" && raw.taskTimeoutMs >= 1000
568
568
  ? raw.taskTimeoutMs
@@ -717,6 +717,12 @@ function parseProvisioningType(raw: unknown): WorkerProvisioningType {
717
717
  : "none";
718
718
  }
719
719
 
720
+ function parseProcessModel(raw: unknown): ProcessModel {
721
+ return typeof raw === "string" && raw.trim() === "multi"
722
+ ? "multi"
723
+ : "multi";
724
+ }
725
+
720
726
  function parseStringArray(raw: unknown): string[] {
721
727
  return Array.isArray(raw)
722
728
  ? [...new Set(raw
@@ -10,11 +10,28 @@ import {
10
10
  normalizeWorkerTaskResultContract,
11
11
  renderWorkerProgressText,
12
12
  } from "../interaction-contracts.js";
13
+ import { loadWorkerIdentity } from "../state.js";
13
14
  import type { PluginConfig, WorkerIdentity } from "../types.js";
14
15
  import { normalizeClarificationQuestionSchema } from "../controller/orchestration-manifest.js";
15
16
 
16
17
  const ALLOWED_PROGRESS_STATUSES = new Set(["in_progress", "review"]);
17
18
 
19
+ function normalizeProgressText(params: Record<string, unknown>): string {
20
+ if (typeof params.progress === "string" && params.progress.trim()) {
21
+ return params.progress.trim();
22
+ }
23
+ if (typeof params.summary === "string" && params.summary.trim()) {
24
+ return params.summary.trim();
25
+ }
26
+ if (typeof params.currentStep === "string" && params.currentStep.trim()) {
27
+ return params.currentStep.trim();
28
+ }
29
+ if (typeof params.message === "string" && params.message.trim()) {
30
+ return params.message.trim();
31
+ }
32
+ return "";
33
+ }
34
+
18
35
  export type WorkerToolsDeps = {
19
36
  config: PluginConfig;
20
37
  getIdentity: () => WorkerIdentity | null;
@@ -23,6 +40,10 @@ export type WorkerToolsDeps = {
23
40
  export function createWorkerTools(deps: WorkerToolsDeps) {
24
41
  const { config, getIdentity } = deps;
25
42
 
43
+ async function resolveIdentity(): Promise<WorkerIdentity | null> {
44
+ return getIdentity() ?? await loadWorkerIdentity();
45
+ }
46
+
26
47
  return [
27
48
  {
28
49
  name: "teamclaw_ask_peer",
@@ -38,7 +59,7 @@ export function createWorkerTools(deps: WorkerToolsDeps) {
38
59
  references: Type.Optional(Type.Array(Type.String({ description: "Relevant task IDs, file paths, or artifact references" }))),
39
60
  }),
40
61
  async execute(_id: string, params: Record<string, unknown>) {
41
- const identity = getIdentity();
62
+ const identity = await resolveIdentity();
42
63
  if (!identity) {
43
64
  return { content: [{ type: "text" as const, text: "Not registered with a team. Cannot send messages." }] };
44
65
  }
@@ -101,7 +122,7 @@ export function createWorkerTools(deps: WorkerToolsDeps) {
101
122
  references: Type.Optional(Type.Array(Type.String({ description: "Relevant task IDs, file paths, or artifact references" }))),
102
123
  }),
103
124
  async execute(_id: string, params: Record<string, unknown>) {
104
- const identity = getIdentity();
125
+ const identity = await resolveIdentity();
105
126
  if (!identity) {
106
127
  return { content: [{ type: "text" as const, text: "Not registered with a team." }] };
107
128
  }
@@ -158,7 +179,7 @@ export function createWorkerTools(deps: WorkerToolsDeps) {
158
179
  references: Type.Optional(Type.Array(Type.String({ description: "Relevant file paths, artifacts, or checks to review" }))),
159
180
  }),
160
181
  async execute(_id: string, params: Record<string, unknown>) {
161
- const identity = getIdentity();
182
+ const identity = await resolveIdentity();
162
183
  if (!identity) {
163
184
  return { content: [{ type: "text" as const, text: "Not registered with a team." }] };
164
185
  }
@@ -219,7 +240,7 @@ export function createWorkerTools(deps: WorkerToolsDeps) {
219
240
  artifacts: Type.Optional(Type.Array(Type.String({ description: "Files, task IDs, or artifacts the next role should inspect first" }))),
220
241
  }),
221
242
  async execute(_id: string, params: Record<string, unknown>) {
222
- const identity = getIdentity();
243
+ const identity = await resolveIdentity();
223
244
  if (!identity) {
224
245
  return { content: [{ type: "text" as const, text: "Not registered with a team." }] };
225
246
  }
@@ -298,7 +319,7 @@ export function createWorkerTools(deps: WorkerToolsDeps) {
298
319
  notes: Type.Optional(Type.String({ description: "Optional extra delivery notes" })),
299
320
  }),
300
321
  async execute(_id: string, params: Record<string, unknown>) {
301
- const identity = getIdentity();
322
+ const identity = await resolveIdentity();
302
323
  if (!identity) {
303
324
  return { content: [{ type: "text" as const, text: "Not registered with a team." }] };
304
325
  }
@@ -344,6 +365,68 @@ export function createWorkerTools(deps: WorkerToolsDeps) {
344
365
  }
345
366
  },
346
367
  },
368
+ {
369
+ name: "teamclaw_request_parallel_help",
370
+ label: "Request Parallel Help",
371
+ description: "Ask the controller to spawn more same-role or target-role workers for parallel work on this requirement",
372
+ parameters: Type.Object({
373
+ taskId: Type.String({ description: "Current task ID" }),
374
+ reason: Type.String({ description: "Why this task should be split across more workers now" }),
375
+ requestedWorkerCount: Type.Optional(Type.Number({ description: "Desired total worker count for this role after expansion" })),
376
+ targetRole: Type.Optional(Type.String({ description: "Role that should receive more workers; defaults to the current worker role" })),
377
+ suggestedWorkstreams: Type.Optional(Type.Array(Type.String({ description: "Concrete parallel workstreams or module slices the controller should fan out" }))),
378
+ }),
379
+ async execute(_id: string, params: Record<string, unknown>) {
380
+ const identity = await resolveIdentity();
381
+ if (!identity) {
382
+ return { content: [{ type: "text" as const, text: "Not registered with a team." }] };
383
+ }
384
+
385
+ const taskId = String(params.taskId ?? "");
386
+ const reason = String(params.reason ?? "").trim();
387
+ const targetRole = typeof params.targetRole === "string" && params.targetRole.trim()
388
+ ? params.targetRole.trim()
389
+ : identity.role;
390
+ const requestedWorkerCount = typeof params.requestedWorkerCount === "number"
391
+ ? Math.max(2, Math.min(10, Math.floor(params.requestedWorkerCount)))
392
+ : undefined;
393
+ const suggestedWorkstreams = Array.isArray(params.suggestedWorkstreams)
394
+ ? params.suggestedWorkstreams.map((entry) => String(entry ?? "").trim()).filter(Boolean)
395
+ : [];
396
+
397
+ if (!taskId || !reason) {
398
+ return { content: [{ type: "text" as const, text: "taskId and reason are required." }] };
399
+ }
400
+
401
+ try {
402
+ const res = await fetch(`${identity.controllerUrl}/api/v1/tasks/${taskId}/parallel-help`, {
403
+ method: "POST",
404
+ headers: { "Content-Type": "application/json" },
405
+ body: JSON.stringify({
406
+ requestedBy: identity.workerId,
407
+ requestedByRole: identity.role,
408
+ targetRole,
409
+ reason,
410
+ requestedWorkerCount,
411
+ suggestedWorkstreams,
412
+ }),
413
+ });
414
+
415
+ if (!res.ok) {
416
+ return { content: [{ type: "text" as const, text: `Failed to request parallel help: ${res.status}` }] };
417
+ }
418
+
419
+ return {
420
+ content: [{
421
+ type: "text" as const,
422
+ text: `Parallel help requested for ${taskId}${requestedWorkerCount ? ` (target ${targetRole} workers: ${requestedWorkerCount})` : ""}.`,
423
+ }],
424
+ };
425
+ } catch (err) {
426
+ return { content: [{ type: "text" as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }] };
427
+ }
428
+ },
429
+ },
347
430
  {
348
431
  name: "teamclaw_request_clarification",
349
432
  label: "Request Clarification",
@@ -372,7 +455,7 @@ export function createWorkerTools(deps: WorkerToolsDeps) {
372
455
  })),
373
456
  }),
374
457
  async execute(_id: string, params: Record<string, unknown>) {
375
- const identity = getIdentity();
458
+ const identity = await resolveIdentity();
376
459
  if (!identity) {
377
460
  return { content: [{ type: "text" as const, text: "Not registered with a team." }] };
378
461
  }
@@ -419,7 +502,7 @@ export function createWorkerTools(deps: WorkerToolsDeps) {
419
502
  description: "Get current team status including all workers and tasks",
420
503
  parameters: Type.Object({}),
421
504
  async execute(_id: string) {
422
- const identity = getIdentity();
505
+ const identity = await resolveIdentity();
423
506
  if (!identity) {
424
507
  return { content: [{ type: "text" as const, text: "Not registered with a team." }] };
425
508
  }
@@ -443,6 +526,7 @@ export function createWorkerTools(deps: WorkerToolsDeps) {
443
526
  parameters: Type.Object({
444
527
  taskId: Type.String({ description: "Task ID" }),
445
528
  progress: Type.Optional(Type.String({ description: "Progress update message" })),
529
+ message: Type.Optional(Type.String({ description: "Alias for progress when the runtime sends a generic message field" })),
446
530
  status: Type.Optional(Type.String({ description: "Optional non-terminal status: in_progress or review. Do not use completed or failed here." })),
447
531
  summary: Type.Optional(Type.String({ description: "Short structured progress summary" })),
448
532
  currentStep: Type.Optional(Type.String({ description: "What the worker is doing right now" })),
@@ -450,19 +534,19 @@ export function createWorkerTools(deps: WorkerToolsDeps) {
450
534
  blockers: Type.Optional(Type.Array(Type.String({ description: "Any blockers slowing progress" }))),
451
535
  }),
452
536
  async execute(_id: string, params: Record<string, unknown>) {
453
- const identity = getIdentity();
537
+ const identity = await resolveIdentity();
454
538
  if (!identity) {
455
539
  return { content: [{ type: "text" as const, text: "Not registered with a team." }] };
456
540
  }
457
541
 
458
542
  const taskId = String(params.taskId ?? "");
459
- const progress = typeof params.progress === "string" ? params.progress : "";
543
+ const progress = normalizeProgressText(params);
460
544
  const status = typeof params.status === "string" ? params.status : undefined;
461
545
 
462
546
  if (!taskId) {
463
547
  return { content: [{ type: "text" as const, text: "taskId is required." }] };
464
548
  }
465
- if (!progress && typeof params.summary !== "string") {
549
+ if (!progress) {
466
550
  return { content: [{ type: "text" as const, text: "progress or summary is required." }] };
467
551
  }
468
552
  if (status && !ALLOWED_PROGRESS_STATUSES.has(status)) {