@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/src/state.ts ADDED
@@ -0,0 +1,118 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import type { TeamProvisioningState, TeamState, WorkerIdentity } from "./types.js";
5
+
6
+ function resolvePluginStateDir(): string {
7
+ const explicitStateDir = process.env.OPENCLAW_STATE_DIR?.trim();
8
+ if (explicitStateDir) {
9
+ return path.join(explicitStateDir, "plugins", "teamclaw");
10
+ }
11
+
12
+ const explicitHome = process.env.OPENCLAW_HOME?.trim() || process.env.HOME?.trim();
13
+ const homeDir = explicitHome ? path.resolve(explicitHome) : os.homedir();
14
+ return path.join(homeDir, ".openclaw", "plugins", "teamclaw");
15
+ }
16
+
17
+ const STATE_DIR = resolvePluginStateDir();
18
+
19
+ function createEmptyProvisioningState(): TeamProvisioningState {
20
+ return {
21
+ workers: {},
22
+ };
23
+ }
24
+
25
+ async function ensureDir(dir: string): Promise<void> {
26
+ await fs.mkdir(dir, { recursive: true });
27
+ }
28
+
29
+ async function loadTeamState(teamName: string): Promise<TeamState | null> {
30
+ const filePath = path.join(STATE_DIR, `${teamName}-team-state.json`);
31
+ try {
32
+ const raw = await fs.readFile(filePath, "utf8");
33
+ const parsed = JSON.parse(raw) as TeamState;
34
+ if (
35
+ typeof parsed.teamName !== "string" ||
36
+ typeof parsed.createdAt !== "number" ||
37
+ typeof parsed.updatedAt !== "number" ||
38
+ !parsed.workers ||
39
+ !parsed.tasks
40
+ ) {
41
+ return null;
42
+ }
43
+ if (!Array.isArray(parsed.messages)) {
44
+ parsed.messages = [];
45
+ }
46
+ if (!parsed.clarifications || typeof parsed.clarifications !== "object") {
47
+ parsed.clarifications = {};
48
+ }
49
+ if (parsed.repo && typeof parsed.repo !== "object") {
50
+ delete parsed.repo;
51
+ }
52
+ if (!parsed.provisioning || typeof parsed.provisioning !== "object") {
53
+ parsed.provisioning = createEmptyProvisioningState();
54
+ }
55
+ if (!parsed.provisioning.workers || typeof parsed.provisioning.workers !== "object") {
56
+ parsed.provisioning.workers = {};
57
+ }
58
+ return parsed;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ async function saveTeamState(state: TeamState): Promise<void> {
65
+ await ensureDir(STATE_DIR);
66
+ const filePath = path.join(STATE_DIR, `${state.teamName}-team-state.json`);
67
+ state.updatedAt = Date.now();
68
+ state.provisioning = state.provisioning && typeof state.provisioning === "object"
69
+ ? state.provisioning
70
+ : createEmptyProvisioningState();
71
+ state.provisioning.workers = state.provisioning.workers && typeof state.provisioning.workers === "object"
72
+ ? state.provisioning.workers
73
+ : {};
74
+ await fs.writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
75
+ }
76
+
77
+ async function loadWorkerIdentity(): Promise<WorkerIdentity | null> {
78
+ const filePath = path.join(STATE_DIR, "worker-identity.json");
79
+ try {
80
+ const raw = await fs.readFile(filePath, "utf8");
81
+ const parsed = JSON.parse(raw) as WorkerIdentity;
82
+ if (
83
+ typeof parsed.workerId !== "string" ||
84
+ typeof parsed.role !== "string" ||
85
+ typeof parsed.controllerUrl !== "string" ||
86
+ typeof parsed.registeredAt !== "number"
87
+ ) {
88
+ return null;
89
+ }
90
+ return parsed;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ async function saveWorkerIdentity(identity: WorkerIdentity): Promise<void> {
97
+ await ensureDir(STATE_DIR);
98
+ const filePath = path.join(STATE_DIR, "worker-identity.json");
99
+ await fs.writeFile(filePath, `${JSON.stringify(identity, null, 2)}\n`, "utf8");
100
+ }
101
+
102
+ async function clearWorkerIdentity(): Promise<void> {
103
+ const filePath = path.join(STATE_DIR, "worker-identity.json");
104
+ try {
105
+ await fs.unlink(filePath);
106
+ } catch {
107
+ // ignore
108
+ }
109
+ }
110
+
111
+ export {
112
+ STATE_DIR,
113
+ loadTeamState,
114
+ saveTeamState,
115
+ loadWorkerIdentity,
116
+ saveWorkerIdentity,
117
+ clearWorkerIdentity,
118
+ };
@@ -0,0 +1,478 @@
1
+ import type { OpenClawPluginApi, PluginLogger } from "../api.js";
2
+ import { getRole } from "./roles.js";
3
+ import type { RoleId, TaskExecutionEventInput } from "./types.js";
4
+
5
+ const TEAMCLAW_ROLE_IDS_TEXT = [
6
+ "pm",
7
+ "architect",
8
+ "developer",
9
+ "qa",
10
+ "release-engineer",
11
+ "infra-engineer",
12
+ "devops",
13
+ "security-engineer",
14
+ "designer",
15
+ "marketing",
16
+ ].join(", ");
17
+
18
+ const SESSION_PROGRESS_POLL_INTERVAL_MS = 1000;
19
+ const SESSION_PROGRESS_MESSAGE_LIMIT = 200;
20
+ const MAX_SESSION_PROGRESS_MESSAGE_CHARS = 4000;
21
+ const TOOL_CALL_BLOCK_TYPES = new Set(["tool_use", "toolcall", "tool_call"]);
22
+ const TOOL_RESULT_BLOCK_TYPES = new Set(["tool_result", "tool_result_error"]);
23
+
24
+ type SessionProgressEntry = {
25
+ fingerprint: string;
26
+ message: string;
27
+ phase: string;
28
+ stream: string;
29
+ };
30
+
31
+ type SessionProgressSnapshot = {
32
+ fingerprints: string[];
33
+ lastAssistantMessage: string;
34
+ latestMessages: unknown[];
35
+ };
36
+
37
+ export type RoleTaskExecutorDeps = {
38
+ runtime: OpenClawPluginApi["runtime"];
39
+ logger: PluginLogger;
40
+ role: RoleId;
41
+ taskTimeoutMs: number;
42
+ getSessionKey: (taskId: string) => string;
43
+ getIdempotencyKey?: (taskId: string) => string;
44
+ reportExecutionEvent?: (taskId: string, event: TaskExecutionEventInput) => Promise<void> | void;
45
+ };
46
+
47
+ export function createRoleTaskExecutor(deps: RoleTaskExecutorDeps) {
48
+ const { runtime, logger, role, taskTimeoutMs, getSessionKey, getIdempotencyKey, reportExecutionEvent } = deps;
49
+ const roleDef = getRole(role);
50
+ const roleSystemPrompt = roleDef
51
+ ? roleDef.systemPrompt
52
+ : `You are a ${role} in a virtual software team. Complete the assigned task.`;
53
+
54
+ return async (taskDescription: string, taskId: string): Promise<string> => {
55
+ const sessionKey = getSessionKey(taskId);
56
+ const taskMessage = buildTaskMessage(taskDescription, taskId, roleDef?.label ?? role);
57
+ logger.info(`TeamClaw: executing task ${taskId} as ${role} via subagent`);
58
+
59
+ async function emitExecutionEvent(event: TaskExecutionEventInput): Promise<void> {
60
+ if (!reportExecutionEvent) {
61
+ return;
62
+ }
63
+ try {
64
+ await Promise.resolve(reportExecutionEvent(taskId, {
65
+ role,
66
+ source: event.source ?? "worker",
67
+ ...event,
68
+ }));
69
+ } catch (err) {
70
+ logger.warn(`TeamClaw: failed to report execution event for task ${taskId}: ${String(err)}`);
71
+ }
72
+ }
73
+
74
+ try {
75
+ const runResult = await runtime.subagent.run({
76
+ sessionKey,
77
+ message: taskMessage,
78
+ extraSystemPrompt: roleSystemPrompt,
79
+ idempotencyKey: getIdempotencyKey?.(taskId),
80
+ });
81
+
82
+ logger.info(`TeamClaw: subagent run started for task ${taskId}, runId=${runResult.runId}`);
83
+ await emitExecutionEvent({
84
+ type: "lifecycle",
85
+ phase: "run_started",
86
+ source: "subagent",
87
+ status: "running",
88
+ runId: runResult.runId,
89
+ sessionKey,
90
+ message: `Subagent run started (${runResult.runId})`,
91
+ });
92
+
93
+ const progressSnapshot: SessionProgressSnapshot = {
94
+ fingerprints: [],
95
+ lastAssistantMessage: "",
96
+ latestMessages: [],
97
+ };
98
+
99
+ const syncSessionProgress = async (): Promise<void> => {
100
+ const sessionMessages = await runtime.subagent.getSessionMessages({
101
+ sessionKey,
102
+ limit: SESSION_PROGRESS_MESSAGE_LIMIT,
103
+ });
104
+ progressSnapshot.latestMessages = Array.isArray(sessionMessages.messages) ? sessionMessages.messages : [];
105
+
106
+ const entries = buildSessionProgressEntries(progressSnapshot.latestMessages, taskMessage);
107
+ const newEntries = getNewSessionProgressEntries(entries, progressSnapshot.fingerprints);
108
+ progressSnapshot.fingerprints = entries.map((entry) => entry.fingerprint);
109
+
110
+ for (const entry of newEntries) {
111
+ if (entry.stream === "assistant") {
112
+ progressSnapshot.lastAssistantMessage = entry.message;
113
+ }
114
+ await emitExecutionEvent({
115
+ type: "progress",
116
+ phase: entry.phase,
117
+ source: "subagent",
118
+ stream: entry.stream,
119
+ runId: runResult.runId,
120
+ sessionKey,
121
+ message: entry.message,
122
+ });
123
+ }
124
+ };
125
+
126
+ let keepPolling = true;
127
+ const pollSessionProgress = (async () => {
128
+ while (keepPolling) {
129
+ try {
130
+ await syncSessionProgress();
131
+ } catch (err) {
132
+ logger.debug?.(`TeamClaw: failed to sync session progress for ${taskId}: ${String(err)}`);
133
+ }
134
+
135
+ if (!keepPolling) {
136
+ break;
137
+ }
138
+ await delay(SESSION_PROGRESS_POLL_INTERVAL_MS);
139
+ }
140
+ })();
141
+
142
+ let waitResult;
143
+ try {
144
+ waitResult = await runtime.subagent.waitForRun({
145
+ runId: runResult.runId,
146
+ timeoutMs: taskTimeoutMs,
147
+ });
148
+ } finally {
149
+ keepPolling = false;
150
+ await pollSessionProgress;
151
+ }
152
+
153
+ try {
154
+ await syncSessionProgress();
155
+ } catch (err) {
156
+ logger.debug?.(`TeamClaw: failed final session progress sync for ${taskId}: ${String(err)}`);
157
+ }
158
+
159
+ if (waitResult.status === "ok") {
160
+ let result = extractLastAssistantText(progressSnapshot.latestMessages);
161
+ if (!result) {
162
+ const sessionMessages = await runtime.subagent.getSessionMessages({
163
+ sessionKey,
164
+ limit: 100,
165
+ });
166
+ result = extractLastAssistantText(sessionMessages.messages);
167
+ }
168
+ if (result && normalizeComparableText(result) !== normalizeComparableText(progressSnapshot.lastAssistantMessage)) {
169
+ await emitExecutionEvent({
170
+ type: "output",
171
+ phase: "final_output",
172
+ source: "subagent",
173
+ message: result,
174
+ });
175
+ }
176
+
177
+ logger.info(`TeamClaw: task ${taskId} completed successfully as ${role}`);
178
+ return result;
179
+ }
180
+
181
+ if (waitResult.status === "timeout") {
182
+ await emitExecutionEvent({
183
+ type: "error",
184
+ phase: "timeout",
185
+ source: "subagent",
186
+ status: "failed",
187
+ message: `Task execution timed out after ${formatDuration(taskTimeoutMs)}`,
188
+ });
189
+ throw new Error(`Task execution timed out after ${formatDuration(taskTimeoutMs)}`);
190
+ }
191
+
192
+ await emitExecutionEvent({
193
+ type: "error",
194
+ phase: "run_failed",
195
+ source: "subagent",
196
+ status: "failed",
197
+ message: waitResult.error || "Task execution failed",
198
+ });
199
+ throw new Error(waitResult.error || "Task execution failed");
200
+ } catch (err) {
201
+ const errorMsg = err instanceof Error ? err.message : String(err);
202
+ await emitExecutionEvent({
203
+ type: "error",
204
+ phase: "execution_error",
205
+ source: "worker",
206
+ status: "failed",
207
+ message: errorMsg,
208
+ });
209
+ logger.error(`TeamClaw: task ${taskId} execution failed for ${role}: ${errorMsg}`);
210
+ throw err;
211
+ }
212
+ };
213
+ }
214
+
215
+ function formatDuration(timeoutMs: number): string {
216
+ const totalSeconds = Math.ceil(timeoutMs / 1000);
217
+ if (totalSeconds % 3600 === 0) {
218
+ const hours = totalSeconds / 3600;
219
+ return `${hours} hour${hours === 1 ? "" : "s"}`;
220
+ }
221
+ if (totalSeconds % 60 === 0) {
222
+ const minutes = totalSeconds / 60;
223
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
224
+ }
225
+ return `${totalSeconds} second${totalSeconds === 1 ? "" : "s"}`;
226
+ }
227
+
228
+ function delay(ms: number): Promise<void> {
229
+ return new Promise((resolve) => {
230
+ setTimeout(resolve, ms);
231
+ });
232
+ }
233
+
234
+ function buildSessionProgressEntries(messages: unknown[], taskMessage: string): SessionProgressEntry[] {
235
+ const entries: SessionProgressEntry[] = [];
236
+ const normalizedTaskMessage = normalizeComparableText(taskMessage);
237
+
238
+ for (const rawMessage of messages) {
239
+ if (!rawMessage || typeof rawMessage !== "object") {
240
+ continue;
241
+ }
242
+
243
+ const message = rawMessage as Record<string, unknown>;
244
+ const role = normalizeSessionRole(message.role);
245
+ if (!role) {
246
+ continue;
247
+ }
248
+
249
+ const rendered = renderSessionMessage(message, role);
250
+ if (!rendered.message) {
251
+ continue;
252
+ }
253
+
254
+ const comparableMessage = normalizeComparableText(rendered.message);
255
+ if (role === "user" && normalizedTaskMessage && comparableMessage.includes(normalizedTaskMessage)) {
256
+ continue;
257
+ }
258
+
259
+ entries.push({
260
+ fingerprint: `${rendered.stream}:${comparableMessage}`,
261
+ message: rendered.message,
262
+ phase: rendered.stream,
263
+ stream: rendered.stream,
264
+ });
265
+ }
266
+
267
+ return entries;
268
+ }
269
+
270
+ function getNewSessionProgressEntries(
271
+ entries: SessionProgressEntry[],
272
+ previousFingerprints: string[],
273
+ ): SessionProgressEntry[] {
274
+ if (entries.length === 0) {
275
+ return [];
276
+ }
277
+ if (previousFingerprints.length === 0) {
278
+ return entries;
279
+ }
280
+
281
+ const currentFingerprints = entries.map((entry) => entry.fingerprint);
282
+ const maxOverlap = Math.min(previousFingerprints.length, currentFingerprints.length);
283
+ let overlap = 0;
284
+
285
+ for (let size = maxOverlap; size > 0; size -= 1) {
286
+ let matches = true;
287
+ for (let index = 0; index < size; index += 1) {
288
+ if (previousFingerprints[previousFingerprints.length - size + index] !== currentFingerprints[index]) {
289
+ matches = false;
290
+ break;
291
+ }
292
+ }
293
+ if (matches) {
294
+ overlap = size;
295
+ break;
296
+ }
297
+ }
298
+
299
+ return entries.slice(overlap);
300
+ }
301
+
302
+ function normalizeSessionRole(value: unknown): string {
303
+ if (typeof value !== "string") {
304
+ return "";
305
+ }
306
+ const normalized = value.trim().toLowerCase().replace(/[-\s]+/g, "_");
307
+ if (normalized === "toolresult") {
308
+ return "tool_result";
309
+ }
310
+ return normalized;
311
+ }
312
+
313
+ function renderSessionMessage(message: Record<string, unknown>, role: string): { message: string; stream: string } {
314
+ const content = message.content;
315
+ if (typeof content === "string") {
316
+ return {
317
+ message: truncateProgressMessage(content),
318
+ stream: role,
319
+ };
320
+ }
321
+
322
+ if (Array.isArray(content)) {
323
+ const textParts: string[] = [];
324
+ const toolCalls: string[] = [];
325
+ let toolResultCount = 0;
326
+ let toolResultErrors = 0;
327
+
328
+ for (const entry of content) {
329
+ if (!entry || typeof entry !== "object") {
330
+ continue;
331
+ }
332
+
333
+ const block = entry as Record<string, unknown>;
334
+ const type = normalizeBlockType(block.type);
335
+ if (type === "text") {
336
+ const text = typeof block.text === "string" ? block.text.trim() : "";
337
+ if (text) {
338
+ textParts.push(text);
339
+ }
340
+ continue;
341
+ }
342
+
343
+ if (TOOL_CALL_BLOCK_TYPES.has(type)) {
344
+ const name = typeof block.name === "string" ? block.name.trim() : "";
345
+ if (name) {
346
+ toolCalls.push(name);
347
+ }
348
+ continue;
349
+ }
350
+
351
+ if (TOOL_RESULT_BLOCK_TYPES.has(type)) {
352
+ toolResultCount += 1;
353
+ if (block.is_error === true) {
354
+ toolResultErrors += 1;
355
+ }
356
+ }
357
+ }
358
+
359
+ const parts: string[] = [];
360
+ if (textParts.length > 0) {
361
+ parts.push(textParts.join("\n"));
362
+ }
363
+ if (toolCalls.length > 0) {
364
+ parts.push(`[tool call] ${toolCalls.join(", ")}`);
365
+ }
366
+ if (toolResultCount > 0) {
367
+ parts.push(`[tool result] ${toolResultCount}${toolResultErrors > 0 ? ` (${toolResultErrors} error)` : ""}`);
368
+ }
369
+
370
+ if (parts.length > 0) {
371
+ return {
372
+ message: truncateProgressMessage(parts.join("\n")),
373
+ stream: textParts.length > 0 ? role : "tool",
374
+ };
375
+ }
376
+ }
377
+
378
+ const fallbackToolName = typeof message.toolName === "string"
379
+ ? message.toolName.trim()
380
+ : (typeof message.tool_name === "string" ? message.tool_name.trim() : "");
381
+ if (fallbackToolName) {
382
+ return {
383
+ message: `[tool call] ${fallbackToolName}`,
384
+ stream: "tool",
385
+ };
386
+ }
387
+
388
+ return {
389
+ message: truncateProgressMessage(safeJsonStringify(message)),
390
+ stream: role || "session",
391
+ };
392
+ }
393
+
394
+ function normalizeBlockType(value: unknown): string {
395
+ if (typeof value !== "string") {
396
+ return "";
397
+ }
398
+ return value.trim().toLowerCase();
399
+ }
400
+
401
+ function truncateProgressMessage(value: string): string {
402
+ const trimmed = value.trim();
403
+ if (!trimmed) {
404
+ return "";
405
+ }
406
+ if (trimmed.length <= MAX_SESSION_PROGRESS_MESSAGE_CHARS) {
407
+ return trimmed;
408
+ }
409
+ return `${trimmed.slice(0, MAX_SESSION_PROGRESS_MESSAGE_CHARS)}\n… (truncated)`;
410
+ }
411
+
412
+ function normalizeComparableText(value: string): string {
413
+ return value.trim().replace(/\r\n/g, "\n");
414
+ }
415
+
416
+ function safeJsonStringify(value: unknown): string {
417
+ try {
418
+ return typeof value === "string" ? value : JSON.stringify(value);
419
+ } catch (_err) {
420
+ return String(value);
421
+ }
422
+ }
423
+
424
+ function buildTaskMessage(taskDescription: string, taskId: string, roleLabel: string): string {
425
+ return [
426
+ taskDescription,
427
+ "",
428
+ "## Task Context",
429
+ `Reference: ${taskId}`,
430
+ `Assigned Role: ${roleLabel}`,
431
+ "",
432
+ "## Execution Rules",
433
+ "- Deliver exactly the artifact requested by this task.",
434
+ "- Follow the task verb literally: if the task asks for a brief, plan, matrix, review, package, positioning, or design artifact, produce that artifact and stop there.",
435
+ "- Do NOT scaffold code, project structure, configs, or files unless the task explicitly asks for implementation work.",
436
+ "- Do NOT create additional tasks, task trees, or duplicate follow-up work.",
437
+ "- Do NOT re-scope this into a multi-role coordination workflow.",
438
+ "- Do NOT delegate the core work of this task away to another role.",
439
+ "- If Task Context includes recent completed deliverables, treat them as upstream inputs and search the shared workspace for any referenced task IDs or filenames before requesting clarification.",
440
+ "- Do NOT attempt to inspect or resolve another worker's OpenClaw session or session key; those sessions are isolated per worker.",
441
+ "- Do NOT mark the task completed or failed via progress tools. Return the final deliverable (or raise an error) and let TeamClaw close the task.",
442
+ "- If critical information is missing and you cannot proceed safely, request clarification and wait instead of guessing.",
443
+ "- If more work is needed, mention it briefly in your result or use a handoff/review tool on this same task.",
444
+ `- When naming a role, use exact TeamClaw role IDs: ${TEAMCLAW_ROLE_IDS_TEXT}.`,
445
+ ].join("\n");
446
+ }
447
+
448
+ function extractLastAssistantText(messages: unknown[]): string {
449
+ const assistantMessages = messages.filter((message): message is { role?: unknown; content?: unknown } => {
450
+ if (!message || typeof message !== "object") {
451
+ return false;
452
+ }
453
+ return (message as { role?: unknown }).role === "assistant";
454
+ });
455
+
456
+ const lastAssistant = assistantMessages[assistantMessages.length - 1];
457
+ if (!lastAssistant) {
458
+ return "";
459
+ }
460
+
461
+ if (typeof lastAssistant.content === "string") {
462
+ return lastAssistant.content;
463
+ }
464
+
465
+ if (Array.isArray(lastAssistant.content)) {
466
+ const textBlocks = lastAssistant.content
467
+ .filter((block): block is { type?: unknown; text?: unknown } => {
468
+ return !!block && typeof block === "object" && (block as { type?: unknown }).type === "text";
469
+ })
470
+ .map((block) => (typeof block.text === "string" ? block.text : ""))
471
+ .filter(Boolean);
472
+ if (textBlocks.length > 0) {
473
+ return textBlocks.join("\n");
474
+ }
475
+ }
476
+
477
+ return JSON.stringify(lastAssistant);
478
+ }