@teamclaws/teamclaw 2026.3.26-2 → 2026.4.2-2
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 +52 -8
- package/cli.mjs +538 -224
- package/index.ts +76 -27
- package/openclaw.plugin.json +53 -28
- package/package.json +5 -2
- package/skills/teamclaw/SKILL.md +213 -0
- package/skills/teamclaw/references/api-quick-ref.md +117 -0
- package/skills/teamclaw-setup/SKILL.md +81 -0
- package/skills/teamclaw-setup/references/install-modes.md +136 -0
- package/skills/teamclaw-setup/references/validation-checklist.md +73 -0
- package/src/config.ts +44 -16
- package/src/controller/controller-capacity.ts +2 -2
- package/src/controller/controller-service.ts +193 -47
- package/src/controller/controller-tools.ts +102 -2
- package/src/controller/delivery-report.ts +563 -0
- package/src/controller/http-server.ts +1907 -172
- package/src/controller/kickoff-orchestrator.ts +292 -0
- package/src/controller/managed-gateway-process.ts +330 -0
- package/src/controller/orchestration-manifest.ts +69 -1
- package/src/controller/preview-manager.ts +676 -0
- package/src/controller/prompt-injector.ts +116 -67
- package/src/controller/role-inference.ts +41 -0
- package/src/controller/websocket.ts +3 -1
- package/src/controller/worker-provisioning.ts +429 -74
- package/src/discovery.ts +1 -1
- package/src/git-collaboration.ts +198 -47
- package/src/identity.ts +12 -2
- package/src/interaction-contracts.ts +179 -3
- package/src/networking.ts +99 -0
- package/src/openclaw-workspace.ts +478 -11
- package/src/prompt-policy.ts +381 -0
- package/src/roles.ts +37 -36
- package/src/state.ts +40 -1
- package/src/task-executor.ts +282 -78
- package/src/types.ts +150 -7
- package/src/ui/app.js +1403 -175
- package/src/ui/assets/teamclaw-app-icon.png +0 -0
- package/src/ui/index.html +122 -40
- package/src/ui/style.css +829 -143
- package/src/worker/http-handler.ts +40 -4
- package/src/worker/prompt-injector.ts +9 -38
- package/src/worker/skill-installer.ts +2 -2
- package/src/worker/tools.ts +31 -5
- package/src/worker/worker-service.ts +49 -8
- package/src/workspace-browser.ts +20 -7
- package/src/controller/local-worker-manager.ts +0 -533
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Team Kickoff Orchestrator
|
|
3
|
+
*
|
|
4
|
+
* Implements the "Team Kickoff Meeting" pattern: before committing to an execution
|
|
5
|
+
* plan, the controller provisions candidate-role workers and asks each for a
|
|
6
|
+
* structured assessment of the requirement from their professional perspective.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. Controller's initial LLM pass identifies complexity + candidate roles
|
|
10
|
+
* 2. Controller calls `teamclaw_request_kickoff` tool
|
|
11
|
+
* 3. This module provisions workers for each candidate role
|
|
12
|
+
* 4. Each worker receives a kickoff assessment request (lightweight LLM call)
|
|
13
|
+
* 5. Assessments are collected and returned to the controller
|
|
14
|
+
* 6. Controller synthesizes team input into final execution plan
|
|
15
|
+
* 7. Unneeded workers are reclaimed by the idle TTL mechanism
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { PluginLogger } from "../../api.js";
|
|
19
|
+
import type { KickoffAssessment, KickoffPlan, RoleId, TeamState, WorkerInfo } from "../types.js";
|
|
20
|
+
import { getRole, ROLES } from "../roles.js";
|
|
21
|
+
|
|
22
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Maximum time to wait for all workers to register after provisioning. */
|
|
25
|
+
const WORKER_PROVISION_TIMEOUT_MS = 90_000;
|
|
26
|
+
|
|
27
|
+
/** Maximum time to wait for a single worker's kickoff assessment. */
|
|
28
|
+
const ASSESSMENT_TIMEOUT_MS = 120_000;
|
|
29
|
+
|
|
30
|
+
/** Poll interval when waiting for workers to appear in state. */
|
|
31
|
+
const WORKER_POLL_INTERVAL_MS = 2_000;
|
|
32
|
+
|
|
33
|
+
const VALID_ROLE_IDS = new Set<string>(ROLES.map((r) => r.id));
|
|
34
|
+
|
|
35
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export type KickoffOrchestratorDeps = {
|
|
38
|
+
logger: PluginLogger;
|
|
39
|
+
getTeamState: () => TeamState | null;
|
|
40
|
+
/** Trigger provisioning for a specific role (returns immediately). */
|
|
41
|
+
ensureRoleProvisioned: (role: RoleId) => Promise<void>;
|
|
42
|
+
/** Send a kickoff assessment request to a worker and get the response. */
|
|
43
|
+
requestWorkerAssessment: (worker: WorkerInfo, requirement: string) => Promise<KickoffAssessment>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type KickoffRequest = {
|
|
47
|
+
requirement: string;
|
|
48
|
+
candidateRoles: RoleId[];
|
|
49
|
+
complexity: "simple" | "medium" | "complex";
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type KickoffResult = {
|
|
53
|
+
plan: KickoffPlan;
|
|
54
|
+
/** Human-readable summary of the team discussion for the controller LLM. */
|
|
55
|
+
summary: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
export async function runKickoffMeeting(
|
|
61
|
+
request: KickoffRequest,
|
|
62
|
+
deps: KickoffOrchestratorDeps,
|
|
63
|
+
): Promise<KickoffResult> {
|
|
64
|
+
const { logger } = deps;
|
|
65
|
+
const { requirement, candidateRoles, complexity } = request;
|
|
66
|
+
|
|
67
|
+
// Validate roles
|
|
68
|
+
const validRoles = candidateRoles.filter((r) => VALID_ROLE_IDS.has(r));
|
|
69
|
+
if (validRoles.length === 0) {
|
|
70
|
+
return buildEmptyResult(complexity, "No valid candidate roles provided.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
logger.info(`Kickoff: starting team meeting — complexity=${complexity}, candidates=[${validRoles.join(", ")}]`);
|
|
74
|
+
|
|
75
|
+
// Phase 1: Provision all candidate role workers in parallel
|
|
76
|
+
const provisionPromises = validRoles.map(async (role) => {
|
|
77
|
+
try {
|
|
78
|
+
await deps.ensureRoleProvisioned(role);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
logger.warn(`Kickoff: failed to provision ${role}: ${err instanceof Error ? err.message : String(err)}`);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
await Promise.all(provisionPromises);
|
|
84
|
+
|
|
85
|
+
// Phase 2: Wait for workers to appear in state
|
|
86
|
+
const availableWorkers = await waitForWorkers(validRoles, deps);
|
|
87
|
+
if (availableWorkers.size === 0) {
|
|
88
|
+
return buildEmptyResult(complexity, "No workers became available within the provisioning timeout.");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
logger.info(`Kickoff: ${availableWorkers.size}/${validRoles.length} workers available — requesting assessments`);
|
|
92
|
+
|
|
93
|
+
// Phase 3: Send assessment requests to all available workers in parallel
|
|
94
|
+
const assessments: KickoffAssessment[] = [];
|
|
95
|
+
const assessmentPromises = [...availableWorkers.entries()].map(async ([role, worker]) => {
|
|
96
|
+
try {
|
|
97
|
+
const assessment = await deps.requestWorkerAssessment(worker, requirement);
|
|
98
|
+
assessment.role = role; // ensure role is set correctly
|
|
99
|
+
assessments.push(assessment);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
logger.warn(`Kickoff: assessment failed for ${role}: ${err instanceof Error ? err.message : String(err)}`);
|
|
102
|
+
// Record a failed assessment so the controller knows this role didn't respond
|
|
103
|
+
assessments.push({
|
|
104
|
+
role,
|
|
105
|
+
needed: false,
|
|
106
|
+
scope: `Assessment failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
107
|
+
suggestedTasks: [],
|
|
108
|
+
dependencies: [],
|
|
109
|
+
risks: [`Could not assess — ${err instanceof Error ? err.message : "unknown error"}`],
|
|
110
|
+
questions: [],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
await Promise.all(assessmentPromises);
|
|
115
|
+
|
|
116
|
+
// Phase 4: Build the kickoff plan
|
|
117
|
+
const plan: KickoffPlan = {
|
|
118
|
+
complexity,
|
|
119
|
+
candidateRoles: validRoles,
|
|
120
|
+
assessments,
|
|
121
|
+
completed: true,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const summary = buildKickoffSummary(plan, requirement);
|
|
125
|
+
logger.info(`Kickoff: team meeting complete — ${assessments.filter((a) => a.needed).length}/${assessments.length} roles needed`);
|
|
126
|
+
|
|
127
|
+
return { plan, summary };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Internal helpers ─────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
async function waitForWorkers(
|
|
133
|
+
roles: RoleId[],
|
|
134
|
+
deps: KickoffOrchestratorDeps,
|
|
135
|
+
): Promise<Map<RoleId, WorkerInfo>> {
|
|
136
|
+
const deadline = Date.now() + WORKER_PROVISION_TIMEOUT_MS;
|
|
137
|
+
const result = new Map<RoleId, WorkerInfo>();
|
|
138
|
+
|
|
139
|
+
while (Date.now() < deadline) {
|
|
140
|
+
const state = deps.getTeamState();
|
|
141
|
+
if (state) {
|
|
142
|
+
for (const role of roles) {
|
|
143
|
+
if (result.has(role)) continue;
|
|
144
|
+
|
|
145
|
+
const worker = Object.values(state.workers).find(
|
|
146
|
+
(w) => w.role === role && (w.status === "idle" || w.status === "busy"),
|
|
147
|
+
);
|
|
148
|
+
if (worker) {
|
|
149
|
+
result.set(role, worker);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (result.size === roles.length) {
|
|
154
|
+
break; // all workers found
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await sleep(WORKER_POLL_INTERVAL_MS);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildEmptyResult(complexity: KickoffPlan["complexity"], reason: string): KickoffResult {
|
|
165
|
+
return {
|
|
166
|
+
plan: {
|
|
167
|
+
complexity,
|
|
168
|
+
candidateRoles: [],
|
|
169
|
+
assessments: [],
|
|
170
|
+
completed: true,
|
|
171
|
+
},
|
|
172
|
+
summary: `Team kickoff could not be completed: ${reason} Proceed with controller-only planning.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildKickoffSummary(plan: KickoffPlan, _requirement: string): string {
|
|
177
|
+
const parts: string[] = [];
|
|
178
|
+
parts.push(`## Team Kickoff Meeting Results (${plan.complexity} complexity)`);
|
|
179
|
+
parts.push("");
|
|
180
|
+
|
|
181
|
+
const needed = plan.assessments.filter((a) => a.needed);
|
|
182
|
+
const notNeeded = plan.assessments.filter((a) => !a.needed);
|
|
183
|
+
|
|
184
|
+
if (needed.length > 0) {
|
|
185
|
+
parts.push("### Roles Confirmed Needed");
|
|
186
|
+
for (const a of needed) {
|
|
187
|
+
const roleDef = getRole(a.role);
|
|
188
|
+
const icon = roleDef?.icon ?? "•";
|
|
189
|
+
parts.push(`\n**${icon} ${roleDef?.label ?? a.role}**`);
|
|
190
|
+
parts.push(`- Scope: ${a.scope}`);
|
|
191
|
+
if (a.suggestedTasks.length > 0) {
|
|
192
|
+
parts.push(`- Suggested tasks: ${a.suggestedTasks.join("; ")}`);
|
|
193
|
+
}
|
|
194
|
+
if (a.dependencies.length > 0) {
|
|
195
|
+
parts.push(`- Dependencies: ${a.dependencies.join("; ")}`);
|
|
196
|
+
}
|
|
197
|
+
if (a.risks.length > 0) {
|
|
198
|
+
parts.push(`- Risks: ${a.risks.join("; ")}`);
|
|
199
|
+
}
|
|
200
|
+
if (a.questions.length > 0) {
|
|
201
|
+
parts.push(`- Questions: ${a.questions.join("; ")}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (notNeeded.length > 0) {
|
|
207
|
+
parts.push("");
|
|
208
|
+
parts.push("### Roles Not Needed");
|
|
209
|
+
for (const a of notNeeded) {
|
|
210
|
+
const roleDef = getRole(a.role);
|
|
211
|
+
parts.push(`- ${roleDef?.icon ?? "•"} ${roleDef?.label ?? a.role}: ${a.scope}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
parts.push("");
|
|
216
|
+
parts.push("### Team Consensus");
|
|
217
|
+
parts.push(`- Required roles: ${needed.map((a) => a.role).join(", ") || "none identified"}`);
|
|
218
|
+
|
|
219
|
+
const allTasks = needed.flatMap((a) =>
|
|
220
|
+
a.suggestedTasks.map((t) => `[${a.role}] ${t}`),
|
|
221
|
+
);
|
|
222
|
+
if (allTasks.length > 0) {
|
|
223
|
+
parts.push("- Suggested task breakdown:");
|
|
224
|
+
for (const t of allTasks) {
|
|
225
|
+
parts.push(` - ${t}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const allDeps = needed.flatMap((a) => a.dependencies);
|
|
230
|
+
if (allDeps.length > 0) {
|
|
231
|
+
parts.push(`- Cross-role dependencies: ${[...new Set(allDeps)].join("; ")}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const allRisks = needed.flatMap((a) => a.risks);
|
|
235
|
+
if (allRisks.length > 0) {
|
|
236
|
+
parts.push(`- Team risks: ${[...new Set(allRisks)].join("; ")}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const allQuestions = needed.flatMap((a) => a.questions);
|
|
240
|
+
if (allQuestions.length > 0) {
|
|
241
|
+
parts.push(`- Unresolved questions: ${[...new Set(allQuestions)].join("; ")}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
parts.push("");
|
|
245
|
+
parts.push("Use these team assessments to create the final execution plan. Only create tasks for roles that confirmed they are needed. Respect the dependency ordering suggested by the team.");
|
|
246
|
+
|
|
247
|
+
return parts.join("\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Build the kickoff assessment prompt sent to a worker.
|
|
252
|
+
* The worker should respond with structured JSON matching KickoffAssessment.
|
|
253
|
+
*/
|
|
254
|
+
export function buildKickoffAssessmentPrompt(role: RoleId, requirement: string): string {
|
|
255
|
+
const roleDef = getRole(role);
|
|
256
|
+
const roleLabel = roleDef?.label ?? role;
|
|
257
|
+
|
|
258
|
+
return [
|
|
259
|
+
`You are the ${roleLabel} in a virtual software team.`,
|
|
260
|
+
`Your team is conducting a kickoff meeting for a new requirement.`,
|
|
261
|
+
``,
|
|
262
|
+
`## Requirement`,
|
|
263
|
+
requirement,
|
|
264
|
+
``,
|
|
265
|
+
`## Your Task`,
|
|
266
|
+
`Evaluate this requirement from your professional perspective as ${roleLabel}.`,
|
|
267
|
+
`Respond with a JSON object (and nothing else) with these fields:`,
|
|
268
|
+
``,
|
|
269
|
+
`{`,
|
|
270
|
+
` "needed": boolean, // Is your role needed for this project?`,
|
|
271
|
+
` "scope": string, // What would you contribute? (1-2 sentences)`,
|
|
272
|
+
` "suggestedTasks": string[], // Specific tasks you'd handle (2-5 items)`,
|
|
273
|
+
` "dependencies": string[], // What you need from other roles before or during your work`,
|
|
274
|
+
` "risks": string[], // Concerns or risks from your perspective`,
|
|
275
|
+
` "questions": string[] // Clarifications you'd want before starting`,
|
|
276
|
+
`}`,
|
|
277
|
+
``,
|
|
278
|
+
`Guidelines:`,
|
|
279
|
+
`- Be honest about whether your role is truly needed. Don't inflate your importance.`,
|
|
280
|
+
`- For "needed", consider: does the project actually require ${roleLabel}-level expertise?`,
|
|
281
|
+
`- A simple single-file script does NOT need an architect. A TODO app does NOT need a security engineer.`,
|
|
282
|
+
`- Keep suggestedTasks concrete and actionable, not vague.`,
|
|
283
|
+
`- Only list dependencies on roles that are actually relevant.`,
|
|
284
|
+
`- Return ONLY the JSON object, no markdown fencing, no explanation.`,
|
|
285
|
+
].join("\n");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function sleep(ms: number): Promise<void> {
|
|
289
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export { ASSESSMENT_TIMEOUT_MS };
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
const MANAGED_GATEWAY_PARENT_CHECK_INTERVAL_MS = 5_000;
|
|
5
|
+
const MANAGED_GATEWAY_PARENT_EXIT_GRACE_MS = 5_000;
|
|
6
|
+
|
|
7
|
+
export type ManagedGatewaySpawnOptions = {
|
|
8
|
+
gatewayEntrypoint: string;
|
|
9
|
+
gatewayPort: number;
|
|
10
|
+
cwd?: string;
|
|
11
|
+
env: NodeJS.ProcessEnv;
|
|
12
|
+
stdio?: SpawnOptions["stdio"];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ManagedCommandSpawnOptions = {
|
|
16
|
+
command: string;
|
|
17
|
+
cwd?: string;
|
|
18
|
+
env: NodeJS.ProcessEnv;
|
|
19
|
+
stdio?: SpawnOptions["stdio"];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function spawnManagedGatewayProcess(options: ManagedGatewaySpawnOptions): ChildProcess {
|
|
23
|
+
const gatewayEntrypoint = path.resolve(options.gatewayEntrypoint);
|
|
24
|
+
const cwd = options.cwd ?? path.dirname(gatewayEntrypoint);
|
|
25
|
+
const child = spawn(process.execPath, buildManagedGatewayWrapperArgs(gatewayEntrypoint, options.gatewayPort), {
|
|
26
|
+
...buildManagedProcessSpawnOptions({
|
|
27
|
+
cwd,
|
|
28
|
+
env: options.env,
|
|
29
|
+
stdio: options.stdio,
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
attachSpawnErrorGuard(child, "managed-gateway");
|
|
33
|
+
return child;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function spawnManagedCommandProcess(options: ManagedCommandSpawnOptions): ChildProcess {
|
|
37
|
+
const cwd = options.cwd ?? process.cwd();
|
|
38
|
+
const child = spawn(process.execPath, buildManagedCommandWrapperArgs(options.command), {
|
|
39
|
+
...buildManagedProcessSpawnOptions({
|
|
40
|
+
cwd,
|
|
41
|
+
env: options.env,
|
|
42
|
+
stdio: options.stdio,
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
attachSpawnErrorGuard(child, "managed-command");
|
|
46
|
+
return child;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Prevent uncaught exception when spawn fails (e.g. ENOENT for missing cwd/binary).
|
|
51
|
+
* Without this guard, Node.js emits the error on process.nextTick which crashes the host.
|
|
52
|
+
*/
|
|
53
|
+
function attachSpawnErrorGuard(child: ChildProcess, label: string): void {
|
|
54
|
+
child.on("error", (err) => {
|
|
55
|
+
console.error(`[teamclaw] spawn ${label} failed: ${err.message}`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function stopManagedProcess(
|
|
60
|
+
child: ChildProcess,
|
|
61
|
+
timeoutMs: number,
|
|
62
|
+
onForceKill?: () => void,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await new Promise<void>((resolve) => {
|
|
69
|
+
let settled = false;
|
|
70
|
+
const finish = () => {
|
|
71
|
+
if (settled) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
settled = true;
|
|
75
|
+
resolve();
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const timeout = setTimeout(() => {
|
|
79
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
80
|
+
onForceKill?.();
|
|
81
|
+
signalManagedProcessPid(child.pid, "SIGKILL");
|
|
82
|
+
}
|
|
83
|
+
finish();
|
|
84
|
+
}, timeoutMs);
|
|
85
|
+
timeout.unref?.();
|
|
86
|
+
|
|
87
|
+
child.once("exit", () => {
|
|
88
|
+
clearTimeout(timeout);
|
|
89
|
+
finish();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
signalManagedProcessPid(child.pid, "SIGTERM");
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function stopManagedGatewayProcess(
|
|
97
|
+
child: ChildProcess,
|
|
98
|
+
timeoutMs: number,
|
|
99
|
+
onForceKill?: () => void,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
await stopManagedProcess(child, timeoutMs, onForceKill);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function signalManagedProcessPid(pid: number | undefined, signal: NodeJS.Signals): void {
|
|
105
|
+
if (!pid || !Number.isFinite(pid) || pid <= 0) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (process.platform !== "win32") {
|
|
110
|
+
try {
|
|
111
|
+
process.kill(-pid, signal);
|
|
112
|
+
return;
|
|
113
|
+
} catch {
|
|
114
|
+
// Fall back to the direct PID when the wrapper process is already gone or the platform rejects group signaling.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
process.kill(pid, signal);
|
|
120
|
+
} catch {
|
|
121
|
+
// Best-effort cleanup only.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function signalManagedGatewayPid(pid: number | undefined, signal: NodeJS.Signals): void {
|
|
126
|
+
signalManagedProcessPid(pid, signal);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildManagedProcessSpawnOptions(options: {
|
|
130
|
+
cwd: string;
|
|
131
|
+
env: NodeJS.ProcessEnv;
|
|
132
|
+
stdio?: SpawnOptions["stdio"];
|
|
133
|
+
}): SpawnOptions {
|
|
134
|
+
return {
|
|
135
|
+
cwd: options.cwd,
|
|
136
|
+
env: {
|
|
137
|
+
...options.env,
|
|
138
|
+
TEAMCLAW_PARENT_PID: String(process.pid),
|
|
139
|
+
TEAMCLAW_PARENT_CHECK_INTERVAL_MS: String(MANAGED_GATEWAY_PARENT_CHECK_INTERVAL_MS),
|
|
140
|
+
TEAMCLAW_PARENT_EXIT_GRACE_MS: String(MANAGED_GATEWAY_PARENT_EXIT_GRACE_MS),
|
|
141
|
+
},
|
|
142
|
+
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
|
143
|
+
detached: process.platform !== "win32",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildManagedGatewayWrapperArgs(gatewayEntrypoint: string, gatewayPort: number): string[] {
|
|
148
|
+
const wrapperScript = String.raw`
|
|
149
|
+
const { spawn } = require("node:child_process");
|
|
150
|
+
|
|
151
|
+
const gatewayEntrypoint = process.argv[1];
|
|
152
|
+
const gatewayPort = process.argv[2];
|
|
153
|
+
const parentPid = Number(process.env.TEAMCLAW_PARENT_PID || "0");
|
|
154
|
+
const parentCheckIntervalMs = Number(process.env.TEAMCLAW_PARENT_CHECK_INTERVAL_MS || "5000");
|
|
155
|
+
const parentExitGraceMs = Number(process.env.TEAMCLAW_PARENT_EXIT_GRACE_MS || "5000");
|
|
156
|
+
const shutdownSignals = process.platform === "win32"
|
|
157
|
+
? ["SIGTERM", "SIGINT", "SIGBREAK"]
|
|
158
|
+
: ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"];
|
|
159
|
+
|
|
160
|
+
let shuttingDown = false;
|
|
161
|
+
let parentMonitor = null;
|
|
162
|
+
|
|
163
|
+
const child = spawn(process.execPath, [
|
|
164
|
+
gatewayEntrypoint,
|
|
165
|
+
"gateway",
|
|
166
|
+
"--allow-unconfigured",
|
|
167
|
+
"--bind",
|
|
168
|
+
"loopback",
|
|
169
|
+
"--port",
|
|
170
|
+
gatewayPort,
|
|
171
|
+
], {
|
|
172
|
+
env: process.env,
|
|
173
|
+
stdio: "inherit",
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
function stopChild(signal) {
|
|
177
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
child.kill(signal);
|
|
182
|
+
} catch {
|
|
183
|
+
// ignore
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function shutdown() {
|
|
188
|
+
if (shuttingDown) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
shuttingDown = true;
|
|
192
|
+
if (parentMonitor) {
|
|
193
|
+
clearInterval(parentMonitor);
|
|
194
|
+
parentMonitor = null;
|
|
195
|
+
}
|
|
196
|
+
stopChild("SIGTERM");
|
|
197
|
+
const timeout = setTimeout(() => {
|
|
198
|
+
stopChild("SIGKILL");
|
|
199
|
+
}, parentExitGraceMs);
|
|
200
|
+
timeout.unref?.();
|
|
201
|
+
child.once("exit", () => {
|
|
202
|
+
clearTimeout(timeout);
|
|
203
|
+
process.exit(0);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (parentPid > 1) {
|
|
208
|
+
parentMonitor = setInterval(() => {
|
|
209
|
+
try {
|
|
210
|
+
process.kill(parentPid, 0);
|
|
211
|
+
} catch {
|
|
212
|
+
shutdown();
|
|
213
|
+
}
|
|
214
|
+
}, parentCheckIntervalMs);
|
|
215
|
+
parentMonitor.unref?.();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const signal of shutdownSignals) {
|
|
219
|
+
process.once(signal, shutdown);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
process.once("exit", () => {
|
|
223
|
+
stopChild("SIGTERM");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
child.once("exit", (code, signal) => {
|
|
227
|
+
if (parentMonitor) {
|
|
228
|
+
clearInterval(parentMonitor);
|
|
229
|
+
parentMonitor = null;
|
|
230
|
+
}
|
|
231
|
+
if (shuttingDown) {
|
|
232
|
+
process.exit(code === null ? 0 : code);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
process.exit(code === null ? (signal ? 1 : 0) : code);
|
|
236
|
+
});
|
|
237
|
+
`;
|
|
238
|
+
|
|
239
|
+
return ["-e", wrapperScript, gatewayEntrypoint, String(gatewayPort)];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function buildManagedCommandWrapperArgs(command: string): string[] {
|
|
243
|
+
const wrapperScript = String.raw`
|
|
244
|
+
const { spawn } = require("node:child_process");
|
|
245
|
+
|
|
246
|
+
const command = process.argv[1];
|
|
247
|
+
const parentPid = Number(process.env.TEAMCLAW_PARENT_PID || "0");
|
|
248
|
+
const parentCheckIntervalMs = Number(process.env.TEAMCLAW_PARENT_CHECK_INTERVAL_MS || "5000");
|
|
249
|
+
const parentExitGraceMs = Number(process.env.TEAMCLAW_PARENT_EXIT_GRACE_MS || "5000");
|
|
250
|
+
const shutdownSignals = process.platform === "win32"
|
|
251
|
+
? ["SIGTERM", "SIGINT", "SIGBREAK"]
|
|
252
|
+
: ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"];
|
|
253
|
+
const shell = process.platform === "win32" ? "cmd.exe" : "sh";
|
|
254
|
+
const shellArgs = process.platform === "win32"
|
|
255
|
+
? ["/d", "/s", "/c", command]
|
|
256
|
+
: ["-lc", command];
|
|
257
|
+
|
|
258
|
+
let shuttingDown = false;
|
|
259
|
+
let parentMonitor = null;
|
|
260
|
+
|
|
261
|
+
const child = spawn(shell, shellArgs, {
|
|
262
|
+
env: process.env,
|
|
263
|
+
stdio: "inherit",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
function stopChild(signal) {
|
|
267
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
child.kill(signal);
|
|
272
|
+
} catch {
|
|
273
|
+
// ignore
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function shutdown() {
|
|
278
|
+
if (shuttingDown) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
shuttingDown = true;
|
|
282
|
+
if (parentMonitor) {
|
|
283
|
+
clearInterval(parentMonitor);
|
|
284
|
+
parentMonitor = null;
|
|
285
|
+
}
|
|
286
|
+
stopChild("SIGTERM");
|
|
287
|
+
const timeout = setTimeout(() => {
|
|
288
|
+
stopChild("SIGKILL");
|
|
289
|
+
}, parentExitGraceMs);
|
|
290
|
+
timeout.unref?.();
|
|
291
|
+
child.once("exit", () => {
|
|
292
|
+
clearTimeout(timeout);
|
|
293
|
+
process.exit(0);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (parentPid > 1) {
|
|
298
|
+
parentMonitor = setInterval(() => {
|
|
299
|
+
try {
|
|
300
|
+
process.kill(parentPid, 0);
|
|
301
|
+
} catch {
|
|
302
|
+
shutdown();
|
|
303
|
+
}
|
|
304
|
+
}, parentCheckIntervalMs);
|
|
305
|
+
parentMonitor.unref?.();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const signal of shutdownSignals) {
|
|
309
|
+
process.once(signal, shutdown);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
process.once("exit", () => {
|
|
313
|
+
stopChild("SIGTERM");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
child.once("exit", (code, signal) => {
|
|
317
|
+
if (parentMonitor) {
|
|
318
|
+
clearInterval(parentMonitor);
|
|
319
|
+
parentMonitor = null;
|
|
320
|
+
}
|
|
321
|
+
if (shuttingDown) {
|
|
322
|
+
process.exit(code === null ? 0 : code);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
process.exit(code === null ? (signal ? 1 : 0) : code);
|
|
326
|
+
});
|
|
327
|
+
`;
|
|
328
|
+
|
|
329
|
+
return ["-e", wrapperScript, command];
|
|
330
|
+
}
|