@teamclaws/teamclaw 2026.3.21
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 +37 -0
- package/api.ts +10 -0
- package/index.ts +246 -0
- package/openclaw.plugin.json +41 -0
- package/package.json +63 -0
- package/src/config.ts +297 -0
- package/src/controller/controller-service.ts +197 -0
- package/src/controller/controller-tools.ts +224 -0
- package/src/controller/http-server.ts +1946 -0
- package/src/controller/local-worker-manager.ts +531 -0
- package/src/controller/message-router.ts +62 -0
- package/src/controller/prompt-injector.ts +116 -0
- package/src/controller/task-router.ts +97 -0
- package/src/controller/websocket.ts +63 -0
- package/src/controller/worker-provisioning.ts +1286 -0
- package/src/discovery.ts +101 -0
- package/src/git-collaboration.ts +690 -0
- package/src/identity.ts +149 -0
- package/src/openclaw-workspace.ts +101 -0
- package/src/protocol.ts +88 -0
- package/src/roles.ts +275 -0
- package/src/state.ts +118 -0
- package/src/task-executor.ts +478 -0
- package/src/types.ts +469 -0
- package/src/ui/app.js +1400 -0
- package/src/ui/index.html +207 -0
- package/src/ui/style.css +1281 -0
- package/src/worker/http-handler.ts +136 -0
- package/src/worker/message-queue.ts +31 -0
- package/src/worker/prompt-injector.ts +72 -0
- package/src/worker/tools.ts +318 -0
- package/src/worker/worker-service.ts +194 -0
- package/src/workspace-browser.ts +312 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
6
|
+
import readline from "node:readline";
|
|
7
|
+
import JSON5 from "json5";
|
|
8
|
+
import type { OpenClawPluginApi, PluginLogger } from "../../api.js";
|
|
9
|
+
import { getRole } from "../roles.js";
|
|
10
|
+
import type {
|
|
11
|
+
PluginConfig,
|
|
12
|
+
RoleId,
|
|
13
|
+
TaskAssignmentPayload,
|
|
14
|
+
TeamMessage,
|
|
15
|
+
TeamState,
|
|
16
|
+
WorkerIdentity,
|
|
17
|
+
WorkerInfo,
|
|
18
|
+
} from "../types.js";
|
|
19
|
+
import {
|
|
20
|
+
resolveDefaultOpenClawConfigPath,
|
|
21
|
+
resolveDefaultOpenClawStateDir,
|
|
22
|
+
resolveDefaultOpenClawWorkspaceDir,
|
|
23
|
+
} from "../openclaw-workspace.js";
|
|
24
|
+
|
|
25
|
+
const LOCAL_WORKER_RESTART_DELAY_MS = 1_000;
|
|
26
|
+
const LOCAL_WORKER_STOP_TIMEOUT_MS = 5_000;
|
|
27
|
+
|
|
28
|
+
type ManagedLocalWorkerRecord = {
|
|
29
|
+
workerId: string;
|
|
30
|
+
role: RoleId;
|
|
31
|
+
workerPort: number;
|
|
32
|
+
gatewayPort: number;
|
|
33
|
+
homeDir: string;
|
|
34
|
+
stateDir: string;
|
|
35
|
+
process?: ChildProcess;
|
|
36
|
+
stopping: boolean;
|
|
37
|
+
restartTimer?: ReturnType<typeof setTimeout>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export class LocalWorkerManager {
|
|
41
|
+
private readonly controllerUrl: string;
|
|
42
|
+
private readonly managedWorkers = new Map<string, ManagedLocalWorkerRecord>();
|
|
43
|
+
private workerBaseDir: string | null = null;
|
|
44
|
+
private stoppingAll = false;
|
|
45
|
+
|
|
46
|
+
constructor(private readonly deps: {
|
|
47
|
+
config: PluginConfig;
|
|
48
|
+
logger: PluginLogger;
|
|
49
|
+
runtime: OpenClawPluginApi["runtime"];
|
|
50
|
+
}) {
|
|
51
|
+
this.controllerUrl = `http://127.0.0.1:${deps.config.port}`;
|
|
52
|
+
|
|
53
|
+
for (const role of deps.config.localRoles) {
|
|
54
|
+
const workerId = getLocalWorkerId(role);
|
|
55
|
+
this.managedWorkers.set(workerId, {
|
|
56
|
+
workerId,
|
|
57
|
+
role,
|
|
58
|
+
workerPort: 0,
|
|
59
|
+
gatewayPort: 0,
|
|
60
|
+
homeDir: "",
|
|
61
|
+
stateDir: "",
|
|
62
|
+
stopping: false,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
hasLocalWorkers(): boolean {
|
|
68
|
+
return this.managedWorkers.size > 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
isLocalWorker(_worker: Pick<WorkerInfo, "id" | "url" | "transport">): boolean {
|
|
72
|
+
// localRoles now run as controller-managed child gateways and still heartbeat/register over HTTP.
|
|
73
|
+
// Keep the controller timeout path aligned with normal workers instead of treating them as in-process sessions.
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
isLocalWorkerId(workerId: string): boolean {
|
|
78
|
+
return this.managedWorkers.has(workerId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
getIdentityForSession(_sessionKey?: string | null): WorkerIdentity | null {
|
|
82
|
+
// Local roles no longer execute inside the controller's own OpenClaw runtime.
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getMessageQueueForSession(_sessionKey?: string | null): null {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
syncState(state: TeamState): boolean {
|
|
91
|
+
let changed = false;
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
const desiredWorkerIds = new Set(this.managedWorkers.keys());
|
|
94
|
+
|
|
95
|
+
for (const task of Object.values(state.tasks)) {
|
|
96
|
+
if (!task.assignedWorkerId || !desiredWorkerIds.has(task.assignedWorkerId)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (task.status === "completed" || task.status === "failed") {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
task.assignedRole = task.assignedRole ?? resolveRoleIdFromLocalWorkerId(task.assignedWorkerId) ?? undefined;
|
|
104
|
+
task.assignedWorkerId = undefined;
|
|
105
|
+
if (task.status !== "blocked") {
|
|
106
|
+
task.status = "pending";
|
|
107
|
+
}
|
|
108
|
+
task.updatedAt = now;
|
|
109
|
+
changed = true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const [workerId, worker] of Object.entries(state.workers)) {
|
|
113
|
+
if (desiredWorkerIds.has(workerId) || isManagedLoopbackWorker(worker, this.deps.config.localRoles)) {
|
|
114
|
+
delete state.workers[workerId];
|
|
115
|
+
changed = true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return changed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async start(): Promise<void> {
|
|
123
|
+
if (!this.hasLocalWorkers() || this.workerBaseDir) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.stoppingAll = false;
|
|
128
|
+
this.workerBaseDir = await fs.mkdtemp(
|
|
129
|
+
path.join(os.tmpdir(), `teamclaw-local-workers-${sanitizePathSegment(this.deps.config.teamName)}-`),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const sourceStateDir = resolveDefaultOpenClawStateDir();
|
|
133
|
+
const sourceWorkspaceDir = resolveDefaultOpenClawWorkspaceDir();
|
|
134
|
+
const sourceConfigPath = resolveDefaultOpenClawConfigPath();
|
|
135
|
+
const baseConfig = await loadOpenClawConfig(sourceConfigPath);
|
|
136
|
+
|
|
137
|
+
for (const record of this.managedWorkers.values()) {
|
|
138
|
+
await this.startManagedWorker(record, sourceStateDir, sourceWorkspaceDir, baseConfig);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async stop(): Promise<void> {
|
|
143
|
+
this.stoppingAll = true;
|
|
144
|
+
|
|
145
|
+
await Promise.all([...this.managedWorkers.values()].map(async (record) => {
|
|
146
|
+
record.stopping = true;
|
|
147
|
+
if (record.restartTimer) {
|
|
148
|
+
clearTimeout(record.restartTimer);
|
|
149
|
+
record.restartTimer = undefined;
|
|
150
|
+
}
|
|
151
|
+
await stopManagedWorker(record, this.deps.logger);
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
if (this.workerBaseDir) {
|
|
155
|
+
await fs.rm(this.workerBaseDir, { recursive: true, force: true }).catch(() => {
|
|
156
|
+
// Best-effort cleanup; tmpdirs are safe to leave behind.
|
|
157
|
+
});
|
|
158
|
+
this.workerBaseDir = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async dispatchTask(workerId: string, assignment: TaskAssignmentPayload): Promise<boolean> {
|
|
163
|
+
return await this.postToManagedWorker(workerId, "/api/v1/tasks/assign", assignment, `dispatch task ${assignment.taskId}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async queueMessage(workerId: string, message: TeamMessage): Promise<boolean> {
|
|
167
|
+
return await this.postToManagedWorker(workerId, "/api/v1/messages", message, "queue message");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async cancelTaskExecution(workerId: string, taskId: string): Promise<boolean> {
|
|
171
|
+
const record = this.managedWorkers.get(workerId);
|
|
172
|
+
if (!record || record.workerPort <= 0) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const res = await fetch(`http://127.0.0.1:${record.workerPort}/api/v1/tasks/${taskId}/cancel`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
});
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
this.deps.logger.warn(`Controller: local worker cancel failed for ${taskId} on ${workerId} (${res.status})`);
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
this.deps.logger.info(`Controller: cancelled local execution for task ${taskId} on ${workerId}`);
|
|
185
|
+
return true;
|
|
186
|
+
} catch (err) {
|
|
187
|
+
this.deps.logger.warn(
|
|
188
|
+
`Controller: failed to cancel local execution for task ${taskId} on ${workerId}: ${String(err)}`,
|
|
189
|
+
);
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async startManagedWorker(
|
|
195
|
+
record: ManagedLocalWorkerRecord,
|
|
196
|
+
sourceStateDir: string,
|
|
197
|
+
sourceWorkspaceDir: string,
|
|
198
|
+
baseConfig: Record<string, unknown>,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
if (!this.workerBaseDir) {
|
|
201
|
+
throw new Error("Local worker base directory not initialized");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
record.stopping = false;
|
|
205
|
+
if (record.restartTimer) {
|
|
206
|
+
clearTimeout(record.restartTimer);
|
|
207
|
+
record.restartTimer = undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
record.workerPort = record.workerPort || await reserveEphemeralPort();
|
|
211
|
+
record.gatewayPort = record.gatewayPort || await reserveEphemeralPort();
|
|
212
|
+
record.homeDir = await fs.mkdtemp(path.join(this.workerBaseDir, `${sanitizePathSegment(record.role)}-`));
|
|
213
|
+
record.stateDir = path.join(record.homeDir, ".openclaw");
|
|
214
|
+
|
|
215
|
+
await copyStateDir(sourceStateDir, record.stateDir);
|
|
216
|
+
await linkSharedWorkspace(record.stateDir, sourceWorkspaceDir);
|
|
217
|
+
await clearCopiedWorkerIdentity(record.stateDir);
|
|
218
|
+
await this.writeWorkerConfig(record, baseConfig);
|
|
219
|
+
|
|
220
|
+
const gatewayEntrypoint = resolveGatewayEntrypoint();
|
|
221
|
+
const child = spawn(process.execPath, [
|
|
222
|
+
gatewayEntrypoint,
|
|
223
|
+
"gateway",
|
|
224
|
+
"--allow-unconfigured",
|
|
225
|
+
"--bind",
|
|
226
|
+
"loopback",
|
|
227
|
+
"--port",
|
|
228
|
+
String(record.gatewayPort),
|
|
229
|
+
], {
|
|
230
|
+
cwd: path.dirname(gatewayEntrypoint),
|
|
231
|
+
env: {
|
|
232
|
+
...process.env,
|
|
233
|
+
HOME: record.homeDir,
|
|
234
|
+
OPENCLAW_HOME: record.homeDir,
|
|
235
|
+
OPENCLAW_STATE_DIR: record.stateDir,
|
|
236
|
+
OPENCLAW_CONFIG_PATH: path.join(record.stateDir, "openclaw.json"),
|
|
237
|
+
OPENCLAW_SKIP_CANVAS_HOST: "1",
|
|
238
|
+
TEAMCLAW_WORKER_ID: record.workerId,
|
|
239
|
+
},
|
|
240
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
record.process = child;
|
|
244
|
+
attachChildLogs(child, this.deps.logger, record.role);
|
|
245
|
+
|
|
246
|
+
child.on("exit", (code, signal) => {
|
|
247
|
+
record.process = undefined;
|
|
248
|
+
if (record.restartTimer) {
|
|
249
|
+
clearTimeout(record.restartTimer);
|
|
250
|
+
record.restartTimer = undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (this.stoppingAll || record.stopping) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.deps.logger.warn(
|
|
258
|
+
`Controller: local worker ${record.workerId} exited unexpectedly (code=${String(code)}, signal=${String(signal)}), restarting`,
|
|
259
|
+
);
|
|
260
|
+
record.restartTimer = setTimeout(() => {
|
|
261
|
+
record.restartTimer = undefined;
|
|
262
|
+
void this.startManagedWorker(record, sourceStateDir, sourceWorkspaceDir, baseConfig).catch((err) => {
|
|
263
|
+
this.deps.logger.error(
|
|
264
|
+
`Controller: failed to restart local worker ${record.workerId}: ${err instanceof Error ? err.message : String(err)}`,
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
}, LOCAL_WORKER_RESTART_DELAY_MS);
|
|
268
|
+
record.restartTimer.unref?.();
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private async writeWorkerConfig(
|
|
273
|
+
record: ManagedLocalWorkerRecord,
|
|
274
|
+
baseConfig: Record<string, unknown>,
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
const config = cloneJson(baseConfig);
|
|
277
|
+
const gateway = ensureRecord(config.gateway);
|
|
278
|
+
gateway.mode = "local";
|
|
279
|
+
gateway.bind = "loopback";
|
|
280
|
+
gateway.port = record.gatewayPort;
|
|
281
|
+
config.gateway = gateway;
|
|
282
|
+
|
|
283
|
+
const plugins = ensureRecord(config.plugins);
|
|
284
|
+
plugins.enabled = true;
|
|
285
|
+
const entries = ensureRecord(plugins.entries);
|
|
286
|
+
const teamclawEntry = ensureRecord(entries.teamclaw);
|
|
287
|
+
teamclawEntry.enabled = true;
|
|
288
|
+
const teamclawConfig = ensureRecord(teamclawEntry.config);
|
|
289
|
+
teamclawConfig.mode = "worker";
|
|
290
|
+
teamclawConfig.role = record.role;
|
|
291
|
+
teamclawConfig.port = record.workerPort;
|
|
292
|
+
teamclawConfig.controllerUrl = this.controllerUrl;
|
|
293
|
+
teamclawConfig.teamName = this.deps.config.teamName;
|
|
294
|
+
teamclawConfig.heartbeatIntervalMs = this.deps.config.heartbeatIntervalMs;
|
|
295
|
+
teamclawConfig.taskTimeoutMs = this.deps.config.taskTimeoutMs;
|
|
296
|
+
teamclawConfig.localRoles = [];
|
|
297
|
+
teamclawEntry.config = teamclawConfig;
|
|
298
|
+
entries.teamclaw = teamclawEntry;
|
|
299
|
+
plugins.entries = entries;
|
|
300
|
+
config.plugins = plugins;
|
|
301
|
+
|
|
302
|
+
const configPath = path.join(record.stateDir, "openclaw.json");
|
|
303
|
+
await fs.mkdir(record.stateDir, { recursive: true });
|
|
304
|
+
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private async postToManagedWorker(
|
|
308
|
+
workerId: string,
|
|
309
|
+
pathname: string,
|
|
310
|
+
payload: unknown,
|
|
311
|
+
action: string,
|
|
312
|
+
): Promise<boolean> {
|
|
313
|
+
const record = this.managedWorkers.get(workerId);
|
|
314
|
+
if (!record || record.workerPort <= 0) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const res = await fetch(`http://127.0.0.1:${record.workerPort}${pathname}`, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
headers: { "Content-Type": "application/json" },
|
|
322
|
+
body: JSON.stringify(payload),
|
|
323
|
+
});
|
|
324
|
+
if (!res.ok) {
|
|
325
|
+
this.deps.logger.warn(`Controller: local worker ${workerId} failed to ${action} (${res.status})`);
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
} catch (err) {
|
|
330
|
+
this.deps.logger.warn(`Controller: failed to ${action} on local worker ${workerId}: ${String(err)}`);
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function loadOpenClawConfig(configPath: string): Promise<Record<string, unknown>> {
|
|
337
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
338
|
+
return parseLooseJsonObject(raw, configPath);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function copyStateDir(sourceStateDir: string, targetStateDir: string): Promise<void> {
|
|
342
|
+
await fs.mkdir(targetStateDir, { recursive: true });
|
|
343
|
+
try {
|
|
344
|
+
await fs.cp(sourceStateDir, targetStateDir, {
|
|
345
|
+
recursive: true,
|
|
346
|
+
force: true,
|
|
347
|
+
errorOnExist: false,
|
|
348
|
+
filter: (sourcePath) => shouldCopyStatePath(sourcePath, sourceStateDir),
|
|
349
|
+
});
|
|
350
|
+
} catch {
|
|
351
|
+
await fs.mkdir(targetStateDir, { recursive: true });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function linkSharedWorkspace(targetStateDir: string, sourceWorkspaceDir: string): Promise<void> {
|
|
356
|
+
const targetWorkspacePath = path.join(targetStateDir, "workspace");
|
|
357
|
+
await fs.mkdir(sourceWorkspaceDir, { recursive: true });
|
|
358
|
+
await fs.rm(targetWorkspacePath, { recursive: true, force: true });
|
|
359
|
+
try {
|
|
360
|
+
await fs.symlink(sourceWorkspaceDir, targetWorkspacePath, "dir");
|
|
361
|
+
} catch {
|
|
362
|
+
await fs.mkdir(targetWorkspacePath, { recursive: true });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function clearCopiedWorkerIdentity(targetStateDir: string): Promise<void> {
|
|
367
|
+
await fs.rm(path.join(targetStateDir, "plugins", "teamclaw", "worker-identity.json"), { force: true });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function shouldCopyStatePath(sourcePath: string, sourceStateDir: string): boolean {
|
|
371
|
+
const relativePath = path.relative(sourceStateDir, sourcePath);
|
|
372
|
+
if (!relativePath) {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const normalizedPath = relativePath.split(path.sep).join("/");
|
|
377
|
+
if (normalizedPath === "workspace" || normalizedPath.startsWith("workspace/")) {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
if (normalizedPath === "plugins/teamclaw/worker-identity.json") {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function cloneJson<T>(value: T): T {
|
|
387
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function ensureRecord(value: unknown): Record<string, unknown> {
|
|
391
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
392
|
+
? { ...(value as Record<string, unknown>) }
|
|
393
|
+
: {};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function parseLooseJsonObject(raw: string, configPath: string): Record<string, unknown> {
|
|
397
|
+
try {
|
|
398
|
+
return JSON5.parse(raw) as Record<string, unknown>;
|
|
399
|
+
} catch (err) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`Failed to parse OpenClaw config at ${configPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function reserveEphemeralPort(): Promise<number> {
|
|
407
|
+
return await new Promise<number>((resolve, reject) => {
|
|
408
|
+
const server = net.createServer();
|
|
409
|
+
server.listen(0, "127.0.0.1", () => {
|
|
410
|
+
const address = server.address();
|
|
411
|
+
const port = address && typeof address === "object" ? address.port : 0;
|
|
412
|
+
server.close((err) => {
|
|
413
|
+
if (err) {
|
|
414
|
+
reject(err);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (!port) {
|
|
418
|
+
reject(new Error("Failed to reserve ephemeral port"));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
resolve(port);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
server.on("error", reject);
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function resolveGatewayEntrypoint(): string {
|
|
429
|
+
const scriptPath = process.argv[1];
|
|
430
|
+
if (!scriptPath) {
|
|
431
|
+
throw new Error("Unable to resolve OpenClaw gateway entrypoint");
|
|
432
|
+
}
|
|
433
|
+
return path.resolve(scriptPath);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function attachChildLogs(child: ChildProcess, logger: PluginLogger, role: RoleId): void {
|
|
437
|
+
if (child.stdout) {
|
|
438
|
+
const stdoutReader = readline.createInterface({ input: child.stdout });
|
|
439
|
+
stdoutReader.on("line", (line) => {
|
|
440
|
+
logger.info(`LocalWorker[${role}]: ${line}`);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
if (child.stderr) {
|
|
444
|
+
const stderrReader = readline.createInterface({ input: child.stderr });
|
|
445
|
+
stderrReader.on("line", (line) => {
|
|
446
|
+
logger.warn(`LocalWorker[${role}]: ${line}`);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function stopManagedWorker(record: ManagedLocalWorkerRecord, logger: PluginLogger): Promise<void> {
|
|
452
|
+
const child = record.process;
|
|
453
|
+
if (!child) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
await new Promise<void>((resolve) => {
|
|
458
|
+
let settled = false;
|
|
459
|
+
const finish = () => {
|
|
460
|
+
if (settled) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
settled = true;
|
|
464
|
+
record.process = undefined;
|
|
465
|
+
resolve();
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const timeout = setTimeout(() => {
|
|
469
|
+
if (child.exitCode == null) {
|
|
470
|
+
logger.warn(`Controller: force-killing local worker ${record.workerId}`);
|
|
471
|
+
child.kill("SIGKILL");
|
|
472
|
+
}
|
|
473
|
+
finish();
|
|
474
|
+
}, LOCAL_WORKER_STOP_TIMEOUT_MS);
|
|
475
|
+
|
|
476
|
+
timeout.unref?.();
|
|
477
|
+
child.once("exit", () => {
|
|
478
|
+
clearTimeout(timeout);
|
|
479
|
+
finish();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
child.kill("SIGTERM");
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function sanitizePathSegment(value: string): string {
|
|
487
|
+
return value.replace(/[^a-zA-Z0-9_-]+/g, "-");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function isManagedLoopbackWorker(worker: WorkerInfo, roles: RoleId[]): boolean {
|
|
491
|
+
if (!roles.includes(worker.role)) {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
try {
|
|
496
|
+
const parsed = new URL(worker.url);
|
|
497
|
+
return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
|
|
498
|
+
} catch {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function resolveRoleIdFromLocalWorkerId(workerId: string): RoleId | null {
|
|
504
|
+
if (!workerId.startsWith("local-")) {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
const roleCandidate = workerId.slice("local-".length);
|
|
508
|
+
return isRoleId(roleCandidate) ? roleCandidate : null;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function isRoleId(value: string): value is RoleId {
|
|
512
|
+
return value === "pm" ||
|
|
513
|
+
value === "architect" ||
|
|
514
|
+
value === "developer" ||
|
|
515
|
+
value === "qa" ||
|
|
516
|
+
value === "release-engineer" ||
|
|
517
|
+
value === "infra-engineer" ||
|
|
518
|
+
value === "devops" ||
|
|
519
|
+
value === "security-engineer" ||
|
|
520
|
+
value === "designer" ||
|
|
521
|
+
value === "marketing";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function getLocalWorkerId(role: RoleId): string {
|
|
525
|
+
return `local-${role}`;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function buildLocalWorkerLabel(role: RoleId): string {
|
|
529
|
+
const roleDef = getRole(role);
|
|
530
|
+
return roleDef ? `${roleDef.label} (Local)` : `${role} (Local)`;
|
|
531
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { PluginLogger } from "../../api.js";
|
|
2
|
+
import type { TeamMessage, WorkerInfo } from "../types.js";
|
|
3
|
+
import { generateId } from "../protocol.js";
|
|
4
|
+
|
|
5
|
+
export class MessageRouter {
|
|
6
|
+
private logger: PluginLogger;
|
|
7
|
+
|
|
8
|
+
constructor(logger: PluginLogger) {
|
|
9
|
+
this.logger = logger;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
routeDirectMessage(
|
|
13
|
+
message: TeamMessage,
|
|
14
|
+
workers: Record<string, WorkerInfo>,
|
|
15
|
+
): { worker: WorkerInfo; message: TeamMessage } | null {
|
|
16
|
+
if (!message.toRole) return null;
|
|
17
|
+
|
|
18
|
+
const targetWorker = Object.values(workers).find(
|
|
19
|
+
(w) => w.role === message.toRole && w.status !== "offline",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
if (!targetWorker) {
|
|
23
|
+
this.logger.warn(`MessageRouter: no worker found for role ${message.toRole}`);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const routedMessage: TeamMessage = {
|
|
28
|
+
...message,
|
|
29
|
+
id: message.id || generateId(),
|
|
30
|
+
to: targetWorker.id,
|
|
31
|
+
createdAt: message.createdAt || Date.now(),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return { worker: targetWorker, message: routedMessage };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
routeBroadcast(
|
|
38
|
+
message: TeamMessage,
|
|
39
|
+
workers: Record<string, WorkerInfo>,
|
|
40
|
+
): Array<{ worker: WorkerInfo; message: TeamMessage }> {
|
|
41
|
+
const activeWorkers = Object.values(workers).filter(
|
|
42
|
+
(w) => w.status !== "offline" && w.id !== message.from,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return activeWorkers.map((worker) => ({
|
|
46
|
+
worker,
|
|
47
|
+
message: {
|
|
48
|
+
...message,
|
|
49
|
+
id: generateId(),
|
|
50
|
+
to: worker.id,
|
|
51
|
+
createdAt: message.createdAt || Date.now(),
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
routeReviewRequest(
|
|
57
|
+
message: TeamMessage,
|
|
58
|
+
workers: Record<string, WorkerInfo>,
|
|
59
|
+
): { worker: WorkerInfo; message: TeamMessage } | null {
|
|
60
|
+
return this.routeDirectMessage(message, workers);
|
|
61
|
+
}
|
|
62
|
+
}
|