@teamclaws/teamclaw 2026.3.24-5 → 2026.3.24-7
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/cli.mjs +422 -16
- package/index.ts +13 -4
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/controller/controller-capacity.ts +23 -0
- package/src/controller/controller-service.ts +27 -1
- package/src/controller/controller-tools.ts +14 -1
- package/src/controller/http-server.ts +24 -0
- package/src/controller/prompt-injector.ts +29 -7
package/cli.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import fsSync from "node:fs";
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
|
+
import http from "node:http";
|
|
6
7
|
import { createRequire } from "node:module";
|
|
7
8
|
import os from "node:os";
|
|
8
9
|
import path from "node:path";
|
|
@@ -25,7 +26,7 @@ const DEFAULT_TEAM_NAME = "default";
|
|
|
25
26
|
const DEFAULT_TASK_TIMEOUT_MS = 1_800_000;
|
|
26
27
|
const DEFAULT_AGENT_TIMEOUT_SECONDS = 2_400;
|
|
27
28
|
const DEFAULT_LOCAL_ROLES = ["architect", "developer", "qa"];
|
|
28
|
-
const
|
|
29
|
+
const LEGACY_DEFAULT_PROVISIONING_ROLES = ["architect", "developer", "qa"];
|
|
29
30
|
|
|
30
31
|
const ROLE_OPTIONS = [
|
|
31
32
|
{ value: "pm", label: "Product Manager" },
|
|
@@ -189,6 +190,30 @@ function resolveDefaultOpenClawWorkspaceDir(env = process.env) {
|
|
|
189
190
|
return path.join(resolveDefaultOpenClawStateDir(env), "workspace");
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
function resolveOpenClawStateDirForConfigPath(configPath) {
|
|
194
|
+
return path.dirname(path.resolve(configPath));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveOpenClawWorkspaceDirForConfigPath(configPath) {
|
|
198
|
+
return path.join(resolveOpenClawStateDirForConfigPath(configPath), "workspace");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sanitizeInstallerPathSegment(value) {
|
|
202
|
+
const normalized = String(value || "")
|
|
203
|
+
.toLowerCase()
|
|
204
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
205
|
+
.replace(/^-+|-+$/g, "");
|
|
206
|
+
return normalized || "default";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolveDefaultTeamClawWorkspaceDir(configPath, teamName) {
|
|
210
|
+
return path.join(
|
|
211
|
+
resolveOpenClawStateDirForConfigPath(configPath),
|
|
212
|
+
"teamclaw-workspaces",
|
|
213
|
+
sanitizeInstallerPathSegment(teamName),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
192
217
|
async function pathExists(targetPath) {
|
|
193
218
|
try {
|
|
194
219
|
await fs.access(targetPath);
|
|
@@ -309,10 +334,40 @@ function getCurrentWorkspacePath(config) {
|
|
|
309
334
|
return typeof defaults.workspace === "string" ? expandUserPath(defaults.workspace) : "";
|
|
310
335
|
}
|
|
311
336
|
|
|
337
|
+
function resolveInstallerWorkspaceDefault(configPath, config, teamName) {
|
|
338
|
+
const currentWorkspacePath = getCurrentWorkspacePath(config);
|
|
339
|
+
const sharedWorkspacePath = resolveOpenClawWorkspaceDirForConfigPath(configPath);
|
|
340
|
+
if (currentWorkspacePath && path.resolve(currentWorkspacePath) !== path.resolve(sharedWorkspacePath)) {
|
|
341
|
+
return currentWorkspacePath;
|
|
342
|
+
}
|
|
343
|
+
return resolveDefaultTeamClawWorkspaceDir(configPath, teamName);
|
|
344
|
+
}
|
|
345
|
+
|
|
312
346
|
function dedupeStrings(values) {
|
|
313
347
|
return Array.from(new Set(values.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim())));
|
|
314
348
|
}
|
|
315
349
|
|
|
350
|
+
function hasSameStringSet(left, right) {
|
|
351
|
+
const normalizedLeft = dedupeStrings(left).slice().sort();
|
|
352
|
+
const normalizedRight = dedupeStrings(right).slice().sort();
|
|
353
|
+
if (normalizedLeft.length !== normalizedRight.length) {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
return normalizedLeft.every((value, index) => value === normalizedRight[index]);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function normalizeConfiguredRoleList(raw) {
|
|
360
|
+
return Array.isArray(raw) ? dedupeStrings(raw) : [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function resolveDefaultProvisioningRoles(existingTeamClaw) {
|
|
364
|
+
const existingRoles = normalizeConfiguredRoleList(existingTeamClaw.workerProvisioningRoles);
|
|
365
|
+
if (existingRoles.length === 0) {
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
return hasSameStringSet(existingRoles, LEGACY_DEFAULT_PROVISIONING_ROLES) ? [] : existingRoles;
|
|
369
|
+
}
|
|
370
|
+
|
|
316
371
|
function extractModelOptions(config) {
|
|
317
372
|
const currentModel = getCurrentModel(config);
|
|
318
373
|
const models = [];
|
|
@@ -519,6 +574,28 @@ async function promptRoleList(prompter, message, defaultRoles) {
|
|
|
519
574
|
return parseRoleList(raw).values;
|
|
520
575
|
}
|
|
521
576
|
|
|
577
|
+
async function promptOptionalRoleList(prompter, message, defaultRoles) {
|
|
578
|
+
const defaultValue = defaultRoles.join(",");
|
|
579
|
+
if (!prompter.yes) {
|
|
580
|
+
console.log(
|
|
581
|
+
`Available roles: ${ROLE_OPTIONS.map((option) => `${option.value} (${option.label})`).join(", ")}. Leave empty to allow all roles.`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
const raw = await prompter.text({
|
|
585
|
+
message,
|
|
586
|
+
defaultValue,
|
|
587
|
+
allowEmpty: true,
|
|
588
|
+
validate: (value) => {
|
|
589
|
+
const parsed = parseRoleList(value);
|
|
590
|
+
if (parsed.invalid.length > 0) {
|
|
591
|
+
return `Unknown role ids: ${parsed.invalid.join(", ")}`;
|
|
592
|
+
}
|
|
593
|
+
return "";
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
return parseRoleList(raw).values;
|
|
597
|
+
}
|
|
598
|
+
|
|
522
599
|
function buildStartCommand(configPath) {
|
|
523
600
|
const defaultPath = resolveDefaultOpenClawConfigPath();
|
|
524
601
|
if (path.resolve(configPath) === path.resolve(defaultPath)) {
|
|
@@ -534,6 +611,57 @@ function shellEscape(value) {
|
|
|
534
611
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
535
612
|
}
|
|
536
613
|
|
|
614
|
+
function isControllerInstallMode(installMode) {
|
|
615
|
+
return installMode !== "worker";
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function isOnDemandControllerInstallMode(installMode) {
|
|
619
|
+
return installMode === "controller-process" || installMode === "controller-docker" || installMode === "controller-kubernetes";
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function describeProvisioningRoles(roles) {
|
|
623
|
+
return Array.isArray(roles) && roles.length > 0
|
|
624
|
+
? roles.join(", ")
|
|
625
|
+
: "all TeamClaw roles (controller decides at runtime)";
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function getLocalUiUrl(port) {
|
|
629
|
+
return `http://127.0.0.1:${port}/ui`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function rankLanAddress(address) {
|
|
633
|
+
if (address.startsWith("192.168.")) {
|
|
634
|
+
return 0;
|
|
635
|
+
}
|
|
636
|
+
if (address.startsWith("10.")) {
|
|
637
|
+
return 1;
|
|
638
|
+
}
|
|
639
|
+
const parts = address.split(".").map((value) => Number.parseInt(value, 10));
|
|
640
|
+
if (parts.length === 4 && parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) {
|
|
641
|
+
return 2;
|
|
642
|
+
}
|
|
643
|
+
return 3;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function listLanUiUrls(port) {
|
|
647
|
+
const urls = [];
|
|
648
|
+
const interfaces = os.networkInterfaces();
|
|
649
|
+
for (const records of Object.values(interfaces)) {
|
|
650
|
+
for (const record of records ?? []) {
|
|
651
|
+
if (!record || record.internal || record.family !== "IPv4") {
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
urls.push({
|
|
655
|
+
address: record.address,
|
|
656
|
+
url: `http://${record.address}:${port}/ui`,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return urls
|
|
661
|
+
.sort((left, right) => rankLanAddress(left.address) - rankLanAddress(right.address) || left.address.localeCompare(right.address))
|
|
662
|
+
.map((entry) => entry.url);
|
|
663
|
+
}
|
|
664
|
+
|
|
537
665
|
function installPluginWithCommand(command, args, env) {
|
|
538
666
|
const result = spawnSync(command, args, {
|
|
539
667
|
stdio: "inherit",
|
|
@@ -546,6 +674,50 @@ function installPluginWithCommand(command, args, env) {
|
|
|
546
674
|
};
|
|
547
675
|
}
|
|
548
676
|
|
|
677
|
+
function runGatewayCommand(command, args, env) {
|
|
678
|
+
const result = spawnSync(command, args, {
|
|
679
|
+
env,
|
|
680
|
+
encoding: "utf8",
|
|
681
|
+
});
|
|
682
|
+
return {
|
|
683
|
+
status: result.status ?? 1,
|
|
684
|
+
signal: result.signal ?? null,
|
|
685
|
+
error: result.error ?? null,
|
|
686
|
+
stdout: result.stdout ?? "",
|
|
687
|
+
stderr: result.stderr ?? "",
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function readJsonIfExists(filePath) {
|
|
692
|
+
try {
|
|
693
|
+
if (!fsSync.existsSync(filePath)) {
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
return JSON.parse(fsSync.readFileSync(filePath, "utf8"));
|
|
697
|
+
} catch {
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function inspectInstalledPlugin(configPath) {
|
|
703
|
+
const stateDir = path.dirname(path.resolve(configPath));
|
|
704
|
+
const pluginDir = path.join(stateDir, "extensions", PLUGIN_ID);
|
|
705
|
+
if (!fsSync.existsSync(pluginDir)) {
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
const manifest = readJsonIfExists(path.join(pluginDir, "openclaw.plugin.json"));
|
|
709
|
+
const packageJson = readJsonIfExists(path.join(pluginDir, "package.json"));
|
|
710
|
+
const version = typeof manifest?.version === "string" && manifest.version.trim()
|
|
711
|
+
? manifest.version.trim()
|
|
712
|
+
: typeof packageJson?.version === "string" && packageJson.version.trim()
|
|
713
|
+
? packageJson.version.trim()
|
|
714
|
+
: "";
|
|
715
|
+
return {
|
|
716
|
+
pluginDir,
|
|
717
|
+
version: version || null,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
549
721
|
function createPackageTarball(env) {
|
|
550
722
|
let tempDir = "";
|
|
551
723
|
try {
|
|
@@ -591,11 +763,82 @@ function createPackageTarball(env) {
|
|
|
591
763
|
}
|
|
592
764
|
}
|
|
593
765
|
|
|
766
|
+
function attemptPluginUninstall({ configPath }) {
|
|
767
|
+
const env = {
|
|
768
|
+
...process.env,
|
|
769
|
+
OPENCLAW_CONFIG_PATH: configPath,
|
|
770
|
+
};
|
|
771
|
+
const candidates = [
|
|
772
|
+
{
|
|
773
|
+
label: "openclaw",
|
|
774
|
+
command: "openclaw",
|
|
775
|
+
args: ["plugins", "uninstall", PLUGIN_ID, "--force"],
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
label: "npm exec fallback",
|
|
779
|
+
command: "npm",
|
|
780
|
+
args: ["exec", "-y", "openclaw@latest", "--", "plugins", "uninstall", PLUGIN_ID, "--force"],
|
|
781
|
+
},
|
|
782
|
+
];
|
|
783
|
+
const failures = [];
|
|
784
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
785
|
+
const candidate = candidates[index];
|
|
786
|
+
console.log(`\nRemoving existing ${PLUGIN_ID} plugin with ${candidate.label}...`);
|
|
787
|
+
const result = installPluginWithCommand(candidate.command, candidate.args, env);
|
|
788
|
+
if (result.status === 0 && !result.error) {
|
|
789
|
+
return {
|
|
790
|
+
ok: true,
|
|
791
|
+
method: candidate.label,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
const errorCode = result.error && typeof result.error === "object" ? result.error.code : "";
|
|
795
|
+
const detail = result.error
|
|
796
|
+
? result.error.message
|
|
797
|
+
: result.signal
|
|
798
|
+
? `terminated by signal ${result.signal}`
|
|
799
|
+
: `exited with code ${result.status}`;
|
|
800
|
+
failures.push(`${candidate.label} failed: ${detail}`);
|
|
801
|
+
if (errorCode === "ENOENT" && index < candidates.length - 1) {
|
|
802
|
+
console.log(`${candidate.command} was not found. Trying the next uninstall fallback...`);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
ok: false,
|
|
809
|
+
error: failures.join("; "),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
594
813
|
function attemptPluginInstall({ configPath }) {
|
|
595
814
|
const env = {
|
|
596
815
|
...process.env,
|
|
597
816
|
OPENCLAW_CONFIG_PATH: configPath,
|
|
598
817
|
};
|
|
818
|
+
const installedPlugin = inspectInstalledPlugin(configPath);
|
|
819
|
+
if (installedPlugin?.version === PACKAGE_VERSION) {
|
|
820
|
+
console.log(
|
|
821
|
+
`\nFound existing TeamClaw plugin at ${installedPlugin.pluginDir} (version ${installedPlugin.version}). Skipping plugin reinstall.`,
|
|
822
|
+
);
|
|
823
|
+
return {
|
|
824
|
+
ok: true,
|
|
825
|
+
method: `already installed (${installedPlugin.version})`,
|
|
826
|
+
skipped: true,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
if (installedPlugin) {
|
|
830
|
+
const installedVersion = installedPlugin.version ? `version ${installedPlugin.version}` : "an unknown version";
|
|
831
|
+
console.log(
|
|
832
|
+
`\nFound existing TeamClaw plugin at ${installedPlugin.pluginDir} (${installedVersion}). Removing it before install...`,
|
|
833
|
+
);
|
|
834
|
+
const uninstallResult = attemptPluginUninstall({ configPath });
|
|
835
|
+
if (!uninstallResult.ok) {
|
|
836
|
+
return {
|
|
837
|
+
ok: false,
|
|
838
|
+
error: `Could not remove existing TeamClaw plugin at ${installedPlugin.pluginDir}: ${uninstallResult.error}`,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
}
|
|
599
842
|
const candidates = [];
|
|
600
843
|
const tarballResult = createPackageTarball(env);
|
|
601
844
|
if (tarballResult.ok) {
|
|
@@ -674,10 +917,127 @@ function attemptPluginInstall({ configPath }) {
|
|
|
674
917
|
}
|
|
675
918
|
}
|
|
676
919
|
|
|
677
|
-
|
|
920
|
+
function attemptGatewayRestart({ configPath }) {
|
|
921
|
+
const env = {
|
|
922
|
+
...process.env,
|
|
923
|
+
OPENCLAW_CONFIG_PATH: configPath,
|
|
924
|
+
};
|
|
925
|
+
const candidates = [
|
|
926
|
+
{
|
|
927
|
+
label: "openclaw",
|
|
928
|
+
command: "openclaw",
|
|
929
|
+
args: ["gateway", "restart"],
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
label: "npm exec fallback",
|
|
933
|
+
command: "npm",
|
|
934
|
+
args: ["exec", "-y", "openclaw@latest", "--", "gateway", "restart"],
|
|
935
|
+
},
|
|
936
|
+
];
|
|
937
|
+
const failures = [];
|
|
938
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
939
|
+
const candidate = candidates[index];
|
|
940
|
+
const result = runGatewayCommand(candidate.command, candidate.args, env);
|
|
941
|
+
if (result.status === 0 && !result.error) {
|
|
942
|
+
return {
|
|
943
|
+
ok: true,
|
|
944
|
+
method: candidate.label,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
const detail = result.error
|
|
948
|
+
? result.error.message
|
|
949
|
+
: (result.stderr || result.stdout || (result.signal
|
|
950
|
+
? `terminated by signal ${result.signal}`
|
|
951
|
+
: `exited with code ${result.status}`)).trim();
|
|
952
|
+
failures.push(`${candidate.label} failed: ${detail}`);
|
|
953
|
+
const errorCode = result.error && typeof result.error === "object" ? result.error.code : "";
|
|
954
|
+
if (!(errorCode === "ENOENT" && index < candidates.length - 1)) {
|
|
955
|
+
break;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return {
|
|
959
|
+
ok: false,
|
|
960
|
+
error: failures.join("; "),
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
async function waitForControllerHealth(port) {
|
|
965
|
+
const url = `http://127.0.0.1:${port}/api/v1/health`;
|
|
966
|
+
const deadline = Date.now() + 30_000;
|
|
967
|
+
let lastError = "";
|
|
968
|
+
while (Date.now() < deadline) {
|
|
969
|
+
try {
|
|
970
|
+
const response = await new Promise((resolve, reject) => {
|
|
971
|
+
const request = http.get(
|
|
972
|
+
url,
|
|
973
|
+
{
|
|
974
|
+
agent: false,
|
|
975
|
+
headers: {
|
|
976
|
+
Connection: "close",
|
|
977
|
+
},
|
|
978
|
+
},
|
|
979
|
+
(incoming) => {
|
|
980
|
+
let body = "";
|
|
981
|
+
incoming.setEncoding("utf8");
|
|
982
|
+
incoming.on("data", (chunk) => {
|
|
983
|
+
body += chunk;
|
|
984
|
+
});
|
|
985
|
+
incoming.on("end", () => {
|
|
986
|
+
resolve({
|
|
987
|
+
statusCode: incoming.statusCode ?? 0,
|
|
988
|
+
body,
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
},
|
|
992
|
+
);
|
|
993
|
+
request.setTimeout(5_000, () => {
|
|
994
|
+
request.destroy(new Error("request timed out"));
|
|
995
|
+
});
|
|
996
|
+
request.on("error", reject);
|
|
997
|
+
});
|
|
998
|
+
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
999
|
+
const payload = JSON.parse(response.body);
|
|
1000
|
+
if (payload && payload.status === "ok" && payload.mode === "controller") {
|
|
1001
|
+
return {
|
|
1002
|
+
ok: true,
|
|
1003
|
+
url,
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
lastError = "unexpected health payload";
|
|
1007
|
+
} else {
|
|
1008
|
+
lastError = `HTTP ${response.statusCode}`;
|
|
1009
|
+
}
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
1012
|
+
}
|
|
1013
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
1014
|
+
}
|
|
1015
|
+
return {
|
|
1016
|
+
ok: false,
|
|
1017
|
+
url,
|
|
1018
|
+
error: lastError || "timed out after 30s",
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async function collectInstallChoices(configPath, config, prompter) {
|
|
678
1023
|
const existingTeamClaw = getExistingTeamClawConfig(config);
|
|
679
1024
|
const existingMode = typeof existingTeamClaw.mode === "string" ? existingTeamClaw.mode.trim() : "";
|
|
680
|
-
const
|
|
1025
|
+
const existingProvisioningType =
|
|
1026
|
+
typeof existingTeamClaw.workerProvisioningType === "string" ? existingTeamClaw.workerProvisioningType.trim() : "";
|
|
1027
|
+
let modeDefault = "single-local";
|
|
1028
|
+
if (existingMode === "worker") {
|
|
1029
|
+
modeDefault = "worker";
|
|
1030
|
+
} else if (existingMode === "controller") {
|
|
1031
|
+
if (existingProvisioningType === "docker") {
|
|
1032
|
+
modeDefault = "controller-docker";
|
|
1033
|
+
} else if (existingProvisioningType === "kubernetes") {
|
|
1034
|
+
modeDefault = "controller-kubernetes";
|
|
1035
|
+
} else if (existingProvisioningType === "process") {
|
|
1036
|
+
modeDefault = "controller-process";
|
|
1037
|
+
} else if (!Array.isArray(existingTeamClaw.localRoles) || existingTeamClaw.localRoles.length === 0) {
|
|
1038
|
+
modeDefault = "controller-manual";
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
681
1041
|
|
|
682
1042
|
const installMode = await prompter.select({
|
|
683
1043
|
message: "Choose an installation mode",
|
|
@@ -710,7 +1070,7 @@ async function collectInstallChoices(config, prompter) {
|
|
|
710
1070
|
});
|
|
711
1071
|
const workspacePath = expandUserPath(await prompter.text({
|
|
712
1072
|
message: "OpenClaw workspace directory",
|
|
713
|
-
defaultValue:
|
|
1073
|
+
defaultValue: resolveInstallerWorkspaceDefault(configPath, config, teamName),
|
|
714
1074
|
}));
|
|
715
1075
|
|
|
716
1076
|
if (installMode === "worker") {
|
|
@@ -790,12 +1150,10 @@ async function collectInstallChoices(config, prompter) {
|
|
|
790
1150
|
};
|
|
791
1151
|
}
|
|
792
1152
|
|
|
793
|
-
const provisioningRoles = await
|
|
1153
|
+
const provisioningRoles = await promptOptionalRoleList(
|
|
794
1154
|
prompter,
|
|
795
|
-
"On-demand roles to
|
|
796
|
-
|
|
797
|
-
? existingTeamClaw.workerProvisioningRoles
|
|
798
|
-
: DEFAULT_PROVISIONING_ROLES,
|
|
1155
|
+
"On-demand roles to allow (comma-separated, leave empty for all roles)",
|
|
1156
|
+
resolveDefaultProvisioningRoles(existingTeamClaw),
|
|
799
1157
|
);
|
|
800
1158
|
const maxPerRole = await prompter.number({
|
|
801
1159
|
message: "Maximum on-demand workers per role",
|
|
@@ -838,11 +1196,11 @@ async function collectInstallChoices(config, prompter) {
|
|
|
838
1196
|
: DEFAULT_TEAMCLAW_IMAGE,
|
|
839
1197
|
});
|
|
840
1198
|
const dockerWorkspaceVolume = await prompter.text({
|
|
841
|
-
message: "Docker workspace volume or host path (leave empty for ephemeral workspaces)",
|
|
1199
|
+
message: "Docker workspace volume or host path (leave empty for isolated ephemeral workspaces)",
|
|
842
1200
|
defaultValue:
|
|
843
1201
|
typeof existingTeamClaw.workerProvisioningDockerWorkspaceVolume === "string"
|
|
844
1202
|
? existingTeamClaw.workerProvisioningDockerWorkspaceVolume.trim()
|
|
845
|
-
: "
|
|
1203
|
+
: "",
|
|
846
1204
|
allowEmpty: true,
|
|
847
1205
|
});
|
|
848
1206
|
return {
|
|
@@ -893,7 +1251,7 @@ async function collectInstallChoices(config, prompter) {
|
|
|
893
1251
|
: "teamclaw-worker",
|
|
894
1252
|
});
|
|
895
1253
|
const kubernetesWorkspacePersistentVolumeClaim = await prompter.text({
|
|
896
|
-
message: "Kubernetes workspace PVC (leave empty for ephemeral workspaces)",
|
|
1254
|
+
message: "Kubernetes workspace PVC (leave empty for isolated ephemeral workspaces)",
|
|
897
1255
|
defaultValue:
|
|
898
1256
|
typeof existingTeamClaw.workerProvisioningKubernetesWorkspacePersistentVolumeClaim === "string"
|
|
899
1257
|
? existingTeamClaw.workerProvisioningKubernetesWorkspacePersistentVolumeClaim.trim()
|
|
@@ -1133,19 +1491,38 @@ function buildSummaryLines(params) {
|
|
|
1133
1491
|
}
|
|
1134
1492
|
if (params.pluginInstallStatus === "installed") {
|
|
1135
1493
|
lines.push(`Plugin install: completed via ${params.pluginInstallMethod}`);
|
|
1494
|
+
} else if (params.pluginInstallStatus === "already-installed") {
|
|
1495
|
+
lines.push(`Plugin install: ${params.pluginInstallMethod}`);
|
|
1136
1496
|
} else if (params.pluginInstallStatus === "skipped") {
|
|
1137
1497
|
lines.push("Plugin install: skipped");
|
|
1138
1498
|
} else if (params.pluginInstallError) {
|
|
1139
1499
|
lines.push(`Plugin install: ${params.pluginInstallError}`);
|
|
1140
1500
|
}
|
|
1501
|
+
if (params.gatewayRestartStatus === "restarted") {
|
|
1502
|
+
lines.push(`Gateway restart: completed via ${params.gatewayRestartMethod}`);
|
|
1503
|
+
} else if (params.gatewayRestartStatus === "failed") {
|
|
1504
|
+
lines.push(`Gateway restart: ${params.gatewayRestartError}`);
|
|
1505
|
+
}
|
|
1506
|
+
if (params.controllerHealthStatus === "ok") {
|
|
1507
|
+
lines.push(`Controller health: ok (${params.controllerHealthUrl})`);
|
|
1508
|
+
} else if (params.controllerHealthStatus === "failed") {
|
|
1509
|
+
lines.push(`Controller health: ${params.controllerHealthError} (${params.controllerHealthUrl})`);
|
|
1510
|
+
}
|
|
1141
1511
|
lines.push(`Start command: ${buildStartCommand(params.configPath)}`);
|
|
1142
1512
|
|
|
1143
|
-
if (params.choices.installMode
|
|
1144
|
-
|
|
1513
|
+
if (isControllerInstallMode(params.choices.installMode)) {
|
|
1514
|
+
const lanUiUrls = listLanUiUrls(params.choices.controllerPort);
|
|
1515
|
+
if (lanUiUrls.length > 0) {
|
|
1516
|
+
lines.push(`Open UI (LAN): ${lanUiUrls[0]}`);
|
|
1517
|
+
}
|
|
1518
|
+
lines.push(`Open UI (local): ${getLocalUiUrl(params.choices.controllerPort)}`);
|
|
1145
1519
|
}
|
|
1146
1520
|
if (params.choices.installMode === "controller-docker" || params.choices.installMode === "controller-kubernetes") {
|
|
1147
1521
|
lines.push(`Provisioning image: ${params.choices.workerImage}`);
|
|
1148
1522
|
}
|
|
1523
|
+
if (isOnDemandControllerInstallMode(params.choices.installMode)) {
|
|
1524
|
+
lines.push(`On-demand roles: ${describeProvisioningRoles(params.choices.provisioningRoles)}`);
|
|
1525
|
+
}
|
|
1149
1526
|
if (params.choices.installMode === "controller-docker" && params.choices.dockerWorkspaceVolume) {
|
|
1150
1527
|
lines.push(`Docker workspace volume: ${params.choices.dockerWorkspaceVolume}`);
|
|
1151
1528
|
}
|
|
@@ -1197,7 +1574,7 @@ async function runInstall(options) {
|
|
|
1197
1574
|
if (!options.skipPluginInstall && !options.dryRun) {
|
|
1198
1575
|
const installResult = attemptPluginInstall({ configPath });
|
|
1199
1576
|
if (installResult.ok) {
|
|
1200
|
-
pluginInstallStatus = "installed";
|
|
1577
|
+
pluginInstallStatus = installResult.skipped ? "already-installed" : "installed";
|
|
1201
1578
|
pluginInstallMethod = installResult.method;
|
|
1202
1579
|
} else {
|
|
1203
1580
|
pluginInstallStatus = "failed";
|
|
@@ -1214,7 +1591,7 @@ async function runInstall(options) {
|
|
|
1214
1591
|
}
|
|
1215
1592
|
|
|
1216
1593
|
const config = await readOpenClawConfig(configPath);
|
|
1217
|
-
const choices = await collectInstallChoices(config, prompter);
|
|
1594
|
+
const choices = await collectInstallChoices(configPath, config, prompter);
|
|
1218
1595
|
const nextConfig = applyInstallerChoices(config, choices);
|
|
1219
1596
|
|
|
1220
1597
|
if (options.dryRun) {
|
|
@@ -1223,6 +1600,29 @@ async function runInstall(options) {
|
|
|
1223
1600
|
await writeConfig(configPath, nextConfig);
|
|
1224
1601
|
}
|
|
1225
1602
|
|
|
1603
|
+
let gatewayRestartStatus = "skipped";
|
|
1604
|
+
let gatewayRestartMethod = "";
|
|
1605
|
+
let gatewayRestartError = "";
|
|
1606
|
+
let controllerHealthStatus = "skipped";
|
|
1607
|
+
let controllerHealthUrl = "";
|
|
1608
|
+
let controllerHealthError = "";
|
|
1609
|
+
if (!options.dryRun) {
|
|
1610
|
+
const restartResult = attemptGatewayRestart({ configPath });
|
|
1611
|
+
if (restartResult.ok) {
|
|
1612
|
+
gatewayRestartStatus = "restarted";
|
|
1613
|
+
gatewayRestartMethod = restartResult.method;
|
|
1614
|
+
if (isControllerInstallMode(choices.installMode)) {
|
|
1615
|
+
const healthResult = await waitForControllerHealth(choices.controllerPort);
|
|
1616
|
+
controllerHealthStatus = healthResult.ok ? "ok" : "failed";
|
|
1617
|
+
controllerHealthUrl = healthResult.url;
|
|
1618
|
+
controllerHealthError = healthResult.error ?? "";
|
|
1619
|
+
}
|
|
1620
|
+
} else {
|
|
1621
|
+
gatewayRestartStatus = "failed";
|
|
1622
|
+
gatewayRestartError = restartResult.error;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1226
1626
|
const summaryLines = buildSummaryLines({
|
|
1227
1627
|
configPath,
|
|
1228
1628
|
choices,
|
|
@@ -1230,6 +1630,12 @@ async function runInstall(options) {
|
|
|
1230
1630
|
pluginInstallStatus,
|
|
1231
1631
|
pluginInstallMethod,
|
|
1232
1632
|
pluginInstallError,
|
|
1633
|
+
gatewayRestartStatus,
|
|
1634
|
+
gatewayRestartMethod,
|
|
1635
|
+
gatewayRestartError,
|
|
1636
|
+
controllerHealthStatus,
|
|
1637
|
+
controllerHealthUrl,
|
|
1638
|
+
controllerHealthError,
|
|
1233
1639
|
});
|
|
1234
1640
|
|
|
1235
1641
|
prompter.note("\nTeamClaw installer summary");
|
package/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { definePluginEntry, type OpenClawPluginApi } from "./api.js";
|
|
2
2
|
import { parsePluginConfig } from "./src/types.js";
|
|
3
|
-
import type { TaskExecutionEventInput, WorkerIdentity } from "./src/types.js";
|
|
3
|
+
import type { TaskExecutionEventInput, TeamState, WorkerIdentity } from "./src/types.js";
|
|
4
4
|
import { buildConfigSchema } from "./src/config.js";
|
|
5
5
|
import { loadTeamState } from "./src/state.js";
|
|
6
6
|
import { createRoleTaskExecutor } from "./src/task-executor.js";
|
|
@@ -39,9 +39,18 @@ function registerController(api: OpenClawPluginApi, config: ReturnType<typeof pa
|
|
|
39
39
|
logger,
|
|
40
40
|
runtime: api.runtime,
|
|
41
41
|
});
|
|
42
|
+
let getControllerTeamState = (): TeamState | null => null;
|
|
42
43
|
|
|
43
44
|
// Service (starts HTTP server + mDNS + WebSocket)
|
|
44
|
-
api.registerService(createControllerService({
|
|
45
|
+
api.registerService(createControllerService({
|
|
46
|
+
config,
|
|
47
|
+
logger,
|
|
48
|
+
runtime: api.runtime,
|
|
49
|
+
localWorkerManager,
|
|
50
|
+
onTeamStateAvailable: (getter) => {
|
|
51
|
+
getControllerTeamState = getter;
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
45
54
|
|
|
46
55
|
// Prompt injection
|
|
47
56
|
api.on("before_prompt_build", async (_event: unknown, ctx: { sessionKey?: string | null }) => {
|
|
@@ -56,7 +65,7 @@ function registerController(api: OpenClawPluginApi, config: ReturnType<typeof pa
|
|
|
56
65
|
return injector() ?? {};
|
|
57
66
|
}
|
|
58
67
|
|
|
59
|
-
const state = await loadTeamState(config.teamName);
|
|
68
|
+
const state = getControllerTeamState() ?? await loadTeamState(config.teamName);
|
|
60
69
|
const injector = createControllerPromptInjector({
|
|
61
70
|
config,
|
|
62
71
|
getTeamState: () => state,
|
|
@@ -78,7 +87,7 @@ function registerController(api: OpenClawPluginApi, config: ReturnType<typeof pa
|
|
|
78
87
|
return createControllerTools({
|
|
79
88
|
config,
|
|
80
89
|
controllerUrl,
|
|
81
|
-
getTeamState:
|
|
90
|
+
getTeamState: getControllerTeamState,
|
|
82
91
|
});
|
|
83
92
|
});
|
|
84
93
|
}
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.3.24-
|
|
5
|
+
"version": "2026.3.24-7",
|
|
6
6
|
"uiHints": {
|
|
7
7
|
"mode": {
|
|
8
8
|
"label": "Mode",
|
package/package.json
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PluginConfig, TeamState } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export function hasOnDemandWorkerProvisioning(
|
|
4
|
+
config: Pick<PluginConfig, "workerProvisioningType">,
|
|
5
|
+
): boolean {
|
|
6
|
+
return config.workerProvisioningType !== "none";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function shouldBlockControllerWithoutWorkers(
|
|
10
|
+
config: Pick<PluginConfig, "workerProvisioningType">,
|
|
11
|
+
state: TeamState | null,
|
|
12
|
+
): boolean {
|
|
13
|
+
return !!state && Object.keys(state.workers).length === 0 && !hasOnDemandWorkerProvisioning(config);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildControllerNoWorkersMessage(): string {
|
|
17
|
+
return [
|
|
18
|
+
"No TeamClaw workers are registered and on-demand provisioning is disabled.",
|
|
19
|
+
"You may analyze the requirement and identify the roles that would be needed,",
|
|
20
|
+
"but do not create TeamClaw tasks and do not do the worker-role work yourself.",
|
|
21
|
+
"Ask the human to bring workers online or enable process/docker/kubernetes provisioning first.",
|
|
22
|
+
].join(" ");
|
|
23
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "../../api.js";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import type { PluginConfig, TeamState } from "../types.js";
|
|
3
4
|
import { loadTeamState, saveTeamState } from "../state.js";
|
|
4
5
|
import { MDnsAdvertiser } from "../discovery.js";
|
|
@@ -17,8 +18,27 @@ export type ControllerServiceDeps = {
|
|
|
17
18
|
logger: PluginLogger;
|
|
18
19
|
runtime: OpenClawPluginApi["runtime"];
|
|
19
20
|
localWorkerManager?: LocalWorkerManager;
|
|
21
|
+
onTeamStateAvailable?: (getter: () => TeamState | null) => void;
|
|
20
22
|
};
|
|
21
23
|
|
|
24
|
+
function getPreferredLanUiUrl(port: number): string | null {
|
|
25
|
+
const candidates: string[] = [];
|
|
26
|
+
const interfaces = os.networkInterfaces();
|
|
27
|
+
for (const records of Object.values(interfaces)) {
|
|
28
|
+
for (const record of records ?? []) {
|
|
29
|
+
if (!record || record.internal || record.family !== "IPv4") {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
candidates.push(record.address);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
candidates.sort((left, right) => left.localeCompare(right));
|
|
36
|
+
if (candidates.length === 0) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return `http://${candidates[0]}:${port}/ui`;
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
export function createControllerService(deps: ControllerServiceDeps): OpenClawPluginService {
|
|
23
43
|
const { config, logger, localWorkerManager } = deps;
|
|
24
44
|
let teamState: TeamState | null = null;
|
|
@@ -61,6 +81,7 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
|
|
|
61
81
|
repoStateChanged = JSON.stringify(teamState.repo ?? null) !== previousRepoState;
|
|
62
82
|
logger.info(`Controller: restored team "${config.teamName}" with ${Object.keys(teamState.workers).length} workers`);
|
|
63
83
|
}
|
|
84
|
+
deps.onTeamStateAvailable?.(() => teamState);
|
|
64
85
|
|
|
65
86
|
const updateState = (updater: (state: TeamState) => void): TeamState => {
|
|
66
87
|
updater(teamState!);
|
|
@@ -108,7 +129,11 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
|
|
|
108
129
|
await new Promise<void>((resolve, reject) => {
|
|
109
130
|
server.listen(config.port, () => {
|
|
110
131
|
logger.info(`Controller: HTTP server listening on port ${config.port}`);
|
|
111
|
-
logger.info(`Controller: Web UI available at http://
|
|
132
|
+
logger.info(`Controller: Web UI available at http://127.0.0.1:${config.port}/ui`);
|
|
133
|
+
const lanUiUrl = getPreferredLanUiUrl(config.port);
|
|
134
|
+
if (lanUiUrl) {
|
|
135
|
+
logger.info(`Controller: Web UI available on LAN at ${lanUiUrl}`);
|
|
136
|
+
}
|
|
112
137
|
resolve();
|
|
113
138
|
});
|
|
114
139
|
server.on("error", reject);
|
|
@@ -180,6 +205,7 @@ export function createControllerService(deps: ControllerServiceDeps): OpenClawPl
|
|
|
180
205
|
}
|
|
181
206
|
},
|
|
182
207
|
async stop() {
|
|
208
|
+
deps.onTeamStateAvailable?.(() => null);
|
|
183
209
|
if (timeoutTimer) {
|
|
184
210
|
clearInterval(timeoutTimer);
|
|
185
211
|
timeoutTimer = null;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
2
|
import type { PluginConfig, TaskInfo, TeamState } from "../types.js";
|
|
3
|
+
import { buildControllerNoWorkersMessage, hasOnDemandWorkerProvisioning, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js";
|
|
3
4
|
|
|
4
5
|
export type ControllerToolsDeps = {
|
|
5
6
|
config: PluginConfig;
|
|
@@ -46,6 +47,16 @@ export function createControllerTools(deps: ControllerToolsDeps) {
|
|
|
46
47
|
return { content: [{ type: "text" as const, text: "title is required." }] };
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
const state = getTeamState();
|
|
51
|
+
if (shouldBlockControllerWithoutWorkers(config, state)) {
|
|
52
|
+
return {
|
|
53
|
+
content: [{
|
|
54
|
+
type: "text" as const,
|
|
55
|
+
text: `${buildControllerNoWorkersMessage()} Stop after reporting this block to the human.`,
|
|
56
|
+
}],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
const blocker = detectExecutionReadyBlocker(description);
|
|
50
61
|
if (blocker) {
|
|
51
62
|
return {
|
|
@@ -80,7 +91,9 @@ export function createControllerTools(deps: ControllerToolsDeps) {
|
|
|
80
91
|
const assigned = task.assignedWorkerId
|
|
81
92
|
? ` -> assigned to ${task.assignedWorkerId}`
|
|
82
93
|
: task.status === "pending"
|
|
83
|
-
?
|
|
94
|
+
? hasOnDemandWorkerProvisioning(config)
|
|
95
|
+
? " (pending - waiting for worker provisioning or an available worker)"
|
|
96
|
+
: " (pending - no registered/available worker)"
|
|
84
97
|
: "";
|
|
85
98
|
const recommended = Array.isArray(task.recommendedSkills) && task.recommendedSkills.length > 0
|
|
86
99
|
? ` | skills: ${task.recommendedSkills.join(", ")}`
|
|
@@ -38,6 +38,8 @@ import { TaskRouter } from "./task-router.js";
|
|
|
38
38
|
import { MessageRouter } from "./message-router.js";
|
|
39
39
|
import { TeamWebSocketServer } from "./websocket.js";
|
|
40
40
|
import type { WorkerProvisioningManager } from "./worker-provisioning.js";
|
|
41
|
+
import { createControllerPromptInjector } from "./prompt-injector.js";
|
|
42
|
+
import { buildControllerNoWorkersMessage, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js";
|
|
41
43
|
|
|
42
44
|
export type ControllerHttpDeps = {
|
|
43
45
|
config: PluginConfig;
|
|
@@ -59,6 +61,16 @@ const MAX_TASK_CONTEXT_SUMMARY_CHARS = 500;
|
|
|
59
61
|
const CONTROLLER_INTAKE_TIMEOUT_CAP_MS = 180_000;
|
|
60
62
|
const CONTROLLER_INTAKE_SESSION_PREFIX = "teamclaw-controller-web:";
|
|
61
63
|
|
|
64
|
+
export function buildControllerIntakeSystemPrompt(
|
|
65
|
+
deps: Pick<ControllerHttpDeps, "config" | "getTeamState">,
|
|
66
|
+
): string {
|
|
67
|
+
const injector = createControllerPromptInjector({
|
|
68
|
+
config: deps.config,
|
|
69
|
+
getTeamState: deps.getTeamState,
|
|
70
|
+
});
|
|
71
|
+
return injector()?.prependSystemContext ?? "";
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
function mapTaskStatusToExecutionStatus(taskStatus: TaskStatus, current?: TaskExecution["status"]): TaskExecution["status"] {
|
|
63
75
|
switch (taskStatus) {
|
|
64
76
|
case "completed":
|
|
@@ -524,6 +536,7 @@ async function runControllerIntake(
|
|
|
524
536
|
const runResult = await deps.runtime.subagent.run({
|
|
525
537
|
sessionKey,
|
|
526
538
|
message,
|
|
539
|
+
extraSystemPrompt: buildControllerIntakeSystemPrompt(deps),
|
|
527
540
|
idempotencyKey: `controller-intake-${generateId()}`,
|
|
528
541
|
});
|
|
529
542
|
recordControllerRunEvent(controllerRun.id, {
|
|
@@ -1241,6 +1254,13 @@ async function handleRequest(
|
|
|
1241
1254
|
const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
1242
1255
|
|
|
1243
1256
|
// ==================== Web UI ====================
|
|
1257
|
+
if (req.method === "GET" && pathname === "/") {
|
|
1258
|
+
res.statusCode = 302;
|
|
1259
|
+
res.setHeader("Location", "/ui");
|
|
1260
|
+
res.end();
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1244
1264
|
if (req.method === "GET" && (pathname === "/ui" || pathname === "/ui/")) {
|
|
1245
1265
|
const uiPath = path.join(import.meta.dirname, "..", "ui");
|
|
1246
1266
|
serveStaticFile(res, path.join(uiPath, "index.html"), "text/html; charset=utf-8");
|
|
@@ -1456,6 +1476,10 @@ async function handleRequest(
|
|
|
1456
1476
|
sendError(res, 400, "title is required");
|
|
1457
1477
|
return;
|
|
1458
1478
|
}
|
|
1479
|
+
if (createdBy === "controller" && shouldBlockControllerWithoutWorkers(deps.config, getTeamState())) {
|
|
1480
|
+
sendError(res, 409, buildControllerNoWorkersMessage());
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1459
1483
|
|
|
1460
1484
|
const taskId = generateId();
|
|
1461
1485
|
const now = Date.now();
|
|
@@ -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
|
-
|
|
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
|
|
32
|
+
const pendingClarifications = Object.values(state?.clarifications ?? {}).filter((c) => c.status === "pending");
|
|
34
33
|
|
|
35
34
|
const parts: string[] = [
|
|
36
35
|
"## TeamClaw Controller Mode",
|
|
@@ -46,8 +45,17 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
|
|
|
46
45
|
"### Current Team Status",
|
|
47
46
|
];
|
|
48
47
|
|
|
49
|
-
if (
|
|
50
|
-
parts.push("-
|
|
48
|
+
if (!state) {
|
|
49
|
+
parts.push("- Team state is not loaded yet; treat this as a fresh controller intake and establish execution-ready tasks from the human requirement.");
|
|
50
|
+
} else if (workers.length === 0) {
|
|
51
|
+
if (shouldBlockControllerWithoutWorkers(deps.config, state)) {
|
|
52
|
+
parts.push("- No workers are registered and on-demand provisioning is disabled.");
|
|
53
|
+
parts.push("- Blocking rule: you may analyze the requirement and identify the needed roles, but do not create TeamClaw tasks yet.");
|
|
54
|
+
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.");
|
|
55
|
+
} else {
|
|
56
|
+
parts.push("- No workers are registered yet, but on-demand provisioning is enabled.");
|
|
57
|
+
parts.push("- You may still create execution-ready TeamClaw tasks for the required roles; the controller will provision workers on demand.");
|
|
58
|
+
}
|
|
51
59
|
} else {
|
|
52
60
|
for (const w of workers) {
|
|
53
61
|
const roleDef = ROLES.find((r) => r.id === w.role);
|
|
@@ -86,6 +94,13 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
|
|
|
86
94
|
parts.push(`- ${role.icon} ${role.label}: ${role.description}.${skillLine}`);
|
|
87
95
|
}
|
|
88
96
|
|
|
97
|
+
parts.push("");
|
|
98
|
+
parts.push("## Controller Workflow");
|
|
99
|
+
parts.push("- First determine which TeamClaw roles are needed for the human requirement.");
|
|
100
|
+
parts.push("- Then translate the requirement into the minimum execution-ready TeamClaw tasks owned by those roles.");
|
|
101
|
+
parts.push("- TeamClaw workers, not the controller, do the specialist work in the shared repo/workspace.");
|
|
102
|
+
parts.push("- After workers report progress, results, or handoffs, create only the next tasks whose prerequisites are now satisfied.");
|
|
103
|
+
|
|
89
104
|
parts.push("");
|
|
90
105
|
parts.push("## Requirement Intake Rules");
|
|
91
106
|
parts.push("- Human messages are the initial requirement, not an already-decomposed task tree.");
|
|
@@ -111,6 +126,13 @@ export function createControllerPromptInjector(deps: ControllerPromptDeps) {
|
|
|
111
126
|
parts.push("- Do not let a worker task turn itself into a controller/coordinator workflow.");
|
|
112
127
|
parts.push("- If the correct role is busy, prefer waiting, messaging, or explicit reassignment over routing core work to an unrelated role.");
|
|
113
128
|
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.");
|
|
129
|
+
parts.push("- You are never a substitute worker. Do not personally perform architecture, implementation, QA, release, infra, design, marketing, research, or other specialist work.");
|
|
130
|
+
parts.push("- Your own reply must stay at the orchestration layer: clarification, role selection, task decomposition, assignment decisions, and concise status updates.");
|
|
131
|
+
if (hasOnDemandWorkerProvisioning(deps.config)) {
|
|
132
|
+
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.");
|
|
133
|
+
} else {
|
|
134
|
+
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.");
|
|
135
|
+
}
|
|
114
136
|
parts.push("- Use the controller itself for requirement analysis; use the PM role only for PM-owned deliverables after intake is clear.");
|
|
115
137
|
parts.push(`- Use exact TeamClaw role IDs only: ${TEAMCLAW_ROLE_IDS_TEXT}.`);
|
|
116
138
|
|