@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,197 @@
|
|
|
1
|
+
import type { OpenClawPluginApi, OpenClawPluginService, OpenClawPluginServiceContext, PluginLogger } from "../../api.js";
|
|
2
|
+
import type { PluginConfig, TeamState } from "../types.js";
|
|
3
|
+
import { loadTeamState, saveTeamState } from "../state.js";
|
|
4
|
+
import { MDnsAdvertiser } from "../discovery.js";
|
|
5
|
+
import { WORKER_TIMEOUT_MS } from "../protocol.js";
|
|
6
|
+
import { createControllerHttpServer } from "./http-server.js";
|
|
7
|
+
import type { LocalWorkerManager } from "./local-worker-manager.js";
|
|
8
|
+
import { TaskRouter } from "./task-router.js";
|
|
9
|
+
import { MessageRouter } from "./message-router.js";
|
|
10
|
+
import { TeamWebSocketServer } from "./websocket.js";
|
|
11
|
+
import { ensureOpenClawWorkspaceMemoryDir } from "../openclaw-workspace.js";
|
|
12
|
+
import { ensureControllerGitRepo } from "../git-collaboration.js";
|
|
13
|
+
import { WorkerProvisioningManager } from "./worker-provisioning.js";
|
|
14
|
+
|
|
15
|
+
export type ControllerServiceDeps = {
|
|
16
|
+
config: PluginConfig;
|
|
17
|
+
logger: PluginLogger;
|
|
18
|
+
runtime: OpenClawPluginApi["runtime"];
|
|
19
|
+
localWorkerManager?: LocalWorkerManager;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createControllerService(deps: ControllerServiceDeps): OpenClawPluginService {
|
|
23
|
+
const { config, logger, localWorkerManager } = deps;
|
|
24
|
+
let teamState: TeamState | null = null;
|
|
25
|
+
let mdnsAdvertiser: MDnsAdvertiser;
|
|
26
|
+
let taskRouter: TaskRouter;
|
|
27
|
+
let messageRouter: MessageRouter;
|
|
28
|
+
let wsServer: TeamWebSocketServer;
|
|
29
|
+
let timeoutTimer: ReturnType<typeof setInterval> | null = null;
|
|
30
|
+
let workerProvisioningManager: WorkerProvisioningManager | null = null;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
id: "teamclaw-controller",
|
|
34
|
+
async start(_ctx: OpenClawPluginServiceContext) {
|
|
35
|
+
await ensureOpenClawWorkspaceMemoryDir(logger);
|
|
36
|
+
const repoState = await ensureControllerGitRepo(config, logger).catch((err) => {
|
|
37
|
+
logger.warn(`Controller: failed to prepare git collaboration repo: ${err instanceof Error ? err.message : String(err)}`);
|
|
38
|
+
return null;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Load or create team state
|
|
42
|
+
teamState = await loadTeamState(config.teamName);
|
|
43
|
+
let repoStateChanged = false;
|
|
44
|
+
if (!teamState) {
|
|
45
|
+
teamState = {
|
|
46
|
+
teamName: config.teamName,
|
|
47
|
+
workers: {},
|
|
48
|
+
tasks: {},
|
|
49
|
+
messages: [],
|
|
50
|
+
clarifications: {},
|
|
51
|
+
repo: repoState ?? undefined,
|
|
52
|
+
createdAt: Date.now(),
|
|
53
|
+
updatedAt: Date.now(),
|
|
54
|
+
};
|
|
55
|
+
await saveTeamState(teamState);
|
|
56
|
+
logger.info(`Controller: created new team "${config.teamName}"`);
|
|
57
|
+
} else {
|
|
58
|
+
const previousRepoState = JSON.stringify(teamState.repo ?? null);
|
|
59
|
+
teamState.repo = repoState ?? teamState.repo;
|
|
60
|
+
repoStateChanged = JSON.stringify(teamState.repo ?? null) !== previousRepoState;
|
|
61
|
+
logger.info(`Controller: restored team "${config.teamName}" with ${Object.keys(teamState.workers).length} workers`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const updateState = (updater: (state: TeamState) => void): TeamState => {
|
|
65
|
+
updater(teamState!);
|
|
66
|
+
void saveTeamState(teamState!);
|
|
67
|
+
return teamState!;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
workerProvisioningManager = new WorkerProvisioningManager({
|
|
71
|
+
config,
|
|
72
|
+
logger,
|
|
73
|
+
getTeamState: () => teamState,
|
|
74
|
+
updateTeamState: updateState,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (
|
|
78
|
+
repoStateChanged ||
|
|
79
|
+
localWorkerManager?.syncState(teamState) ||
|
|
80
|
+
workerProvisioningManager.syncState(teamState)
|
|
81
|
+
) {
|
|
82
|
+
await saveTeamState(teamState);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
mdnsAdvertiser = new MDnsAdvertiser(logger);
|
|
86
|
+
taskRouter = new TaskRouter(logger);
|
|
87
|
+
messageRouter = new MessageRouter(logger);
|
|
88
|
+
wsServer = new TeamWebSocketServer(logger);
|
|
89
|
+
|
|
90
|
+
// Start mDNS advertising
|
|
91
|
+
await mdnsAdvertiser.start(config.port, config.teamName);
|
|
92
|
+
|
|
93
|
+
// Start HTTP server
|
|
94
|
+
const server = createControllerHttpServer({
|
|
95
|
+
config,
|
|
96
|
+
logger,
|
|
97
|
+
runtime: deps.runtime,
|
|
98
|
+
getTeamState: () => teamState,
|
|
99
|
+
updateTeamState: updateState,
|
|
100
|
+
taskRouter,
|
|
101
|
+
messageRouter,
|
|
102
|
+
wsServer,
|
|
103
|
+
localWorkerManager,
|
|
104
|
+
workerProvisioningManager,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await new Promise<void>((resolve, reject) => {
|
|
108
|
+
server.listen(config.port, () => {
|
|
109
|
+
logger.info(`Controller: HTTP server listening on port ${config.port}`);
|
|
110
|
+
logger.info(`Controller: Web UI available at http://localhost:${config.port}/ui`);
|
|
111
|
+
resolve();
|
|
112
|
+
});
|
|
113
|
+
server.on("error", reject);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (localWorkerManager?.hasLocalWorkers()) {
|
|
117
|
+
await localWorkerManager.start();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (workerProvisioningManager.isEnabled()) {
|
|
121
|
+
void workerProvisioningManager.requestReconcile("controller startup");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Start timeout monitoring
|
|
125
|
+
timeoutTimer = setInterval(() => {
|
|
126
|
+
if (!teamState) return;
|
|
127
|
+
|
|
128
|
+
let changed = false;
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
|
|
131
|
+
for (const [workerId, worker] of Object.entries(teamState.workers)) {
|
|
132
|
+
if (worker.status === "offline") continue;
|
|
133
|
+
if (localWorkerManager?.isLocalWorker(worker)) {
|
|
134
|
+
worker.lastHeartbeat = now;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (now - worker.lastHeartbeat > WORKER_TIMEOUT_MS) {
|
|
138
|
+
logger.info(`Controller: worker ${workerId} timed out`);
|
|
139
|
+
const activeTaskId = worker.currentTaskId;
|
|
140
|
+
worker.status = "offline";
|
|
141
|
+
worker.currentTaskId = undefined;
|
|
142
|
+
changed = true;
|
|
143
|
+
wsServer.broadcastUpdate({ type: "worker:offline", data: { workerId } });
|
|
144
|
+
|
|
145
|
+
if (activeTaskId) {
|
|
146
|
+
const task = teamState.tasks[activeTaskId];
|
|
147
|
+
if (
|
|
148
|
+
task &&
|
|
149
|
+
task.assignedWorkerId === workerId &&
|
|
150
|
+
task.status !== "completed" &&
|
|
151
|
+
task.status !== "failed" &&
|
|
152
|
+
task.status !== "blocked"
|
|
153
|
+
) {
|
|
154
|
+
task.status = "pending";
|
|
155
|
+
task.assignedWorkerId = undefined;
|
|
156
|
+
task.updatedAt = now;
|
|
157
|
+
wsServer.broadcastUpdate({ type: "task:updated", data: { ...task } });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (workerProvisioningManager?.hasManagedWorker(workerId)) {
|
|
162
|
+
void workerProvisioningManager.onWorkerRemoved(workerId, "heartbeat timeout");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (changed) {
|
|
168
|
+
saveTeamState(teamState);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (workerProvisioningManager?.isEnabled()) {
|
|
172
|
+
void workerProvisioningManager.requestReconcile("periodic controller sync");
|
|
173
|
+
}
|
|
174
|
+
}, 15000);
|
|
175
|
+
|
|
176
|
+
if (timeoutTimer) {
|
|
177
|
+
const timer = timeoutTimer as unknown as { unref?: () => void };
|
|
178
|
+
timer.unref?.();
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
async stop() {
|
|
182
|
+
if (timeoutTimer) {
|
|
183
|
+
clearInterval(timeoutTimer);
|
|
184
|
+
timeoutTimer = null;
|
|
185
|
+
}
|
|
186
|
+
if (localWorkerManager?.hasLocalWorkers()) {
|
|
187
|
+
await localWorkerManager.stop();
|
|
188
|
+
}
|
|
189
|
+
if (workerProvisioningManager) {
|
|
190
|
+
await workerProvisioningManager.stop();
|
|
191
|
+
}
|
|
192
|
+
wsServer.close();
|
|
193
|
+
mdnsAdvertiser.stop();
|
|
194
|
+
logger.info("Controller: stopped");
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { PluginConfig, TaskInfo, TeamState } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export type ControllerToolsDeps = {
|
|
5
|
+
config: PluginConfig;
|
|
6
|
+
controllerUrl: string;
|
|
7
|
+
getTeamState: () => TeamState | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const EXECUTION_READY_BLOCKERS: Array<{ pattern: RegExp; reason: string }> = [
|
|
11
|
+
{ pattern: /\bdepends?\s+on\b/i, reason: "it explicitly depends on other unfinished work" },
|
|
12
|
+
{ pattern: /\bprerequisite\b/i, reason: "it references a prerequisite that may not be satisfied yet" },
|
|
13
|
+
{ pattern: /\bwait(?:ing)?\s+for\b/i, reason: "it says the work should wait for another output first" },
|
|
14
|
+
{ pattern: /\bafter\b.+\b(complete|completed|ready|available|exists?)\b/i, reason: "it is phrased as a later-phase task" },
|
|
15
|
+
{ pattern: /\bonce\b.+\b(complete|completed|ready|available|exists?)\b/i, reason: "it is phrased as a later-phase task" },
|
|
16
|
+
{ pattern: /依赖|前置|前提/u, reason: "it explicitly mentions a predecessor dependency" },
|
|
17
|
+
{ pattern: /完成后|就绪后|待.*完成|等待.*完成/u, reason: "it is described as work for a later phase" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export function createControllerTools(deps: ControllerToolsDeps) {
|
|
21
|
+
const { config, controllerUrl, getTeamState } = deps;
|
|
22
|
+
const baseUrl = controllerUrl;
|
|
23
|
+
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
name: "teamclaw_create_task",
|
|
27
|
+
label: "Create Team Task",
|
|
28
|
+
description: "Create an execution-ready team task after the controller has analyzed the raw human requirement, clarified missing decisions, and confirmed the task can start immediately",
|
|
29
|
+
parameters: Type.Object({
|
|
30
|
+
title: Type.String({ description: "Task title" }),
|
|
31
|
+
description: Type.String({ description: "Execution-ready task description with scope, expected deliverable, constraints, resolved clarifications, and no unmet predecessor dependency" }),
|
|
32
|
+
priority: Type.Optional(Type.String({ description: "Priority: low, medium, high, critical" })),
|
|
33
|
+
assignedRole: Type.Optional(Type.String({ description: "Exact target role ID (pm, architect, developer, qa, release-engineer, infra-engineer, devops, security-engineer, designer, marketing)" })),
|
|
34
|
+
}),
|
|
35
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
36
|
+
const title = String(params.title ?? "");
|
|
37
|
+
const description = String(params.description ?? "");
|
|
38
|
+
if (!title) {
|
|
39
|
+
return { content: [{ type: "text" as const, text: "title is required." }] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const blocker = detectExecutionReadyBlocker(description);
|
|
43
|
+
if (blocker) {
|
|
44
|
+
return {
|
|
45
|
+
content: [{
|
|
46
|
+
type: "text" as const,
|
|
47
|
+
text: `Refusing to create task "${title}" because it is not execution-ready: ${blocker}. Only create tasks that can start immediately; keep downstream work in the controller plan until prerequisites are already complete.`,
|
|
48
|
+
}],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${baseUrl}/api/v1/tasks`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "Content-Type": "application/json" },
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
title,
|
|
58
|
+
description,
|
|
59
|
+
priority: params.priority ?? "medium",
|
|
60
|
+
assignedRole: params.assignedRole ?? undefined,
|
|
61
|
+
createdBy: "controller",
|
|
62
|
+
}),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
const err = await res.text();
|
|
67
|
+
return { content: [{ type: "text" as const, text: `Failed to create task: ${err}` }] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const data = await res.json() as { task: TaskInfo };
|
|
71
|
+
const task = data.task;
|
|
72
|
+
const assigned = task.assignedWorkerId
|
|
73
|
+
? ` -> assigned to ${task.assignedWorkerId}`
|
|
74
|
+
: task.status === "pending"
|
|
75
|
+
? " (pending - no available worker)"
|
|
76
|
+
: "";
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
content: [{
|
|
80
|
+
type: "text" as const,
|
|
81
|
+
text: `Task created: ${task.title} [${task.id}] [${task.priority}]${assigned}`,
|
|
82
|
+
}],
|
|
83
|
+
};
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return { content: [{ type: "text" as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }] };
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "teamclaw_list_tasks",
|
|
91
|
+
label: "List Team Tasks",
|
|
92
|
+
description: "List all tasks with optional status filter",
|
|
93
|
+
parameters: Type.Object({
|
|
94
|
+
status: Type.Optional(Type.String({ description: "Filter by status: pending, assigned, in_progress, review, blocked, completed, failed" })),
|
|
95
|
+
}),
|
|
96
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
97
|
+
const status = typeof params.status === "string" ? params.status : undefined;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const url = new URL(`${baseUrl}/api/v1/tasks`);
|
|
101
|
+
if (status) url.searchParams.set("status", status);
|
|
102
|
+
const res = await fetch(url.toString());
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
return { content: [{ type: "text" as const, text: `Failed to list tasks: ${res.status}` }] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const data = await res.json() as { tasks: TaskInfo[] };
|
|
108
|
+
if (data.tasks.length === 0) {
|
|
109
|
+
return { content: [{ type: "text" as const, text: "No tasks found." }] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const lines = data.tasks.map((t) => {
|
|
113
|
+
const assignee = t.assignedWorkerId ? ` -> ${t.assignedWorkerId.slice(0, 8)}` : "";
|
|
114
|
+
return `[${t.status}] ${t.priority.toUpperCase()} ${t.title} (${t.id})${assignee}`;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return { content: [{ type: "text" as const, text: lines.join("\n") }] };
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { content: [{ type: "text" as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }] };
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "teamclaw_assign_task",
|
|
125
|
+
label: "Assign Team Task",
|
|
126
|
+
description: "Assign a task to a specific worker or let the router decide",
|
|
127
|
+
parameters: Type.Object({
|
|
128
|
+
taskId: Type.String({ description: "Task ID to assign" }),
|
|
129
|
+
workerId: Type.Optional(Type.String({ description: "Specific worker ID (omit for auto-routing)" })),
|
|
130
|
+
}),
|
|
131
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
132
|
+
const taskId = String(params.taskId ?? "");
|
|
133
|
+
if (!taskId) {
|
|
134
|
+
return { content: [{ type: "text" as const, text: "taskId is required." }] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const body: Record<string, unknown> = {};
|
|
139
|
+
if (params.workerId) body.workerId = params.workerId;
|
|
140
|
+
|
|
141
|
+
const res = await fetch(`${baseUrl}/api/v1/tasks/${taskId}/assign`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "Content-Type": "application/json" },
|
|
144
|
+
body: JSON.stringify(body),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
const err = await res.text();
|
|
149
|
+
return { content: [{ type: "text" as const, text: `Failed to assign task: ${err}` }] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const data = await res.json() as { task: TaskInfo; worker?: { id: string; label: string } };
|
|
153
|
+
const worker = data.worker;
|
|
154
|
+
const workerInfo = worker ? ` assigned to ${worker.label} (${worker.id})` : " (no available worker)";
|
|
155
|
+
return { content: [{ type: "text" as const, text: `Task assigned: ${data.task.title}${workerInfo}` }] };
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return { content: [{ type: "text" as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }] };
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "teamclaw_send_message",
|
|
163
|
+
label: "Send Team Message",
|
|
164
|
+
description: "Send a direct message or broadcast to team members after requirement analysis when coordination is actually needed",
|
|
165
|
+
parameters: Type.Object({
|
|
166
|
+
content: Type.String({ description: "Message content" }),
|
|
167
|
+
toRole: Type.Optional(Type.String({ description: "Target role for direct message (omit for broadcast)" })),
|
|
168
|
+
taskId: Type.Optional(Type.String({ description: "Related task ID" })),
|
|
169
|
+
}),
|
|
170
|
+
async execute(_id: string, params: Record<string, unknown>) {
|
|
171
|
+
const content = String(params.content ?? "");
|
|
172
|
+
if (!content) {
|
|
173
|
+
return { content: [{ type: "text" as const, text: "content is required." }] };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const endpoint = params.toRole
|
|
178
|
+
? `${baseUrl}/api/v1/messages/direct`
|
|
179
|
+
: `${baseUrl}/api/v1/messages/broadcast`;
|
|
180
|
+
|
|
181
|
+
const body: Record<string, unknown> = {
|
|
182
|
+
from: "controller",
|
|
183
|
+
content,
|
|
184
|
+
taskId: params.taskId ?? null,
|
|
185
|
+
};
|
|
186
|
+
if (params.toRole) body.toRole = params.toRole;
|
|
187
|
+
|
|
188
|
+
const res = await fetch(endpoint, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify(body),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (!res.ok) {
|
|
195
|
+
return { content: [{ type: "text" as const, text: `Failed to send message: ${res.status}` }] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const data = await res.json() as { status: string; recipients?: number };
|
|
199
|
+
if (params.toRole) {
|
|
200
|
+
return { content: [{ type: "text" as const, text: `Message sent to ${params.toRole}: ${data.status}` }] };
|
|
201
|
+
}
|
|
202
|
+
return { content: [{ type: "text" as const, text: `Broadcast sent to ${data.recipients ?? 0} recipients` }] };
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return { content: [{ type: "text" as const, text: `Error: ${err instanceof Error ? err.message : String(err)}` }] };
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function detectExecutionReadyBlocker(description: string): string | null {
|
|
212
|
+
const text = description.trim();
|
|
213
|
+
if (!text) {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const blocker of EXECUTION_READY_BLOCKERS) {
|
|
218
|
+
if (blocker.pattern.test(text)) {
|
|
219
|
+
return blocker.reason;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return null;
|
|
224
|
+
}
|