@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.
Files changed (46) hide show
  1. package/README.md +52 -8
  2. package/cli.mjs +538 -224
  3. package/index.ts +76 -27
  4. package/openclaw.plugin.json +53 -28
  5. package/package.json +5 -2
  6. package/skills/teamclaw/SKILL.md +213 -0
  7. package/skills/teamclaw/references/api-quick-ref.md +117 -0
  8. package/skills/teamclaw-setup/SKILL.md +81 -0
  9. package/skills/teamclaw-setup/references/install-modes.md +136 -0
  10. package/skills/teamclaw-setup/references/validation-checklist.md +73 -0
  11. package/src/config.ts +44 -16
  12. package/src/controller/controller-capacity.ts +2 -2
  13. package/src/controller/controller-service.ts +193 -47
  14. package/src/controller/controller-tools.ts +102 -2
  15. package/src/controller/delivery-report.ts +563 -0
  16. package/src/controller/http-server.ts +1907 -172
  17. package/src/controller/kickoff-orchestrator.ts +292 -0
  18. package/src/controller/managed-gateway-process.ts +330 -0
  19. package/src/controller/orchestration-manifest.ts +69 -1
  20. package/src/controller/preview-manager.ts +676 -0
  21. package/src/controller/prompt-injector.ts +116 -67
  22. package/src/controller/role-inference.ts +41 -0
  23. package/src/controller/websocket.ts +3 -1
  24. package/src/controller/worker-provisioning.ts +429 -74
  25. package/src/discovery.ts +1 -1
  26. package/src/git-collaboration.ts +198 -47
  27. package/src/identity.ts +12 -2
  28. package/src/interaction-contracts.ts +179 -3
  29. package/src/networking.ts +99 -0
  30. package/src/openclaw-workspace.ts +478 -11
  31. package/src/prompt-policy.ts +381 -0
  32. package/src/roles.ts +37 -36
  33. package/src/state.ts +40 -1
  34. package/src/task-executor.ts +282 -78
  35. package/src/types.ts +150 -7
  36. package/src/ui/app.js +1403 -175
  37. package/src/ui/assets/teamclaw-app-icon.png +0 -0
  38. package/src/ui/index.html +122 -40
  39. package/src/ui/style.css +829 -143
  40. package/src/worker/http-handler.ts +40 -4
  41. package/src/worker/prompt-injector.ts +9 -38
  42. package/src/worker/skill-installer.ts +2 -2
  43. package/src/worker/tools.ts +31 -5
  44. package/src/worker/worker-service.ts +49 -8
  45. package/src/workspace-browser.ts +20 -7
  46. package/src/controller/local-worker-manager.ts +0 -533
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import http from "node:http";
4
+ import zlib from "node:zlib";
4
5
  import type { IncomingMessage, ServerResponse } from "node:http";
5
6
  import type { OpenClawPluginApi, PluginLogger } from "../../api.js";
6
7
  import type {
@@ -12,6 +13,7 @@ import type {
12
13
  PluginConfig,
13
14
  RepoSyncInfo,
14
15
  RoleId,
16
+ StartupProvisioningReadiness,
15
17
  TaskExecution,
16
18
  TaskExecutionEvent,
17
19
  TaskExecutionEventInput,
@@ -25,6 +27,7 @@ import type {
25
27
  WorkerProgressContract,
26
28
  WorkerInfo,
27
29
  WorkerTaskResultContract,
30
+ WorkerTaskResultDeliverable,
28
31
  } from "../types.js";
29
32
  import {
30
33
  parseJsonBody,
@@ -33,16 +36,17 @@ import {
33
36
  sendError,
34
37
  generateId,
35
38
  } from "../protocol.js";
36
- import { listWorkspaceTree, readWorkspaceFile, readWorkspaceRawFile } from "../workspace-browser.js";
39
+ import { listWorkspaceTree, listWorkspaceSubtree, readWorkspaceFile, readWorkspaceRawFile } from "../workspace-browser.js";
37
40
  import { ROLES, normalizeRecommendedSkills, resolveRecommendedSkillsForRole } from "../roles.js";
38
41
  import { buildRepoSyncInfo, ensureControllerGitRepo, exportControllerGitBundle, importControllerGitBundle } from "../git-collaboration.js";
39
- import type { LocalWorkerManager } from "./local-worker-manager.js";
40
42
  import { TaskRouter } from "./task-router.js";
41
43
  import { MessageRouter } from "./message-router.js";
42
44
  import { TeamWebSocketServer } from "./websocket.js";
43
45
  import type { WorkerProvisioningManager } from "./worker-provisioning.js";
46
+ import type { PreviewManager } from "./preview-manager.js";
44
47
  import { createControllerPromptInjector } from "./prompt-injector.js";
45
48
  import { buildControllerNoWorkersMessage, shouldBlockControllerWithoutWorkers } from "./controller-capacity.js";
49
+ import { generateDeliveryReport, isSessionComplete, renderReportHtml, type DeliveryReport } from "./delivery-report.js";
46
50
  import {
47
51
  backfillWorkerProgressContract,
48
52
  backfillWorkerTaskResultContract,
@@ -50,8 +54,20 @@ import {
50
54
  normalizeTaskHandoffContract,
51
55
  normalizeWorkerProgressContract,
52
56
  normalizeWorkerTaskResultContract,
57
+ enrichDeliverablesWithPreviewInference,
53
58
  } from "../interaction-contracts.js";
54
- import { normalizeControllerManifest } from "./orchestration-manifest.js";
59
+ import {
60
+ buildTeamClawProjectWorkspacePath,
61
+ buildTeamClawAgentSessionKey,
62
+ getTeamClawModelReadiness,
63
+ resolveTeamClawAgentWorkspaceRootDir,
64
+ resolveTeamClawWorkspaceDir,
65
+ resolveTeamClawProjectsDir,
66
+ deriveProjectSlug,
67
+ } from "../openclaw-workspace.js";
68
+ import { resolvePreferredLanAddress } from "../networking.js";
69
+ import { normalizeClarificationQuestionSchema, normalizeControllerManifest } from "./orchestration-manifest.js";
70
+ import type { KickoffHandler } from "./controller-service.js";
55
71
 
56
72
  export type ControllerHttpDeps = {
57
73
  config: PluginConfig;
@@ -62,8 +78,10 @@ export type ControllerHttpDeps = {
62
78
  taskRouter: TaskRouter;
63
79
  messageRouter: MessageRouter;
64
80
  wsServer: TeamWebSocketServer;
65
- localWorkerManager?: LocalWorkerManager;
66
81
  workerProvisioningManager?: WorkerProvisioningManager | null;
82
+ previewManager?: PreviewManager;
83
+ /** Late-bound kickoff handler for automatic team kickoff on complex projects. */
84
+ getKickoffHandler?: () => KickoffHandler | undefined;
67
85
  };
68
86
 
69
87
  const MAX_TASK_EXECUTION_EVENTS = 250;
@@ -73,10 +91,44 @@ const MAX_TASK_CONTEXT_SUMMARY_CHARS = 500;
73
91
  const CONTROLLER_INTAKE_SESSION_PREFIX = "teamclaw-controller-web:";
74
92
  const CONTROLLER_INTAKE_AGENT_SESSION_RE = /^agent:[^:]+:(teamclaw-controller-web:[a-zA-Z0-9:_-]{1,120})$/;
75
93
  const CONTROLLER_RUN_WAIT_SLICE_MS = 30_000;
94
+ const EXISTING_PROJECT_REUSE_HINT_RE = /\b(existing|optimi(?:s|z)e|optimi(?:s|z)ation|improvement|enhanc(?:e|ement)|follow[- ]?up|extend|update|bugfix|bug fix)\b/iu;
95
+ const PROJECT_MATCH_STOPWORDS = new Set([
96
+ "a",
97
+ "an",
98
+ "and",
99
+ "app",
100
+ "application",
101
+ "build",
102
+ "complete",
103
+ "existing",
104
+ "follow",
105
+ "for",
106
+ "hub",
107
+ "improvement",
108
+ "improvements",
109
+ "internal",
110
+ "optimization",
111
+ "product",
112
+ "project",
113
+ "requirement",
114
+ "request",
115
+ "studio",
116
+ "system",
117
+ "the",
118
+ "this",
119
+ "up",
120
+ "update",
121
+ "web",
122
+ ]);
76
123
  const CONTROLLER_RATE_LIMIT_STALL_PROBE_MS = 5 * 60 * 1000;
77
124
  const CONTROLLER_RATE_LIMIT_PROBE_TIMEOUT_MS = 60_000;
125
+ const CONTROLLER_INACTIVITY_PROBE_TIMEOUT_MS = 60_000;
78
126
  const CONTROLLER_RATE_LIMIT_WAITING_SENTINEL = "TEAMCLAW_STILL_WAITING";
127
+ const CONTROLLER_INTAKE_MAX_RETRIES = 2;
128
+ const CONTROLLER_INTAKE_RETRY_DELAY_MS = 3_000;
129
+ const CONTROLLER_INTAKE_RETRYABLE_ERROR_PATTERN = /(500|502|503|server error|internal error|overloaded|unavailable)/i;
79
130
  const controllerIntakeQueue = new Map<string, Promise<void>>();
131
+ const EXTERNAL_WORKER_INSTALL_SPEC = "@teamclaws/teamclaw";
80
132
 
81
133
  export function buildControllerIntakeSystemPrompt(
82
134
  deps: Pick<ControllerHttpDeps, "config" | "getTeamState">,
@@ -88,6 +140,49 @@ export function buildControllerIntakeSystemPrompt(
88
140
  return injector()?.prependSystemContext ?? "";
89
141
  }
90
142
 
143
+ function resolveRequestPort(req: IncomingMessage, fallbackPort: number): number {
144
+ const hostHeader = req.headers.host?.trim();
145
+ if (!hostHeader) {
146
+ return fallbackPort;
147
+ }
148
+ try {
149
+ const parsed = new URL(`http://${hostHeader}`);
150
+ return parsed.port ? Number(parsed.port) : fallbackPort;
151
+ } catch {
152
+ return fallbackPort;
153
+ }
154
+ }
155
+
156
+ function resolveRecommendedLanControllerUrl(req: IncomingMessage, fallbackPort: number): string {
157
+ const port = resolveRequestPort(req, fallbackPort);
158
+ const preferredLan = resolvePreferredLanAddress();
159
+ return preferredLan ? `http://${preferredLan}:${port}` : "";
160
+ }
161
+
162
+ function shellEscapeSingleQuotes(value: string): string {
163
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
164
+ }
165
+
166
+ function buildExternalWorkerInstallInfo(req: IncomingMessage, config: PluginConfig): Record<string, unknown> {
167
+ const recommendedControllerUrl = resolveRecommendedLanControllerUrl(req, config.port);
168
+ return {
169
+ teamName: config.teamName,
170
+ recommendedControllerUrl,
171
+ roles: ROLES.map((role) => ({ id: role.id, label: role.label, icon: role.icon })),
172
+ autoDiscoveryCommandPrefix:
173
+ `npx -y ${EXTERNAL_WORKER_INSTALL_SPEC} install --yes --install-mode worker --team-name ${shellEscapeSingleQuotes(config.teamName)} --worker-role `,
174
+ manualCommandPrefix:
175
+ `npx -y ${EXTERNAL_WORKER_INSTALL_SPEC} install --yes --install-mode worker --team-name ${shellEscapeSingleQuotes(config.teamName)} --worker-role `,
176
+ manualControllerUrlFlag: recommendedControllerUrl
177
+ ? ` --controller-url ${shellEscapeSingleQuotes(recommendedControllerUrl)}`
178
+ : "",
179
+ manualControllerWarning: recommendedControllerUrl
180
+ ? "Manual worker installs must use the controller LAN IP, not localhost/127.0.0.1."
181
+ : "No private LAN IPv4 address was detected on this controller host yet, so manual worker install commands are unavailable until a LAN address exists.",
182
+ autoDiscoveryWarning: "mDNS auto-discovery only works when the worker and controller are reachable on the same LAN.",
183
+ };
184
+ }
185
+
91
186
  function mapTaskStatusToExecutionStatus(taskStatus: TaskStatus, current?: TaskExecution["status"]): TaskExecution["status"] {
92
187
  switch (taskStatus) {
93
188
  case "completed":
@@ -152,7 +247,7 @@ function buildTaskExecutionIdentity(taskId: string, workerId: string): {
152
247
  } {
153
248
  const attemptId = generateId();
154
249
  return {
155
- executionSessionKey: `teamclaw-task-${taskId}-${attemptId}`,
250
+ executionSessionKey: buildTeamClawAgentSessionKey(`teamclaw-task-${taskId}-${attemptId}`),
156
251
  executionIdempotencyKey: `teamclaw-${taskId}-${workerId}-${attemptId}`,
157
252
  };
158
253
  }
@@ -363,10 +458,16 @@ function createControllerRun(
363
458
  },
364
459
  ): ControllerRunInfo {
365
460
  const now = Date.now();
461
+ const existingState = deps.getTeamState();
462
+ const inheritedProjectDir = options?.sourceTaskId
463
+ ? existingState?.tasks[options.sourceTaskId]?.projectDir
464
+ : resolveProjectDirForSession(sessionKey, existingState)
465
+ ?? resolveExistingProjectDirFromMessage(message, existingState);
366
466
  const run: ControllerRunInfo = {
367
467
  id: generateId(),
368
468
  title: buildControllerRunTitle(message, options?.source ?? "human", options?.sourceTaskTitle),
369
469
  sessionKey,
470
+ projectDir: inheritedProjectDir ?? deriveProjectSlug(message),
370
471
  source: options?.source ?? "human",
371
472
  sourceTaskId: options?.sourceTaskId,
372
473
  sourceTaskTitle: options?.sourceTaskTitle,
@@ -386,6 +487,154 @@ function createControllerRun(
386
487
  return createdRun;
387
488
  }
388
489
 
490
+ function resolveExistingProjectDirFromMessage(
491
+ message: string,
492
+ state: TeamState | null,
493
+ ): string | undefined {
494
+ if (!EXISTING_PROJECT_REUSE_HINT_RE.test(message)) {
495
+ return undefined;
496
+ }
497
+ const normalizedMessage = normalizeProjectMatchingText(message);
498
+ if (!normalizedMessage) {
499
+ return undefined;
500
+ }
501
+ const explicitAlias = normalizeProjectMatchingText(extractProjectAliasFromText(message) ?? "");
502
+
503
+ let best: { projectDir: string; score: number; updatedAt: number } | null = null;
504
+ const candidates = collectExistingProjectCandidates(state);
505
+ for (const candidate of candidates) {
506
+ const normalizedAliases = Array.from(candidate.aliases)
507
+ .map((alias) => normalizeProjectMatchingText(alias))
508
+ .filter(Boolean)
509
+ .reduce<string[]>((acc, alias) => {
510
+ acc.push(alias);
511
+ return acc;
512
+ }, []);
513
+ const hasExplicitAliasMatch = explicitAlias
514
+ ? normalizedAliases.some((alias) => alias.includes(explicitAlias) || explicitAlias.includes(alias))
515
+ : false;
516
+ if (explicitAlias && !hasExplicitAliasMatch) {
517
+ continue;
518
+ }
519
+ const aliasScore = normalizedAliases
520
+ .reduce((score, alias) => {
521
+ if (!alias) {
522
+ return score;
523
+ }
524
+ return normalizedMessage.includes(alias)
525
+ ? Math.max(score, alias.split(" ").length + 10)
526
+ : score;
527
+ }, 0);
528
+ const tokenScore = scoreProjectTokenOverlap(normalizedMessage, candidate.searchText);
529
+ const totalScore = Math.max(aliasScore, tokenScore);
530
+ if (totalScore < 2) {
531
+ continue;
532
+ }
533
+ if (!best || totalScore > best.score || (totalScore === best.score && candidate.updatedAt > best.updatedAt)) {
534
+ best = {
535
+ projectDir: candidate.projectDir,
536
+ score: totalScore,
537
+ updatedAt: candidate.updatedAt,
538
+ };
539
+ }
540
+ }
541
+ return best?.projectDir;
542
+ }
543
+
544
+ function collectExistingProjectCandidates(state: TeamState | null): Array<{
545
+ projectDir: string;
546
+ aliases: Set<string>;
547
+ searchText: string;
548
+ updatedAt: number;
549
+ }> {
550
+ const byProjectDir = new Map<string, { aliases: Set<string>; texts: string[]; updatedAt: number }>();
551
+ const addCandidate = (projectDir: string | undefined, text: string | undefined, updatedAt: number, alias?: string | null) => {
552
+ if (!projectDir) {
553
+ return;
554
+ }
555
+ const entry = byProjectDir.get(projectDir) ?? { aliases: new Set<string>(), texts: [], updatedAt: 0 };
556
+ if (text && text.trim()) {
557
+ entry.texts.push(text);
558
+ }
559
+ if (alias && alias.trim()) {
560
+ entry.aliases.add(alias.trim());
561
+ }
562
+ entry.updatedAt = Math.max(entry.updatedAt, updatedAt);
563
+ byProjectDir.set(projectDir, entry);
564
+ };
565
+
566
+ for (const run of Object.values(state?.controllerRuns ?? {})) {
567
+ addCandidate(run.projectDir, run.request, run.updatedAt, extractProjectAliasFromText(run.request));
568
+ addCandidate(run.projectDir, run.title, run.updatedAt, extractProjectAliasFromText(run.title));
569
+ }
570
+ for (const task of Object.values(state?.tasks ?? {})) {
571
+ addCandidate(task.projectDir, task.title, task.updatedAt, extractProjectAliasFromText(task.title));
572
+ addCandidate(task.projectDir, task.description, task.updatedAt, extractProjectAliasFromText(task.description));
573
+ addCandidate(task.projectDir, task.resultContract?.summary, task.updatedAt);
574
+ }
575
+
576
+ return Array.from(byProjectDir.entries()).map(([projectDir, entry]) => ({
577
+ projectDir,
578
+ aliases: entry.aliases,
579
+ searchText: entry.texts.join("\n"),
580
+ updatedAt: entry.updatedAt,
581
+ }));
582
+ }
583
+
584
+ function extractProjectAliasFromText(text: string | undefined): string | null {
585
+ const firstMeaningfulLine = String(text ?? "")
586
+ .split(/\n+/u)
587
+ .map((line) => line.replace(/^#+\s*/u, "").trim())
588
+ .find(Boolean);
589
+ if (!firstMeaningfulLine) {
590
+ return null;
591
+ }
592
+ const normalized = firstMeaningfulLine
593
+ .replace(/^(product|optimization)\s+requirement[::]\s*/iu, "")
594
+ .replace(/^implement\s+/iu, "")
595
+ .replace(/^design\s+/iu, "")
596
+ .replace(/^enhance\s+/iu, "")
597
+ .replace(/^qa[:\s-]+/iu, "")
598
+ .replace(/\s+(architecture|application|web app|workflows?|improvements?)$/iu, "")
599
+ .trim();
600
+ if (normalized.length < 4 || normalized.length > 80) {
601
+ return null;
602
+ }
603
+ return normalized;
604
+ }
605
+
606
+ function normalizeProjectMatchingText(text: string): string {
607
+ return text
608
+ .toLowerCase()
609
+ .replace(/[`*_#:[\]()/\\-]+/gu, " ")
610
+ .replace(/\s+/gu, " ")
611
+ .trim();
612
+ }
613
+
614
+ function scoreProjectTokenOverlap(message: string, candidateText: string): number {
615
+ const messageTokens = tokenizeProjectMatchingText(message);
616
+ if (messageTokens.size === 0) {
617
+ return 0;
618
+ }
619
+ const candidateTokens = tokenizeProjectMatchingText(candidateText);
620
+ let overlap = 0;
621
+ for (const token of candidateTokens) {
622
+ if (messageTokens.has(token)) {
623
+ overlap += 1;
624
+ }
625
+ }
626
+ return overlap;
627
+ }
628
+
629
+ function tokenizeProjectMatchingText(text: string): Set<string> {
630
+ return new Set(
631
+ normalizeProjectMatchingText(text)
632
+ .split(" ")
633
+ .map((token) => token.trim())
634
+ .filter((token) => token.length >= 4 && !PROJECT_MATCH_STOPWORDS.has(token)),
635
+ );
636
+ }
637
+
389
638
  function updateControllerRun(
390
639
  runId: string,
391
640
  deps: ControllerHttpDeps,
@@ -422,7 +671,6 @@ function serializeTask(task?: TaskInfo, includeExecutionEvents = false): Record<
422
671
  }
423
672
 
424
673
  const payload: Record<string, unknown> = { ...task };
425
- delete payload.controllerSessionKey;
426
674
  if (!task.execution) {
427
675
  return payload;
428
676
  }
@@ -517,9 +765,10 @@ function normalizeControllerIntakeSessionKey(input: unknown): string {
517
765
  return fallback;
518
766
  }
519
767
 
520
- return logicalKey.startsWith(CONTROLLER_INTAKE_SESSION_PREFIX)
768
+ const normalizedLogicalKey = logicalKey.startsWith(CONTROLLER_INTAKE_SESSION_PREFIX)
521
769
  ? logicalKey
522
770
  : `${CONTROLLER_INTAKE_SESSION_PREFIX}${logicalKey}`;
771
+ return buildTeamClawAgentSessionKey(normalizedLogicalKey);
523
772
  }
524
773
 
525
774
  async function withSerializedControllerIntake<T>(
@@ -693,6 +942,14 @@ function findLatestControllerRunIdForSession(
693
942
  return matchingRuns[0]?.id ?? null;
694
943
  }
695
944
 
945
+ function resolveProjectDirForSession(
946
+ sessionKey: string,
947
+ state: TeamState | null,
948
+ ): string | undefined {
949
+ const runId = findLatestControllerRunIdForSession(sessionKey, state, { preferActive: true });
950
+ return runId ? state?.controllerRuns[runId]?.projectDir : undefined;
951
+ }
952
+
696
953
  function resolveControllerWorkflowSessionKey(task: TaskInfo, state: TeamState | null): string | undefined {
697
954
  if (task.controllerSessionKey) {
698
955
  return normalizeControllerIntakeSessionKey(task.controllerSessionKey);
@@ -727,6 +984,9 @@ function buildControllerManifestEventMessage(manifest: ControllerOrchestrationMa
727
984
  if (manifest.clarificationsNeeded) {
728
985
  parts.push(`clarifications=${manifest.clarificationQuestions.length}`);
729
986
  }
987
+ if (manifest.requirementFullyComplete) {
988
+ parts.push("requirementFullyComplete=true");
989
+ }
730
990
  return parts.join(" ");
731
991
  }
732
992
 
@@ -781,6 +1041,16 @@ function buildControllerManifestReply(
781
1041
  }
782
1042
  }
783
1043
 
1044
+ // ── Team Kickoff Meeting (brief note; full details rendered in UI) ───
1045
+ if (manifest.kickoffPlan?.assessments?.length) {
1046
+ const kp = manifest.kickoffPlan;
1047
+ const needed = kp.assessments.filter((a) => a.needed).length;
1048
+ lines.push(
1049
+ "",
1050
+ `Team Kickoff Meeting: ${kp.assessments.length} roles assessed, ${needed} confirmed needed. See the Kickoff Meeting panel in the dashboard for full discussion details.`,
1051
+ );
1052
+ }
1053
+
784
1054
  if (manifest.handoffPlan) {
785
1055
  lines.push("", `Handoff plan: ${manifest.handoffPlan}`);
786
1056
  }
@@ -848,6 +1118,15 @@ function buildBackfilledControllerManifest(
848
1118
  for (const roleId of inferManifestRolesFromText(rawReply)) {
849
1119
  inferredRoles.add(roleId);
850
1120
  }
1121
+ for (const roleId of inferManifestRolesFromText(request)) {
1122
+ inferredRoles.add(roleId);
1123
+ }
1124
+ // When no roles could be inferred at all (model didn't call the tool and didn't
1125
+ // mention any role names), fall back to "developer" as the most general purpose role
1126
+ // so that the intake run still has usable machine-readable state.
1127
+ if (inferredRoles.size === 0) {
1128
+ inferredRoles.add("developer");
1129
+ }
851
1130
  const clarificationQuestions = inferClarificationQuestionsFromReply(rawReply);
852
1131
  return {
853
1132
  version: "1.0",
@@ -855,6 +1134,12 @@ function buildBackfilledControllerManifest(
855
1134
  requiredRoles: Array.from(inferredRoles),
856
1135
  clarificationsNeeded: clarificationQuestions.length > 0 && actualCreatedTasks.length === 0,
857
1136
  clarificationQuestions,
1137
+ clarificationSchemas: clarificationQuestions.map((question) => ({
1138
+ kind: "text",
1139
+ title: question,
1140
+ required: true,
1141
+ placeholder: "Provide the missing information",
1142
+ })),
858
1143
  createdTasks: actualCreatedTasks.map((task) => ({
859
1144
  title: task.title,
860
1145
  assignedRole: task.assignedRole,
@@ -868,6 +1153,173 @@ function buildBackfilledControllerManifest(
868
1153
  };
869
1154
  }
870
1155
 
1156
+ function looksLikeSoftwareRequirement(request: string): boolean {
1157
+ const normalized = request.toLowerCase();
1158
+ return /(api|backend|frontend|fastapi|react|vue|node|python|typescript|javascript|sql|database|service|app|web|mobile|docker|kubernetes|deploy|测试|系统|平台|接口|服务|数据库|应用|前端|后端)/.test(normalized);
1159
+ }
1160
+
1161
+ function buildFallbackControllerTaskTitle(request: string, assignedRole?: RoleId): string {
1162
+ const firstMeaningfulLine = request
1163
+ .split(/\n+/)
1164
+ .map((line) => line.replace(/^#+\s*/, "").trim())
1165
+ .find(Boolean);
1166
+ if (firstMeaningfulLine) {
1167
+ const normalized = firstMeaningfulLine.replace(/^需求[::]?\s*/, "").trim();
1168
+ if (normalized.length > 0) {
1169
+ const capped = normalized.slice(0, 72).trim();
1170
+ return /[\u4e00-\u9fff]/.test(capped)
1171
+ ? `实现${capped}`
1172
+ : `Implement ${capped}`;
1173
+ }
1174
+ }
1175
+ return assignedRole === "developer"
1176
+ ? "Implement the requested software deliverable"
1177
+ : `Perform the requested ${assignedRole || "software"} work`;
1178
+ }
1179
+
1180
+ async function createControllerManagedTask(
1181
+ input: {
1182
+ title: string;
1183
+ description: string;
1184
+ priority?: TaskPriority;
1185
+ assignedRole?: RoleId;
1186
+ createdBy: string;
1187
+ controllerSessionKey?: string;
1188
+ recommendedSkills?: string[];
1189
+ },
1190
+ deps: ControllerHttpDeps,
1191
+ ): Promise<TaskInfo | undefined> {
1192
+ const taskId = generateId();
1193
+ const now = Date.now();
1194
+ const repoState = await refreshControllerRepoState(deps);
1195
+ const normalizedSessionKey = input.createdBy === "controller" && input.controllerSessionKey
1196
+ ? normalizeControllerIntakeSessionKey(input.controllerSessionKey)
1197
+ : undefined;
1198
+
1199
+ let projectDir: string | undefined;
1200
+ if (normalizedSessionKey) {
1201
+ const runId = findLatestControllerRunIdForSession(normalizedSessionKey, deps.getTeamState(), { preferActive: true });
1202
+ const parentRun = runId ? deps.getTeamState()?.controllerRuns[runId] : undefined;
1203
+ projectDir = parentRun?.projectDir;
1204
+ }
1205
+ if (!projectDir) {
1206
+ projectDir = deriveProjectSlug(input.title);
1207
+ }
1208
+
1209
+ const normalizedRecommendedSkills = normalizeRecommendedSkills(input.recommendedSkills ?? []);
1210
+ const task: TaskInfo = {
1211
+ id: taskId,
1212
+ title: input.title,
1213
+ description: input.description,
1214
+ status: "pending",
1215
+ priority: input.priority ?? "medium",
1216
+ assignedRole: input.assignedRole,
1217
+ createdBy: input.createdBy,
1218
+ recommendedSkills: normalizedRecommendedSkills.length > 0 ? normalizedRecommendedSkills : undefined,
1219
+ controllerSessionKey: normalizedSessionKey,
1220
+ projectDir,
1221
+ createdAt: now,
1222
+ updatedAt: now,
1223
+ };
1224
+
1225
+ deps.updateTeamState((state) => {
1226
+ state.tasks[taskId] = task;
1227
+ });
1228
+ recordTaskExecutionEvent(taskId, {
1229
+ type: "lifecycle",
1230
+ phase: "created",
1231
+ source: "controller",
1232
+ status: "pending",
1233
+ message: `Task created by ${input.createdBy}.`,
1234
+ role: input.assignedRole,
1235
+ }, deps);
1236
+ if (repoState?.enabled) {
1237
+ recordTaskExecutionEvent(taskId, {
1238
+ type: "lifecycle",
1239
+ phase: "repo_ready",
1240
+ source: "controller",
1241
+ status: "pending",
1242
+ message: repoState.remoteReady && repoState.remoteUrl
1243
+ ? `Git collaboration ready on ${repoState.defaultBranch} with remote ${repoState.remoteUrl}.`
1244
+ : `Git collaboration ready on ${repoState.defaultBranch} using controller-managed bundle sync.`,
1245
+ role: input.assignedRole,
1246
+ }, deps);
1247
+ }
1248
+ if (normalizedRecommendedSkills.length > 0) {
1249
+ recordTaskExecutionEvent(taskId, {
1250
+ type: "lifecycle",
1251
+ phase: "skills_recommended",
1252
+ source: "controller",
1253
+ status: "pending",
1254
+ message: `Recommended skills: ${normalizedRecommendedSkills.join(", ")}`,
1255
+ role: input.assignedRole,
1256
+ }, deps);
1257
+ }
1258
+
1259
+ await autoAssignPendingTasks(deps);
1260
+
1261
+ const updatedTask = deps.getTeamState()?.tasks[taskId];
1262
+ deps.wsServer.broadcastUpdate({ type: "task:created", data: serializeTask(updatedTask) });
1263
+ return updatedTask;
1264
+ }
1265
+
1266
+ async function maybeBackfillExecutionReadyTask(
1267
+ controllerRunId: string,
1268
+ sessionKey: string,
1269
+ request: string,
1270
+ manifest: ControllerOrchestrationManifest,
1271
+ deps: ControllerHttpDeps,
1272
+ options?: {
1273
+ source?: ControllerRunSource;
1274
+ },
1275
+ ): Promise<string[]> {
1276
+ if (manifest.createdTasks.length > 0 || manifest.clarificationsNeeded || manifest.requirementFullyComplete) {
1277
+ return [];
1278
+ }
1279
+ if (options?.source === "task_follow_up") {
1280
+ return [];
1281
+ }
1282
+ if (!looksLikeSoftwareRequirement(request)) {
1283
+ return [];
1284
+ }
1285
+
1286
+ const assignedRole = manifest.requiredRoles.includes("developer")
1287
+ ? "developer"
1288
+ : manifest.requiredRoles[0];
1289
+ const task = await createControllerManagedTask({
1290
+ title: buildFallbackControllerTaskTitle(request, assignedRole),
1291
+ description: request,
1292
+ assignedRole,
1293
+ createdBy: "controller",
1294
+ controllerSessionKey: sessionKey,
1295
+ recommendedSkills: resolveRecommendedSkillsForRole(assignedRole, []),
1296
+ }, deps);
1297
+ if (!task) {
1298
+ return [];
1299
+ }
1300
+
1301
+ manifest.createdTasks = [{
1302
+ title: task.title,
1303
+ assignedRole: task.assignedRole,
1304
+ expectedOutcome: summarizeManifestExpectedOutcome(task),
1305
+ }];
1306
+ manifest.notes = manifest.notes
1307
+ ? `${manifest.notes} Controller synthesized a fallback execution-ready task because the model returned no created tasks.`
1308
+ : "Controller synthesized a fallback execution-ready task because the model returned no created tasks.";
1309
+ updateControllerRun(controllerRunId, deps, (run) => {
1310
+ run.manifest = manifest;
1311
+ appendControllerRunEvent(run, {
1312
+ type: "warning",
1313
+ phase: "task_backfilled",
1314
+ source: "controller",
1315
+ status: "running",
1316
+ sessionKey,
1317
+ message: `Controller synthesized fallback task ${task.id} (${task.assignedRole || "unassigned"}).`,
1318
+ });
1319
+ });
1320
+ return [task.id];
1321
+ }
1322
+
871
1323
  function ensureControllerManifest(
872
1324
  controllerRunId: string,
873
1325
  sessionKey: string,
@@ -897,7 +1349,7 @@ function ensureControllerManifest(
897
1349
  return manifest;
898
1350
  }
899
1351
 
900
- function buildControllerFollowUpMessage(task: TaskInfo): string {
1352
+ function buildControllerFollowUpMessage(task: TaskInfo, state: TeamState | null): string {
901
1353
  const parts = [
902
1354
  `A controller-created TeamClaw task has ${task.status === "failed" ? "failed" : "completed"}.`,
903
1355
  `Task ID: ${task.id}`,
@@ -919,6 +1371,56 @@ function buildControllerFollowUpMessage(task: TaskInfo): string {
919
1371
  parts.push("", "## Task Error", task.error);
920
1372
  }
921
1373
 
1374
+ // Include preview URLs so the controller can present them to the human
1375
+ if (task.resultContract?.deliverables) {
1376
+ const liveDeliverables = task.resultContract.deliverables.filter((d) => d.liveUrl);
1377
+ if (liveDeliverables.length > 0) {
1378
+ parts.push("", "## Live Previews");
1379
+ for (const d of liveDeliverables) {
1380
+ parts.push(`- ${d.summary || d.value}: ${d.liveUrl}`);
1381
+ }
1382
+ }
1383
+ }
1384
+
1385
+ // Inject prior manifest context (deferred tasks, clarification questions) so the
1386
+ // controller knows the original plan and can advance it
1387
+ if (state) {
1388
+ const sessionKey = task.controllerSessionKey;
1389
+ if (sessionKey) {
1390
+ const priorRuns = Object.values(state.controllerRuns)
1391
+ .filter((run) => normalizeControllerIntakeSessionKey(run.sessionKey) === normalizeControllerIntakeSessionKey(sessionKey) && run.manifest)
1392
+ .sort((a, b) => b.updatedAt - a.updatedAt);
1393
+
1394
+ // Collect deferred tasks from the latest manifest that had them
1395
+ const latestWithDeferred = priorRuns.find((run) => run.manifest!.deferredTasks && run.manifest!.deferredTasks.length > 0);
1396
+ if (latestWithDeferred?.manifest?.deferredTasks) {
1397
+ parts.push("", "## Prior Orchestration Plan (Deferred Tasks)");
1398
+ parts.push("These tasks were identified in the original plan but deferred until prerequisites were met:");
1399
+ for (const dt of latestWithDeferred.manifest.deferredTasks) {
1400
+ const blockedBy = dt.blockedBy ? ` [blocked by: ${dt.blockedBy}]` : "";
1401
+ parts.push(`- ${dt.title} (${dt.assignedRole || "any"})${blockedBy}`);
1402
+ }
1403
+ }
1404
+
1405
+ // Collect only still-pending clarification questions for this session.
1406
+ const pendingClarifications = Object.values(state.clarifications)
1407
+ .filter((clarification) =>
1408
+ clarification.status === "pending"
1409
+ && normalizeControllerIntakeSessionKey(clarification.controllerSessionKey) === normalizeControllerIntakeSessionKey(sessionKey),
1410
+ )
1411
+ .sort((a, b) => b.updatedAt - a.updatedAt);
1412
+ const priorQuestions = pendingClarifications
1413
+ .map((clarification) => clarification.question)
1414
+ .filter(Boolean);
1415
+ if (priorQuestions.length > 0) {
1416
+ parts.push("", "## Prior Clarification Questions (from earlier runs)");
1417
+ for (const q of priorQuestions.slice(0, 5)) {
1418
+ parts.push(`- ${q}`);
1419
+ }
1420
+ }
1421
+ }
1422
+ }
1423
+
922
1424
  parts.push(
923
1425
  "",
924
1426
  "## Controller Follow-up",
@@ -926,12 +1428,184 @@ function buildControllerFollowUpMessage(task: TaskInfo): string {
926
1428
  "Review the current TeamClaw state before acting.",
927
1429
  "Create only the next execution-ready task(s) whose prerequisites are now satisfied.",
928
1430
  "Do not duplicate tasks that already exist, are active, or are already completed.",
1431
+ "If this task produced a web application with a live preview URL, include it in your reply so the human can verify the result.",
1432
+ "If all planned phases are complete and no follow-ups remain, set requirementFullyComplete=true in the manifest and provide a final delivery summary.",
929
1433
  "If no additional task should be created yet, reply briefly and stop.",
930
1434
  );
931
1435
 
932
1436
  return parts.filter(Boolean).join("\n");
933
1437
  }
934
1438
 
1439
+ function buildControllerClarificationAnswerMessage(
1440
+ clarification: ClarificationRequest,
1441
+ answer: string,
1442
+ answeredBy: string,
1443
+ ): string {
1444
+ return [
1445
+ "The human has answered a pending controller clarification.",
1446
+ `Question: ${clarification.question}`,
1447
+ `Answer: ${answer}`,
1448
+ `Answered by: ${answeredBy}`,
1449
+ "Use this answer to continue the same requirement and update the plan/tasks accordingly.",
1450
+ ].join("\n");
1451
+ }
1452
+
1453
+ function buildStructuredClarificationAnswer(
1454
+ clarification: ClarificationRequest,
1455
+ payload: {
1456
+ answer?: string;
1457
+ answerValue?: string;
1458
+ answerValues?: string[];
1459
+ answerNumber?: number;
1460
+ answerComment?: string;
1461
+ },
1462
+ ): string {
1463
+ if (typeof payload.answer === "string" && payload.answer.trim()) {
1464
+ return payload.answer.trim();
1465
+ }
1466
+ const schema = clarification.questionSchema;
1467
+ const optionLabel = (value: string): string => {
1468
+ const match = schema?.options?.find((entry) => entry.value === value);
1469
+ return match?.label || value;
1470
+ };
1471
+ const parts: string[] = [];
1472
+ if (schema?.kind === "single-select" && payload.answerValue) {
1473
+ parts.push(optionLabel(payload.answerValue));
1474
+ } else if (schema?.kind === "multi-select" && Array.isArray(payload.answerValues) && payload.answerValues.length > 0) {
1475
+ parts.push(payload.answerValues.map((entry) => optionLabel(entry)).join(", "));
1476
+ } else if (schema?.kind === "number" && typeof payload.answerNumber === "number" && Number.isFinite(payload.answerNumber)) {
1477
+ parts.push(`${payload.answerNumber}${schema.unit ? ` ${schema.unit}` : ""}`);
1478
+ } else if (payload.answerValue) {
1479
+ parts.push(payload.answerValue);
1480
+ }
1481
+ if (payload.answerComment && payload.answerComment.trim()) {
1482
+ parts.push(parts.length > 0 ? `Additional context: ${payload.answerComment.trim()}` : payload.answerComment.trim());
1483
+ }
1484
+ return parts.join("\n").trim();
1485
+ }
1486
+
1487
+ function normalizeComparableText(value: string): string {
1488
+ return value
1489
+ .toLowerCase()
1490
+ .replace(/\s+/g, " ")
1491
+ .trim();
1492
+ }
1493
+
1494
+ function buildManifestClarificationEntries(
1495
+ manifest: ControllerOrchestrationManifest,
1496
+ ): Array<{ question: string; questionSchema?: ClarificationRequest["questionSchema"] }> {
1497
+ const normalizedQuestions = manifest.clarificationQuestions
1498
+ .map((entry) => String(entry || "").trim())
1499
+ .filter(Boolean);
1500
+ const schemas = Array.isArray(manifest.clarificationSchemas) ? manifest.clarificationSchemas : [];
1501
+ if (schemas.length > 0) {
1502
+ return schemas.map((schema, index) => ({
1503
+ question: normalizedQuestions[index] || schema.title,
1504
+ questionSchema: schema,
1505
+ }));
1506
+ }
1507
+ return normalizedQuestions.map((question) => ({ question }));
1508
+ }
1509
+
1510
+ function syncControllerRunClarifications(
1511
+ controllerRunId: string,
1512
+ sessionKey: string,
1513
+ manifest: ControllerOrchestrationManifest,
1514
+ deps: ControllerHttpDeps,
1515
+ ): ClarificationRequest[] {
1516
+ const entries = buildManifestClarificationEntries(manifest);
1517
+ if (entries.length === 0) {
1518
+ return [];
1519
+ }
1520
+
1521
+ const created: ClarificationRequest[] = [];
1522
+ const now = Date.now();
1523
+ deps.updateTeamState((state) => {
1524
+ const existingQuestions = new Map(
1525
+ Object.values(state.clarifications)
1526
+ .filter((item) => item.controllerRunId === controllerRunId)
1527
+ .map((item) => [normalizeComparableText(item.question), item]),
1528
+ );
1529
+
1530
+ for (const entry of entries) {
1531
+ const key = normalizeComparableText(entry.question);
1532
+ if (existingQuestions.has(key)) {
1533
+ continue;
1534
+ }
1535
+ const clarification: ClarificationRequest = {
1536
+ id: generateId(),
1537
+ taskId: "",
1538
+ controllerRunId,
1539
+ controllerSessionKey: sessionKey,
1540
+ requestedBy: "controller",
1541
+ question: entry.question,
1542
+ questionSchema: entry.questionSchema,
1543
+ blockingReason: "The controller needs this information before it can confidently continue planning and downstream task creation.",
1544
+ context: manifest.requirementSummary,
1545
+ status: "pending",
1546
+ createdAt: now,
1547
+ updatedAt: now,
1548
+ };
1549
+ state.clarifications[clarification.id] = clarification;
1550
+ created.push(clarification);
1551
+ }
1552
+ });
1553
+
1554
+ for (const clarification of created) {
1555
+ deps.wsServer.broadcastUpdate({ type: "clarification:requested", data: clarification });
1556
+ }
1557
+ return created;
1558
+ }
1559
+
1560
+ function reconcileControllerClarifications(deps: ControllerHttpDeps): TeamState | null {
1561
+ const state = deps.getTeamState();
1562
+ if (!state) {
1563
+ return null;
1564
+ }
1565
+
1566
+ deps.updateTeamState((draft) => {
1567
+ const runsBySession = new Map<string, ControllerRunInfo[]>();
1568
+ for (const run of Object.values(draft.controllerRuns)) {
1569
+ const normalizedSessionKey = normalizeControllerIntakeSessionKey(run.sessionKey);
1570
+ const bucket = runsBySession.get(normalizedSessionKey) ?? [];
1571
+ bucket.push(run);
1572
+ runsBySession.set(normalizedSessionKey, bucket);
1573
+ }
1574
+
1575
+ for (const clar of Object.values(draft.clarifications)) {
1576
+ if (clar.status !== "pending" || clar.requestedBy !== "controller" || clar.taskId) {
1577
+ continue;
1578
+ }
1579
+ const normalizedSessionKey = normalizeControllerIntakeSessionKey(clar.controllerSessionKey);
1580
+ const sessionRuns = runsBySession.get(normalizedSessionKey) ?? [];
1581
+ const supersedingRun = sessionRuns.find((run) =>
1582
+ run.updatedAt > clar.updatedAt
1583
+ && (
1584
+ run.manifest?.createdTasks.length
1585
+ || run.manifest?.requirementFullyComplete
1586
+ ),
1587
+ );
1588
+ if (!supersedingRun) {
1589
+ continue;
1590
+ }
1591
+ clar.status = "answered";
1592
+ clar.answer = "Automatically superseded by later controller progress.";
1593
+ clar.answeredBy = "system";
1594
+ clar.answeredAt = supersedingRun.updatedAt;
1595
+ clar.updatedAt = supersedingRun.updatedAt;
1596
+ }
1597
+ });
1598
+
1599
+ for (const run of Object.values(state.controllerRuns)) {
1600
+ if (!run.manifest?.clarificationsNeeded) {
1601
+ continue;
1602
+ }
1603
+ syncControllerRunClarifications(run.id, run.sessionKey, run.manifest, deps);
1604
+ }
1605
+
1606
+ return deps.getTeamState();
1607
+ }
1608
+
935
1609
  function buildControllerRateLimitProbeMessage(
936
1610
  sourceTaskId?: string,
937
1611
  sourceTaskTitle?: string,
@@ -949,6 +1623,64 @@ function buildControllerRateLimitProbeMessage(
949
1623
  ].join("\n");
950
1624
  }
951
1625
 
1626
+ function buildControllerInactivityProbeMessage(
1627
+ inactivityMs: number,
1628
+ sourceTaskId?: string,
1629
+ sourceTaskTitle?: string,
1630
+ ): string {
1631
+ const workflowLabel = sourceTaskTitle
1632
+ ? `${sourceTaskTitle}${sourceTaskId ? ` (${sourceTaskId})` : ""}`
1633
+ : (sourceTaskId ? `task ${sourceTaskId}` : "this controller workflow");
1634
+ return [
1635
+ `This is a follow-up check for ${workflowLabel}.`,
1636
+ `There has been no new visible controller workflow progress for over ${formatDuration(inactivityMs)}.`,
1637
+ "Do not restart the workflow from scratch.",
1638
+ "Do not duplicate tasks that already exist, are active, or are completed.",
1639
+ "If the earlier controller follow-up is fully complete now, immediately submit the required structured manifest for that same workflow step and provide the final orchestration reply.",
1640
+ `If the earlier controller follow-up is not complete yet, reply with exactly ${CONTROLLER_RATE_LIMIT_WAITING_SENTINEL}.`,
1641
+ ].join("\n");
1642
+ }
1643
+
1644
+ async function checkAndGenerateReport(task: TaskInfo, deps: ControllerHttpDeps): Promise<void> {
1645
+ const sessionKey = task.controllerSessionKey;
1646
+ if (!sessionKey) return;
1647
+
1648
+ const state = deps.getTeamState();
1649
+ if (!state) return;
1650
+
1651
+ if (!isSessionComplete(sessionKey, state, normalizeControllerIntakeSessionKey)) return;
1652
+
1653
+ const report = generateDeliveryReport(sessionKey, state, normalizeControllerIntakeSessionKey);
1654
+ if (!report) return;
1655
+
1656
+ // Check if we already generated a report for this session
1657
+ const existingReport = state.reports?.[report.id];
1658
+ if (existingReport) return;
1659
+
1660
+ // Store a lightweight record in state (full report is generated on demand from live state)
1661
+ deps.updateTeamState((s) => {
1662
+ if (!s.reports) s.reports = {};
1663
+ s.reports[report.id] = {
1664
+ id: report.id,
1665
+ sessionKey: report.sessionKey,
1666
+ generatedAt: report.generatedAt,
1667
+ projectName: report.projectName,
1668
+ requirementSummary: report.requirementSummary,
1669
+ status: report.status,
1670
+ taskCount: report.taskCount,
1671
+ deliverableCount: report.deliverables.length,
1672
+ previewCount: report.deliverables.filter((d) => d.previewUrl).length,
1673
+ };
1674
+ });
1675
+
1676
+ const reportUrl = `/api/v1/reports/${encodeURIComponent(sessionKey)}`;
1677
+ deps.wsServer.broadcastUpdate({
1678
+ type: "report:ready",
1679
+ data: { sessionKey, reportUrl, projectName: report.projectName, status: report.status },
1680
+ });
1681
+ deps.logger.info(`Controller: delivery report generated for session ${sessionKey}: ${reportUrl}`);
1682
+ }
1683
+
952
1684
  async function continueControllerWorkflow(task: TaskInfo, deps: ControllerHttpDeps): Promise<void> {
953
1685
  if (task.createdBy !== "controller") {
954
1686
  return;
@@ -965,7 +1697,7 @@ async function continueControllerWorkflow(task: TaskInfo, deps: ControllerHttpDe
965
1697
  }
966
1698
  });
967
1699
  }
968
- await runControllerIntake(buildControllerFollowUpMessage(task), sessionKey, deps, {
1700
+ await runControllerIntake(buildControllerFollowUpMessage(task, deps.getTeamState()), sessionKey, deps, {
969
1701
  source: "task_follow_up",
970
1702
  sourceTaskId: task.id,
971
1703
  sourceTaskTitle: task.title,
@@ -983,9 +1715,32 @@ async function runControllerIntake(
983
1715
  },
984
1716
  ): Promise<{ sessionKey: string; runId: string; reply: string; controllerRunId: string }> {
985
1717
  const normalizedSessionKey = normalizeControllerIntakeSessionKey(sessionKey);
986
- return withSerializedControllerIntake(normalizedSessionKey, () =>
987
- runControllerIntakeUnlocked(message, normalizedSessionKey, deps, options),
988
- );
1718
+ return withSerializedControllerIntake(normalizedSessionKey, async () => {
1719
+ let lastError: Error | null = null;
1720
+ for (let attempt = 0; attempt <= CONTROLLER_INTAKE_MAX_RETRIES; attempt++) {
1721
+ try {
1722
+ return await runControllerIntakeUnlocked(message, normalizedSessionKey, deps, options);
1723
+ } catch (err) {
1724
+ lastError = err instanceof Error ? err : new Error(String(err));
1725
+ const errorText = lastError.message;
1726
+ // Only retry on transient server errors (500/502/503), not on client errors,
1727
+ // timeouts, or other failures that retrying won't fix.
1728
+ if (
1729
+ attempt === CONTROLLER_INTAKE_MAX_RETRIES
1730
+ || !CONTROLLER_INTAKE_RETRYABLE_ERROR_PATTERN.test(errorText)
1731
+ || errorText.includes("timed out")
1732
+ ) {
1733
+ throw lastError;
1734
+ }
1735
+ // Record a visible retry event so the human can see what happened.
1736
+ deps.logger.warn(
1737
+ `Controller: intake attempt ${attempt + 1} failed with transient error: ${errorText.slice(0, 200)}. Retrying in ${CONTROLLER_INTAKE_RETRY_DELAY_MS * (attempt + 1) / 1000}s...`,
1738
+ );
1739
+ await new Promise<void>((resolve) => setTimeout(resolve, CONTROLLER_INTAKE_RETRY_DELAY_MS * (attempt + 1)));
1740
+ }
1741
+ }
1742
+ throw lastError!;
1743
+ });
989
1744
  }
990
1745
 
991
1746
  async function runControllerIntakeUnlocked(
@@ -1034,6 +1789,16 @@ async function runControllerIntakeUnlocked(
1034
1789
  active: false,
1035
1790
  probeCount: 0,
1036
1791
  };
1792
+ const inactivityState: {
1793
+ active: boolean;
1794
+ visibleAt?: number;
1795
+ nextProbeAt?: number;
1796
+ probeCount: number;
1797
+ } = {
1798
+ active: false,
1799
+ nextProbeAt: Date.now() + deps.config.taskTimeoutMs,
1800
+ probeCount: 0,
1801
+ };
1037
1802
 
1038
1803
  const markRateLimitWaiting = async (): Promise<void> => {
1039
1804
  if (rateLimitState.active) {
@@ -1060,6 +1825,12 @@ async function runControllerIntakeUnlocked(
1060
1825
  rateLimitState.nextProbeAt = undefined;
1061
1826
  };
1062
1827
 
1828
+ const noteObservedControllerActivity = (): void => {
1829
+ inactivityState.active = false;
1830
+ inactivityState.visibleAt = undefined;
1831
+ inactivityState.nextProbeAt = Date.now() + deps.config.taskTimeoutMs;
1832
+ };
1833
+
1063
1834
  const extractSessionAssistantReply = async (): Promise<string> => {
1064
1835
  const sessionMessages = await deps.runtime.subagent.getSessionMessages({
1065
1836
  sessionKey,
@@ -1113,19 +1884,76 @@ async function runControllerIntakeUnlocked(
1113
1884
  }
1114
1885
 
1115
1886
  clearRateLimitWaiting();
1887
+ noteObservedControllerActivity();
1888
+ return probeReply;
1889
+ };
1890
+
1891
+ const probeInactiveControllerCompletion = async (): Promise<string | null> => {
1892
+ inactivityState.probeCount += 1;
1893
+ const now = Date.now();
1894
+ inactivityState.active = true;
1895
+ inactivityState.visibleAt = now;
1896
+ inactivityState.nextProbeAt = now + deps.config.taskTimeoutMs;
1897
+ recordControllerRunEvent(controllerRun.id, {
1898
+ type: "progress",
1899
+ phase: "inactivity_probe",
1900
+ source: "controller",
1901
+ status: "running",
1902
+ sessionKey,
1903
+ runId: runResult.runId,
1904
+ message: `No new controller workflow output has appeared for over ${formatDuration(deps.config.taskTimeoutMs)}. Re-checking whether this orchestration step is complete or still running.`,
1905
+ }, deps);
1906
+
1907
+ const probeRun = await deps.runtime.subagent.run({
1908
+ sessionKey,
1909
+ message: buildControllerInactivityProbeMessage(
1910
+ deps.config.taskTimeoutMs,
1911
+ options?.sourceTaskId,
1912
+ options?.sourceTaskTitle,
1913
+ ),
1914
+ extraSystemPrompt: buildControllerIntakeSystemPrompt(deps),
1915
+ idempotencyKey: `${runResult.runId}:inactivity-probe:${inactivityState.probeCount}`,
1916
+ });
1917
+ const probeWait = await deps.runtime.subagent.waitForRun({
1918
+ runId: probeRun.runId,
1919
+ timeoutMs: CONTROLLER_INACTIVITY_PROBE_TIMEOUT_MS,
1920
+ });
1921
+
1922
+ if (probeWait.status === "error" && isRateLimitMessage(probeWait.error || "")) {
1923
+ await markRateLimitWaiting();
1924
+ return null;
1925
+ }
1926
+ if (probeWait.status !== "ok") {
1927
+ return null;
1928
+ }
1929
+
1930
+ const probeReply = await extractSessionAssistantReply();
1931
+ if (!probeReply || isRateLimitMessage(probeReply) || isStillWaitingResponse(probeReply)) {
1932
+ inactivityState.active = false;
1933
+ inactivityState.visibleAt = undefined;
1934
+ inactivityState.nextProbeAt = Date.now() + deps.config.taskTimeoutMs;
1935
+ recordControllerRunEvent(controllerRun.id, {
1936
+ type: "progress",
1937
+ phase: "inactivity_still_waiting",
1938
+ source: "controller",
1939
+ status: "running",
1940
+ sessionKey,
1941
+ runId: runResult.runId,
1942
+ message: "The controller workflow is still running without a final result yet. TeamClaw will keep waiting instead of failing the run.",
1943
+ }, deps);
1944
+ return null;
1945
+ }
1946
+
1947
+ if (rateLimitState.active) {
1948
+ clearRateLimitWaiting();
1949
+ }
1950
+ noteObservedControllerActivity();
1116
1951
  return probeReply;
1117
1952
  };
1118
1953
 
1119
1954
  let waitResult: Awaited<ReturnType<typeof deps.runtime.subagent.waitForRun>> = { status: "timeout" };
1120
1955
  let completionOverride: string | null = null;
1121
- const deadline = Date.now() + deps.config.taskTimeoutMs;
1122
1956
  while (true) {
1123
- const remainingMs = deadline - Date.now();
1124
- if (remainingMs <= 0) {
1125
- waitResult = { status: "timeout" };
1126
- break;
1127
- }
1128
-
1129
1957
  if (rateLimitState.active && (rateLimitState.nextProbeAt ?? Number.POSITIVE_INFINITY) <= Date.now()) {
1130
1958
  completionOverride = await probeRateLimitedControllerCompletion();
1131
1959
  if (completionOverride) {
@@ -1134,14 +1962,22 @@ async function runControllerIntakeUnlocked(
1134
1962
  }
1135
1963
  }
1136
1964
 
1137
- const sliceTimeoutMs = Math.max(1_000, Math.min(CONTROLLER_RUN_WAIT_SLICE_MS, remainingMs));
1965
+ if (!rateLimitState.active && (inactivityState.nextProbeAt ?? Number.POSITIVE_INFINITY) <= Date.now()) {
1966
+ completionOverride = await probeInactiveControllerCompletion();
1967
+ if (completionOverride) {
1968
+ waitResult = { status: "ok" };
1969
+ break;
1970
+ }
1971
+ }
1972
+
1138
1973
  waitResult = await deps.runtime.subagent.waitForRun({
1139
1974
  runId: runResult.runId,
1140
- timeoutMs: sliceTimeoutMs,
1975
+ timeoutMs: CONTROLLER_RUN_WAIT_SLICE_MS,
1141
1976
  });
1142
1977
 
1143
1978
  if (waitResult.status === "ok") {
1144
1979
  clearRateLimitWaiting();
1980
+ noteObservedControllerActivity();
1145
1981
  break;
1146
1982
  }
1147
1983
  if (waitResult.status === "error") {
@@ -1152,24 +1988,6 @@ async function runControllerIntakeUnlocked(
1152
1988
  break;
1153
1989
  }
1154
1990
  }
1155
-
1156
- if (waitResult.status === "timeout") {
1157
- const createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
1158
- updateControllerRun(controllerRun.id, deps, (run) => {
1159
- run.createdTaskIds = createdTaskIds;
1160
- run.error = "Controller intake timed out";
1161
- appendControllerRunEvent(run, {
1162
- type: "error",
1163
- phase: "timeout",
1164
- source: "controller",
1165
- status: "failed",
1166
- sessionKey,
1167
- runId: runResult.runId,
1168
- message: "Controller intake timed out.",
1169
- });
1170
- });
1171
- throw new Error("Controller intake timed out");
1172
- }
1173
1991
  if (waitResult.status !== "ok") {
1174
1992
  const errorMessage = waitResult.error || "Controller intake failed";
1175
1993
  const createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
@@ -1189,7 +2007,7 @@ async function runControllerIntakeUnlocked(
1189
2007
  throw new Error(errorMessage);
1190
2008
  }
1191
2009
 
1192
- const createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
2010
+ let createdTaskIds = tagControllerCreatedTasks(taskIdsBeforeRun, sessionKey, deps);
1193
2011
 
1194
2012
  const rawReply = completionOverride || await extractSessionAssistantReply()
1195
2013
  || "Controller completed the intake run but did not return any text.";
@@ -1201,7 +2019,88 @@ async function runControllerIntakeUnlocked(
1201
2019
  createdTaskIds,
1202
2020
  deps,
1203
2021
  );
2022
+ const fallbackTaskIds = await maybeBackfillExecutionReadyTask(
2023
+ controllerRun.id,
2024
+ sessionKey,
2025
+ message,
2026
+ recordedManifest,
2027
+ deps,
2028
+ { source: options?.source },
2029
+ );
2030
+ if (fallbackTaskIds.length > 0) {
2031
+ createdTaskIds = Array.from(new Set([...createdTaskIds, ...fallbackTaskIds]));
2032
+ }
2033
+ syncControllerRunClarifications(controllerRun.id, sessionKey, recordedManifest, deps);
1204
2034
  const reconciledTasks = reconcileControllerManifestTaskBindings(sessionKey, createdTaskIds, recordedManifest, deps);
2035
+
2036
+ // ── Automatic Team Kickoff ────────────────────────────────────────────
2037
+ // For complex projects (3+ roles), automatically run a kickoff meeting
2038
+ // so every candidate role can assess the requirement collaboratively.
2039
+ // This happens AFTER the initial LLM pass so we know which roles are
2040
+ // needed, but we store the assessments for visibility and future
2041
+ // refinement passes.
2042
+ const AUTO_KICKOFF_ROLE_THRESHOLD = 3;
2043
+ const isFollowUp = options?.source === "task_follow_up";
2044
+ const kickoffHandler = deps.getKickoffHandler?.();
2045
+ if (
2046
+ !isFollowUp
2047
+ && kickoffHandler
2048
+ && recordedManifest.requiredRoles.length >= AUTO_KICKOFF_ROLE_THRESHOLD
2049
+ && !recordedManifest.clarificationsNeeded
2050
+ ) {
2051
+ deps.logger.info(
2052
+ `Controller: auto-kickoff triggered for ${recordedManifest.requiredRoles.length} roles: ${recordedManifest.requiredRoles.join(", ")}`,
2053
+ );
2054
+ recordControllerRunEvent(controllerRun.id, {
2055
+ type: "lifecycle",
2056
+ phase: "kickoff_started",
2057
+ source: "controller",
2058
+ status: "running",
2059
+ sessionKey,
2060
+ message: `Auto-kickoff: provisioning ${recordedManifest.requiredRoles.length} candidate roles for team assessment.`,
2061
+ }, deps);
2062
+ try {
2063
+ const kickoffResult = await kickoffHandler(
2064
+ recordedManifest.requiredRoles as RoleId[],
2065
+ recordedManifest.requiredRoles.length >= 5 ? "complex" : "medium",
2066
+ message,
2067
+ );
2068
+ // Store kickoff assessments on the manifest for UI/API visibility
2069
+ recordedManifest.kickoffPlan = {
2070
+ assessments: kickoffResult.assessments,
2071
+ summary: kickoffResult.summary,
2072
+ triggeredAt: Date.now(),
2073
+ };
2074
+ updateControllerRun(controllerRun.id, deps, (run) => {
2075
+ if (run.manifest) {
2076
+ run.manifest.kickoffPlan = recordedManifest.kickoffPlan;
2077
+ }
2078
+ appendControllerRunEvent(run, {
2079
+ type: "lifecycle",
2080
+ phase: "kickoff_completed",
2081
+ source: "controller",
2082
+ status: "running",
2083
+ sessionKey,
2084
+ message: `Team kickoff completed: ${kickoffResult.assessments.length} assessments collected. ${kickoffResult.summary.slice(0, 200)}`,
2085
+ });
2086
+ });
2087
+ deps.logger.info(
2088
+ `Controller: auto-kickoff completed with ${kickoffResult.assessments.length} assessments`,
2089
+ );
2090
+ } catch (err) {
2091
+ const errMsg = err instanceof Error ? err.message : String(err);
2092
+ deps.logger.warn(`Controller: auto-kickoff failed: ${errMsg}`);
2093
+ recordControllerRunEvent(controllerRun.id, {
2094
+ type: "lifecycle",
2095
+ phase: "kickoff_failed",
2096
+ source: "controller",
2097
+ status: "running",
2098
+ sessionKey,
2099
+ message: `Auto-kickoff failed: ${errMsg}. Proceeding with controller-only planning.`,
2100
+ }, deps);
2101
+ }
2102
+ }
2103
+
1205
2104
  const latestTeamState = deps.getTeamState();
1206
2105
  const reply = buildControllerManifestReply(recordedManifest, reconciledTasks.taskIds, latestTeamState, rawReply);
1207
2106
 
@@ -1283,8 +2182,15 @@ function buildRecentCompletedTaskContext(task: TaskInfo, state: TeamState | null
1283
2182
  return "";
1284
2183
  }
1285
2184
 
2185
+ // Only include tasks from the same session/project to avoid cross-project contamination.
2186
+ // Tasks sharing the same controllerSessionKey belong to the same user requirement.
2187
+ const sameSession = task.controllerSessionKey
2188
+ ? (candidate: TaskInfo) => candidate.controllerSessionKey === task.controllerSessionKey
2189
+ : () => true; // If no session key, fall back to all completed tasks (legacy behavior)
2190
+
1286
2191
  const recentCompletedTasks = Object.values(state.tasks)
1287
2192
  .filter((candidate) => candidate.id !== task.id && candidate.status === "completed")
2193
+ .filter(sameSession)
1288
2194
  .filter((candidate) => (candidate.completedAt ?? candidate.updatedAt) <= task.createdAt)
1289
2195
  .sort((a, b) => (b.completedAt ?? b.updatedAt) - (a.completedAt ?? a.updatedAt))
1290
2196
  .slice(0, MAX_RECENT_TASK_CONTEXT)
@@ -1325,10 +2231,18 @@ function buildRecommendedSkillsContext(task: TaskInfo): string {
1325
2231
 
1326
2232
  function buildTaskAssignmentDescription(task: TaskInfo, state: TeamState | null, repoInfo?: RepoSyncInfo): string {
1327
2233
  const parts = [task.description];
2234
+ const projectContext = buildProjectDirectoryContext(task.projectDir);
2235
+ if (projectContext) {
2236
+ parts.push("", projectContext);
2237
+ }
1328
2238
  const recommendedSkillsContext = buildRecommendedSkillsContext(task);
1329
2239
  if (recommendedSkillsContext) {
1330
2240
  parts.push("", recommendedSkillsContext);
1331
2241
  }
2242
+ const patternsContext = buildConsolidatedPatternsContext();
2243
+ if (patternsContext) {
2244
+ parts.push("", patternsContext);
2245
+ }
1332
2246
  const recentContext = buildRecentCompletedTaskContext(task, state);
1333
2247
  if (recentContext) {
1334
2248
  parts.push("", recentContext);
@@ -1339,6 +2253,21 @@ function buildTaskAssignmentDescription(task: TaskInfo, state: TeamState | null,
1339
2253
  return parts.join("\n");
1340
2254
  }
1341
2255
 
2256
+ function buildProjectDirectoryContext(projectDir?: string): string | null {
2257
+ if (!projectDir) {
2258
+ return null;
2259
+ }
2260
+ const workspaceRelativePath = buildTeamClawProjectWorkspacePath(projectDir);
2261
+ const absoluteProjectPath = path.join(resolveTeamClawProjectsDir(), projectDir).replace(/\\/gu, "/");
2262
+ return [
2263
+ "## Authoritative Project Paths",
2264
+ `- Workspace-relative project path: \`${workspaceRelativePath}/\``,
2265
+ `- Absolute project path: \`${absoluteProjectPath}/\``,
2266
+ "- If the task description mentions any other workspace path, treat it as stale text from an older layout.",
2267
+ "- Resolve project-local files (for example `ARCHITECTURE.md`, `README.md`, `package.json`, `data/...`) inside the authoritative project path above.",
2268
+ ].join("\n");
2269
+ }
2270
+
1342
2271
  function buildRepoTaskContext(repoInfo: RepoSyncInfo): string {
1343
2272
  const lines = [
1344
2273
  "## TeamClaw Git Collaboration",
@@ -1465,20 +2394,16 @@ async function cancelTaskExecution(
1465
2394
  }
1466
2395
 
1467
2396
  let cancelled = false;
1468
- if (deps.localWorkerManager?.isLocalWorkerId(workerId)) {
1469
- cancelled = await deps.localWorkerManager.cancelTaskExecution(workerId, taskId);
1470
- } else {
1471
- try {
1472
- const res = await fetch(`${worker.url}/api/v1/tasks/${taskId}/cancel`, {
1473
- method: "POST",
1474
- });
1475
- cancelled = res.ok;
1476
- if (!res.ok) {
1477
- deps.logger.warn(`Controller: worker cancel failed for ${taskId} on ${workerId} (${res.status})`);
1478
- }
1479
- } catch (err) {
1480
- deps.logger.warn(`Controller: failed to cancel task ${taskId} on ${workerId}: ${String(err)}`);
2397
+ try {
2398
+ const res = await fetch(`${worker.url}/api/v1/tasks/${taskId}/cancel`, {
2399
+ method: "POST",
2400
+ });
2401
+ cancelled = res.ok;
2402
+ if (!res.ok) {
2403
+ deps.logger.warn(`Controller: worker cancel failed for ${taskId} on ${workerId} (${res.status})`);
1481
2404
  }
2405
+ } catch (err) {
2406
+ deps.logger.warn(`Controller: failed to cancel task ${taskId} on ${workerId}: ${String(err)}`);
1482
2407
  }
1483
2408
 
1484
2409
  if (!cancelled) {
@@ -1511,11 +2436,308 @@ function workspaceRequestErrorStatus(err: unknown): number {
1511
2436
  if (err && typeof err === "object" && "code" in err && (err as { code?: unknown }).code === "ENOENT") {
1512
2437
  return 404;
1513
2438
  }
1514
- return 400;
2439
+ return 400;
2440
+ }
2441
+
2442
+ function workspaceRequestErrorMessage(err: unknown): string {
2443
+ return err instanceof Error ? err.message : "Workspace request failed";
2444
+ }
2445
+
2446
+ function getEffectiveStartupReadiness(
2447
+ deps: ControllerHttpDeps,
2448
+ state: TeamState | null,
2449
+ ): StartupProvisioningReadiness | null {
2450
+ if (!deps.workerProvisioningManager?.isEnabled()) {
2451
+ return null;
2452
+ }
2453
+ const requiredRoles = deps.config.workerProvisioningRoles.length > 0
2454
+ ? [...new Set(deps.config.workerProvisioningRoles)]
2455
+ : ["developer"];
2456
+ const readyWorkerIds = requiredRoles
2457
+ .map((role) => Object.values(state?.workers ?? {}).find((worker) =>
2458
+ worker.role === role && (worker.status === "idle" || worker.status === "busy")
2459
+ )?.id)
2460
+ .filter((workerId): workerId is string => Boolean(workerId));
2461
+
2462
+ if (readyWorkerIds.length === requiredRoles.length) {
2463
+ const recorded = state?.provisioning?.startupReadiness;
2464
+ return {
2465
+ status: "ready",
2466
+ startedAt: recorded?.startedAt ?? state?.createdAt ?? Date.now(),
2467
+ checkedAt: Date.now(),
2468
+ attempts: recorded?.attempts ?? 0,
2469
+ requiredRoles,
2470
+ readyWorkerIds,
2471
+ message: recorded?.status === "ready"
2472
+ ? recorded.message
2473
+ : `Startup provisioning ready with ${readyWorkerIds.length} warm worker(s).`,
2474
+ };
2475
+ }
2476
+
2477
+ return state?.provisioning?.startupReadiness ?? {
2478
+ status: "checking",
2479
+ startedAt: state?.createdAt ?? Date.now(),
2480
+ checkedAt: Date.now(),
2481
+ attempts: 0,
2482
+ requiredRoles,
2483
+ readyWorkerIds,
2484
+ message: `Startup provisioning warm-up is still initializing for ${requiredRoles.join(", ")}.`,
2485
+ };
2486
+ }
2487
+
2488
+ function buildStartupReadinessMessage(readiness: StartupProvisioningReadiness): string {
2489
+ const requiredRoles = readiness.requiredRoles.length > 0 ? readiness.requiredRoles.join(", ") : "configured startup roles";
2490
+ if (readiness.status === "ready") {
2491
+ return readiness.message ?? "Startup provisioning is ready.";
2492
+ }
2493
+ if (readiness.status === "checking") {
2494
+ return readiness.message ?? `Startup provisioning is still warming workers for ${requiredRoles}.`;
2495
+ }
2496
+ return readiness.message ?? `Startup provisioning is degraded for ${requiredRoles}. Check provisioning logs and fix worker startup before retrying.`;
2497
+ }
2498
+
2499
+ function shouldBlockControllerUntilProvisioningReady(
2500
+ deps: ControllerHttpDeps,
2501
+ state: TeamState | null,
2502
+ ): { blocked: boolean; readiness: StartupProvisioningReadiness | null } {
2503
+ const readiness = getEffectiveStartupReadiness(deps, state);
2504
+ if (!readiness) {
2505
+ return { blocked: false, readiness: null };
2506
+ }
2507
+ return {
2508
+ blocked: readiness.status !== "ready",
2509
+ readiness,
2510
+ };
2511
+ }
2512
+
2513
+ /**
2514
+ * Filter deliverables that don't belong to the current task's project directory.
2515
+ * This prevents cross-project contamination when the model references stale files
2516
+ * from previous sessions visible in the shared workspace.
2517
+ */
2518
+ function filterStaleDeliverables(
2519
+ contract: WorkerTaskResultContract,
2520
+ taskProjectDir: string | undefined,
2521
+ ): WorkerTaskResultContract {
2522
+ if (!taskProjectDir) return contract;
2523
+ // Normalize: "projects/foo-bar/" → "projects/foo-bar"
2524
+ const normalizedDir = taskProjectDir.replace(/\/$/u, "");
2525
+ const filtered = contract.deliverables.filter((d) => {
2526
+ const val = (d.value ?? "").replace(/\/$/u, "");
2527
+ if (!val) return true; // keep notes/commands without paths
2528
+ if (d.kind === "note" || d.kind === "command") return true;
2529
+ // Accept deliverables whose path contains the projectDir or is rooted in teamclaw/projects/{projectDir}
2530
+ if (val.includes(normalizedDir)) return true;
2531
+ // Accept relative paths that look like project-internal (no slashes or relative)
2532
+ if (!val.includes("/") || val.startsWith("./")) return true;
2533
+ // Reject paths from other project directories
2534
+ return false;
2535
+ });
2536
+ if (filtered.length === contract.deliverables.length) return contract;
2537
+ return { ...contract, deliverables: filtered };
2538
+ }
2539
+
2540
+ /**
2541
+ * Strategy 4: When text-based enrichment found no HTML file references,
2542
+ * scan the workspace filesystem for HTML files and create a web-app deliverable.
2543
+ * This handles workers that return abstract summaries without mentioning specific paths.
2544
+ */
2545
+ function enrichWithFilesystemHtmlScan(
2546
+ contract: WorkerTaskResultContract,
2547
+ taskProjectDir?: string,
2548
+ ): WorkerTaskResultContract | null {
2549
+ const existingWebApp = contract.deliverables.find((d) => d.artifactType === "web-app");
2550
+ if (existingWebApp) {
2551
+ const cwd = existingWebApp.previewCwd?.trim();
2552
+ if (cwd && cwd !== "." && cwd !== "./") {
2553
+ return null;
2554
+ }
2555
+ // Fall through — existing web-app has root previewCwd, try to improve it
2556
+ }
2557
+
2558
+ let workspaceDir: string;
2559
+ let openclawWorkspaceDir: string;
2560
+ try {
2561
+ workspaceDir = resolveTeamClawWorkspaceDir();
2562
+ openclawWorkspaceDir = resolveTeamClawAgentWorkspaceRootDir();
2563
+ } catch {
2564
+ return null;
2565
+ }
2566
+
2567
+ // Recursively scan the workspace for HTML files (up to 3 levels deep)
2568
+ const MAX_DEPTH = 3;
2569
+ const htmlCandidates: { dirPath: string; filename: string }[] = [];
2570
+
2571
+ function scanDir(dir: string, depth: number) {
2572
+ if (depth > MAX_DEPTH) return;
2573
+ let entries: fs.Dirent[];
2574
+ try {
2575
+ entries = fs.readdirSync(dir, { withFileTypes: true });
2576
+ } catch {
2577
+ return;
2578
+ }
2579
+ for (const entry of entries) {
2580
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === ".git") continue;
2581
+ const fullPath = path.join(dir, entry.name);
2582
+ if (entry.isDirectory()) {
2583
+ scanDir(fullPath, depth + 1);
2584
+ } else if (entry.isFile() && (entry.name.endsWith(".html") || entry.name.endsWith(".htm"))) {
2585
+ if (!entry.name.includes(".config.") && !entry.name.includes(".test.") && !entry.name.includes(".spec.")) {
2586
+ htmlCandidates.push({ dirPath: dir, filename: entry.name });
2587
+ }
2588
+ }
2589
+ }
2590
+ }
2591
+
2592
+ // Scope scan to the task's project directory when available to avoid
2593
+ // picking up stale HTML from unrelated projects.
2594
+ const scanRoot = taskProjectDir
2595
+ ? path.join(workspaceDir, taskProjectDir)
2596
+ : workspaceDir;
2597
+ if (taskProjectDir && !fs.existsSync(scanRoot)) {
2598
+ return null;
2599
+ }
2600
+ scanDir(scanRoot, 0);
2601
+
2602
+ if (htmlCandidates.length === 0) {
2603
+ return null;
2604
+ }
2605
+
2606
+ // Use OpenClaw workspace root as base for relative paths (worker paths and
2607
+ // preview manager both resolve relative to the OpenClaw workspace, not the
2608
+ // TeamClaw subdirectory).
2609
+ const candidate = htmlCandidates[0];
2610
+ const relativeDir = path.relative(openclawWorkspaceDir, candidate.dirPath) || ".";
2611
+ const normalizedDir = relativeDir.replace(/\\/gu, "/");
2612
+
2613
+ // Avoid adding a duplicate if a directory deliverable for this path already exists
2614
+ const existingDir = contract.deliverables.find(
2615
+ (d) => d.kind === "directory" && d.value.replace(/\\/gu, "/").replace(/\/$/u, "") === normalizedDir,
2616
+ );
2617
+ if (existingDir && existingDir.artifactType === "web-app" && existingWebApp?.previewCwd?.trim() !== ".") {
2618
+ // Existing web-app already has a specific, non-root directory — leave it alone.
2619
+ return null;
2620
+ }
2621
+
2622
+ const newDeliverable: WorkerTaskResultDeliverable = {
2623
+ kind: "directory",
2624
+ value: normalizedDir,
2625
+ summary: `Web application at ${normalizedDir}`,
2626
+ artifactType: "web-app",
2627
+ previewCommand: "npx -y serve -l {PORT}",
2628
+ previewCwd: normalizedDir,
2629
+ previewReadyPath: "/",
2630
+ };
2631
+
2632
+ const newDeliverables = [...contract.deliverables];
2633
+ if (existingDir) {
2634
+ // Update existing directory deliverable with web-app fields
2635
+ const idx = newDeliverables.indexOf(existingDir);
2636
+ // Always take the filesystem scan's directory path, which is more specific
2637
+ newDeliverables[idx] = { ...existingDir, ...newDeliverable };
2638
+ } else {
2639
+ newDeliverables.push(newDeliverable);
2640
+ }
2641
+
2642
+ return { ...contract, deliverables: newDeliverables };
2643
+ }
2644
+
2645
+ const MEANINGFUL_PROJECT_CHANGE_EXTENSIONS = new Set([
2646
+ ".js", ".jsx", ".ts", ".tsx", ".json", ".html", ".css", ".scss", ".md", ".txt", ".yml", ".yaml",
2647
+ ]);
2648
+
2649
+ const IGNORED_PROJECT_CHANGE_DIRS = new Set([
2650
+ "node_modules", ".git", "dist", "build", ".next", ".cache", "coverage", "tmp", "temp",
2651
+ ]);
2652
+
2653
+ const IGNORED_PROJECT_CHANGE_FILES = new Set([
2654
+ "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
2655
+ ]);
2656
+
2657
+ function taskRequiresMeaningfulProjectChangeGate(task: TaskInfo): boolean {
2658
+ if (task.assignedRole !== "developer" || !task.projectDir) {
2659
+ return false;
2660
+ }
2661
+ const text = `${task.title}\n${task.description}`.toLowerCase();
2662
+ if (/\b(qa|audit|review|verify|verification|validate|test|retest|confirm|check)\b/u.test(text)) {
2663
+ return false;
2664
+ }
2665
+ if (/(审计|审核|验证|复检|复查|确认|测试|检查)/u.test(text)) {
2666
+ return false;
2667
+ }
2668
+ return /\b(implement|build|fix|rework|update|add|enhanc|deliver|write|create)\b/u.test(text);
2669
+ }
2670
+
2671
+ function projectHasMeaningfulFileChanges(task: TaskInfo): boolean {
2672
+ if (!task.projectDir) {
2673
+ return false;
2674
+ }
2675
+ const projectRoot = path.join(resolveTeamClawProjectsDir(), task.projectDir);
2676
+ if (!fs.existsSync(projectRoot)) {
2677
+ return false;
2678
+ }
2679
+ const threshold = Math.max(task.startedAt ?? 0, task.createdAt ?? 0) - 1000;
2680
+ const stack = [projectRoot];
2681
+ while (stack.length > 0) {
2682
+ const currentDir = stack.pop()!;
2683
+ let entries: fs.Dirent[];
2684
+ try {
2685
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
2686
+ } catch {
2687
+ continue;
2688
+ }
2689
+ for (const entry of entries) {
2690
+ if (entry.isDirectory()) {
2691
+ if (!IGNORED_PROJECT_CHANGE_DIRS.has(entry.name)) {
2692
+ stack.push(path.join(currentDir, entry.name));
2693
+ }
2694
+ continue;
2695
+ }
2696
+ if (!entry.isFile()) {
2697
+ continue;
2698
+ }
2699
+ if (IGNORED_PROJECT_CHANGE_FILES.has(entry.name)) {
2700
+ continue;
2701
+ }
2702
+ const ext = path.extname(entry.name).toLowerCase();
2703
+ if (!MEANINGFUL_PROJECT_CHANGE_EXTENSIONS.has(ext)) {
2704
+ continue;
2705
+ }
2706
+ const fullPath = path.join(currentDir, entry.name);
2707
+ try {
2708
+ const stats = fs.statSync(fullPath);
2709
+ if (stats.mtimeMs >= threshold) {
2710
+ return true;
2711
+ }
2712
+ } catch {
2713
+ continue;
2714
+ }
2715
+ }
2716
+ }
2717
+ return false;
1515
2718
  }
1516
2719
 
1517
- function workspaceRequestErrorMessage(err: unknown): string {
1518
- return err instanceof Error ? err.message : "Workspace request failed";
2720
+ function allowsNoChangeCompletion(
2721
+ task: TaskInfo,
2722
+ contract: WorkerTaskResultContract | undefined,
2723
+ resultText: string,
2724
+ ): boolean {
2725
+ const taskText = `${task.title}\n${task.description}`.toLowerCase();
2726
+ const evidenceText = [
2727
+ contract?.summary ?? "",
2728
+ contract?.notes ?? "",
2729
+ ...(contract?.keyPoints ?? []),
2730
+ resultText,
2731
+ ].join("\n").toLowerCase();
2732
+ const hasVerificationEvidence =
2733
+ (contract?.deliverables ?? []).some((deliverable) => deliverable.kind === "command" || deliverable.kind === "note")
2734
+ || /\b(go test|go vet|npm test|pnpm test|yarn test|pytest|cargo test|verified|verification|passes|all tests pass|no remaining)\b/u.test(evidenceText);
2735
+ const noChangeIsExpected =
2736
+ /\b(qa|audit|review|verify|verification|validate|test|retest|confirm|check)\b/u.test(taskText)
2737
+ || /(审计|审核|验证|复检|复查|确认|测试|检查)/u.test(taskText)
2738
+ || /\b(already fixed|already resolved|no further changes|no code changes|no file changes|no additional changes|verified existing fix)\b/u.test(evidenceText)
2739
+ || /(已修复|已解决|无需改动|无需修改|没有额外修改|无需额外变更|只需验证)/u.test(evidenceText);
2740
+ return hasVerificationEvidence && noChangeIsExpected;
1519
2741
  }
1520
2742
 
1521
2743
  function applyTaskResult(
@@ -1536,6 +2758,19 @@ function applyTaskResult(
1536
2758
  task.error = error;
1537
2759
  task.completedAt = Date.now();
1538
2760
  task.updatedAt = Date.now();
2761
+ // Re-enrich deliverables now that the full result text is available.
2762
+ if (!error && task.resultContract) {
2763
+ // Filter out stale deliverables from other projects first
2764
+ task.resultContract = filterStaleDeliverables(task.resultContract, task.projectDir);
2765
+ let enriched = enrichDeliverablesWithPreviewInference(task.resultContract, result);
2766
+ if (!enriched) {
2767
+ // Text-based enrichment failed — scan the workspace filesystem for HTML files.
2768
+ enriched = enrichWithFilesystemHtmlScan(task.resultContract, task.projectDir);
2769
+ }
2770
+ if (enriched) {
2771
+ task.resultContract = enriched;
2772
+ }
2773
+ }
1539
2774
  completionEvent = appendTaskExecutionEvent(task, {
1540
2775
  type: error ? "error" : "lifecycle",
1541
2776
  phase: error ? "result_failed" : "result_completed",
@@ -1583,13 +2818,111 @@ function applyTaskResult(
1583
2818
  logger.warn(
1584
2819
  `Controller: failed to continue intake workflow after ${taskId}: ${String(err)}`,
1585
2820
  );
2821
+ }).finally(() => {
2822
+ // After the follow-up run completes (or if there was none), check session completion.
2823
+ void checkAndGenerateReport(updatedTask, deps).catch(() => {});
1586
2824
  });
2825
+ } else if (updatedTask.controllerSessionKey) {
2826
+ // Non-controller tasks or failed tasks — still check session completion.
2827
+ void checkAndGenerateReport(updatedTask, deps).catch(() => {});
1587
2828
  }
1588
2829
  }
1589
2830
 
1590
2831
  return updatedTask;
1591
2832
  }
1592
2833
 
2834
+ async function requestTaskClarification(params: {
2835
+ taskId: string;
2836
+ requestedBy: string;
2837
+ requestedByWorkerId?: string;
2838
+ requestedByRole?: RoleId;
2839
+ question: string;
2840
+ blockingReason: string;
2841
+ context?: string;
2842
+ questionSchema?: ClarificationRequest["questionSchema"];
2843
+ }, deps: ControllerHttpDeps): Promise<{
2844
+ status: "created" | "already-pending" | "conflict" | "missing-task";
2845
+ clarification?: ClarificationRequest;
2846
+ task?: TaskInfo;
2847
+ }> {
2848
+ const { getTeamState, updateTeamState, wsServer } = deps;
2849
+ const currentState = getTeamState();
2850
+ const currentTask = currentState?.tasks[params.taskId];
2851
+ if (!currentTask) {
2852
+ return { status: "missing-task" };
2853
+ }
2854
+
2855
+ if (currentTask.clarificationRequestId) {
2856
+ const existing = currentState?.clarifications[currentTask.clarificationRequestId];
2857
+ if (existing?.status === "pending") {
2858
+ return { status: "already-pending", clarification: existing, task: currentTask };
2859
+ }
2860
+ }
2861
+
2862
+ if (currentTask.status === "completed" || currentTask.status === "failed") {
2863
+ return { status: "conflict", task: currentTask };
2864
+ }
2865
+
2866
+ const previousWorkerId = currentTask.assignedWorkerId;
2867
+ const clarificationId = generateId();
2868
+ const now = Date.now();
2869
+ const clarification: ClarificationRequest = {
2870
+ id: clarificationId,
2871
+ taskId: params.taskId,
2872
+ requestedBy: params.requestedBy,
2873
+ requestedByWorkerId: params.requestedByWorkerId,
2874
+ requestedByRole: params.requestedByRole,
2875
+ question: params.question,
2876
+ questionSchema: params.questionSchema,
2877
+ blockingReason: params.blockingReason,
2878
+ context: params.context,
2879
+ status: "pending",
2880
+ createdAt: now,
2881
+ updatedAt: now,
2882
+ };
2883
+
2884
+ const state = updateTeamState((s) => {
2885
+ s.clarifications[clarificationId] = clarification;
2886
+ const task = s.tasks[params.taskId];
2887
+ if (!task) {
2888
+ return;
2889
+ }
2890
+
2891
+ const assignedWorkerId = task.assignedWorkerId;
2892
+ task.status = "blocked";
2893
+ task.progress = `Awaiting clarification: ${params.question}`;
2894
+ task.clarificationRequestId = clarificationId;
2895
+ task.assignedWorkerId = undefined;
2896
+ task.updatedAt = now;
2897
+
2898
+ if (assignedWorkerId && s.workers[assignedWorkerId]) {
2899
+ const assignedWorker = s.workers[assignedWorkerId];
2900
+ if (assignedWorker.status !== "offline") {
2901
+ assignedWorker.status = "idle";
2902
+ }
2903
+ assignedWorker.currentTaskId = undefined;
2904
+ }
2905
+ });
2906
+
2907
+ await cancelTaskExecution(params.taskId, previousWorkerId, "clarification request", deps);
2908
+
2909
+ const updatedTask = state.tasks[params.taskId];
2910
+ wsServer.broadcastUpdate({ type: "clarification:requested", data: clarification });
2911
+ if (updatedTask) {
2912
+ recordTaskExecutionEvent(params.taskId, {
2913
+ type: "lifecycle",
2914
+ phase: "clarification_requested",
2915
+ source: "controller",
2916
+ message: `Clarification requested: ${params.question}`,
2917
+ role: clarification.requestedByRole,
2918
+ workerId: clarification.requestedByWorkerId,
2919
+ }, deps);
2920
+ wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
2921
+ }
2922
+
2923
+ return { status: "created", clarification, task: updatedTask };
2924
+ }
2925
+
1593
2926
  function ensureTaskResultContract(
1594
2927
  taskId: string,
1595
2928
  result: string,
@@ -1602,10 +2935,35 @@ function ensureTaskResultContract(
1602
2935
  return undefined;
1603
2936
  }
1604
2937
  if (currentTask.resultContract) {
2938
+ // Worker submitted a structured contract — but it may be missing preview
2939
+ // fields (artifactType, previewCommand, etc.). Enrich deliverables
2940
+ // so the PreviewManager can auto-launch previews.
2941
+ let enriched = enrichDeliverablesWithPreviewInference(currentTask.resultContract, result);
2942
+ if (!enriched) {
2943
+ // Text-based enrichment failed — scan the workspace filesystem for HTML files.
2944
+ enriched = enrichWithFilesystemHtmlScan(currentTask.resultContract, currentTask.projectDir);
2945
+ }
2946
+ if (enriched) {
2947
+ deps.updateTeamState((teamState) => {
2948
+ const task = teamState.tasks[taskId];
2949
+ if (task) {
2950
+ task.resultContract = enriched;
2951
+ task.updatedAt = Date.now();
2952
+ }
2953
+ });
2954
+ return enriched;
2955
+ }
1605
2956
  return currentTask.resultContract;
1606
2957
  }
1607
2958
 
1608
- const contract = backfillWorkerTaskResultContract(currentTask, result, error);
2959
+ let contract = backfillWorkerTaskResultContract(currentTask, result, error);
2960
+ if (!error) {
2961
+ const enriched = enrichDeliverablesWithPreviewInference(contract, result)
2962
+ ?? enrichWithFilesystemHtmlScan(contract, currentTask.projectDir);
2963
+ if (enriched) {
2964
+ contract = enriched;
2965
+ }
2966
+ }
1609
2967
  deps.updateTeamState((teamState) => {
1610
2968
  const task = teamState.tasks[taskId];
1611
2969
  if (!task || task.resultContract) {
@@ -1670,9 +3028,78 @@ function buildResultContractSection(task: TaskInfo): string {
1670
3028
  if (contract.notes) {
1671
3029
  lines.push(`Notes: ${contract.notes}`);
1672
3030
  }
3031
+ if (contract.discoveredPatterns && contract.discoveredPatterns.length > 0) {
3032
+ lines.push("Discovered patterns:");
3033
+ for (const pattern of contract.discoveredPatterns) {
3034
+ lines.push(`- ${pattern}`);
3035
+ }
3036
+ }
1673
3037
  return lines.join("\n");
1674
3038
  }
1675
3039
 
3040
+ const MAX_PATTERNS_FILE_BYTES = 64 * 1024;
3041
+
3042
+ function consolidateDiscoveredPatterns(
3043
+ contract: WorkerTaskResultContract,
3044
+ taskId: string,
3045
+ role: string | undefined,
3046
+ logger: PluginLogger,
3047
+ ): void {
3048
+ const patterns = contract.discoveredPatterns;
3049
+ if (!patterns || patterns.length === 0) {
3050
+ return;
3051
+ }
3052
+ try {
3053
+ const workspaceDir = resolveTeamClawWorkspaceDir();
3054
+ const patternsFile = path.join(workspaceDir, "memory", "patterns.md");
3055
+ fs.mkdirSync(path.join(workspaceDir, "memory"), { recursive: true });
3056
+
3057
+ let currentSize = 0;
3058
+ try {
3059
+ currentSize = fs.statSync(patternsFile).size;
3060
+ } catch {
3061
+ // File doesn't exist yet; will be created by append.
3062
+ }
3063
+ if (currentSize > MAX_PATTERNS_FILE_BYTES) {
3064
+ logger.info(`Controller: patterns.md already ${currentSize} bytes, skipping consolidation for ${taskId}`);
3065
+ return;
3066
+ }
3067
+
3068
+ const roleLabel = role ?? "unknown";
3069
+ const timestamp = new Date().toISOString().slice(0, 10);
3070
+ const section = [
3071
+ "",
3072
+ `## ${timestamp} — ${taskId} (${roleLabel})`,
3073
+ ...patterns.map((p) => `- ${p}`),
3074
+ ].join("\n") + "\n";
3075
+
3076
+ fs.appendFileSync(patternsFile, section, "utf8");
3077
+ logger.info(`Controller: consolidated ${patterns.length} pattern(s) from ${taskId} into patterns.md`);
3078
+ } catch (err) {
3079
+ logger.warn(`Controller: failed to consolidate patterns for ${taskId}: ${err instanceof Error ? err.message : String(err)}`);
3080
+ }
3081
+ }
3082
+
3083
+ function buildConsolidatedPatternsContext(): string {
3084
+ try {
3085
+ const workspaceDir = resolveTeamClawWorkspaceDir();
3086
+ const patternsFile = path.join(workspaceDir, "memory", "patterns.md");
3087
+ const content = fs.readFileSync(patternsFile, "utf8").trim();
3088
+ // Only inject if there are actual patterns (more than just the header)
3089
+ const lines = content.split("\n").filter((l: string) => l.startsWith("- "));
3090
+ if (lines.length === 0) {
3091
+ return "";
3092
+ }
3093
+ return [
3094
+ "## Discovered Codebase Patterns",
3095
+ "Previous workers discovered these reusable patterns. Apply them where relevant:",
3096
+ ...lines,
3097
+ ].join("\n");
3098
+ } catch {
3099
+ return "";
3100
+ }
3101
+ }
3102
+
1676
3103
  function revertTaskAssignment(taskId: string, workerId: string, deps: ControllerHttpDeps): TaskInfo | undefined {
1677
3104
  const { updateTeamState, wsServer } = deps;
1678
3105
  let revertEvent: TaskExecutionEvent | undefined;
@@ -1702,6 +3129,7 @@ function revertTaskAssignment(taskId: string, workerId: string, deps: Controller
1702
3129
  }
1703
3130
  worker.currentTaskId = undefined;
1704
3131
  }
3132
+
1705
3133
  });
1706
3134
 
1707
3135
  const updatedTask = state.tasks[taskId];
@@ -1723,17 +3151,6 @@ async function deliverMessageToWorker(
1723
3151
  message: TeamMessage,
1724
3152
  deps: ControllerHttpDeps,
1725
3153
  ): Promise<void> {
1726
- const { localWorkerManager } = deps;
1727
-
1728
- if (localWorkerManager?.isLocalWorkerId(worker.id)) {
1729
- const queued = await localWorkerManager.queueMessage(worker.id, message);
1730
- if (queued) {
1731
- return;
1732
- }
1733
-
1734
- deps.logger.warn(`Controller: local message path unavailable for ${worker.id}, falling back to worker URL`);
1735
- }
1736
-
1737
3154
  const res = await fetch(`${worker.url}/api/v1/messages`, {
1738
3155
  method: "POST",
1739
3156
  headers: { "Content-Type": "application/json" },
@@ -1774,14 +3191,21 @@ async function dispatchTaskToWorker(
1774
3191
  worker: WorkerInfo,
1775
3192
  deps: ControllerHttpDeps,
1776
3193
  ): Promise<void> {
1777
- const { getTeamState, localWorkerManager } = deps;
3194
+ const { getTeamState } = deps;
1778
3195
  const state = getTeamState();
1779
3196
  const task = state?.tasks[taskId];
1780
3197
  if (!task) {
1781
3198
  throw new Error(`task ${taskId} not found`);
1782
3199
  }
1783
3200
 
1784
- const sharedWorkspace = localWorkerManager?.isLocalWorkerId(worker.id) ?? false;
3201
+ // Ensure project directory exists
3202
+ if (task.projectDir) {
3203
+ const projectPath = path.join(resolveTeamClawProjectsDir(), task.projectDir);
3204
+ try { fs.mkdirSync(projectPath, { recursive: true }); } catch { /* best-effort */ }
3205
+ }
3206
+
3207
+ const sharedWorkspace = deps.workerProvisioningManager?.isSharedWorkspaceWorker(worker.id)
3208
+ || false;
1785
3209
  const repoState = await refreshControllerRepoState(deps);
1786
3210
  const repoInfo = buildRepoSyncInfo(repoState, sharedWorkspace);
1787
3211
  const description = buildTaskAssignmentDescription(task, state ?? null, repoInfo);
@@ -1793,20 +3217,12 @@ async function dispatchTaskToWorker(
1793
3217
  description,
1794
3218
  priority: task.priority,
1795
3219
  recommendedSkills,
3220
+ projectDir: task.projectDir,
1796
3221
  executionSessionKey: executionIdentity.executionSessionKey,
1797
3222
  executionIdempotencyKey: executionIdentity.executionIdempotencyKey,
1798
3223
  repo: repoInfo,
1799
3224
  };
1800
3225
 
1801
- if (localWorkerManager?.isLocalWorkerId(worker.id)) {
1802
- const accepted = await localWorkerManager.dispatchTask(worker.id, assignment);
1803
- if (accepted) {
1804
- return;
1805
- }
1806
-
1807
- deps.logger.warn(`Controller: local dispatch path unavailable for ${worker.id}, falling back to worker URL`);
1808
- }
1809
-
1810
3226
  const res = await fetch(`${worker.url}/api/v1/tasks/assign`, {
1811
3227
  method: "POST",
1812
3228
  headers: { "Content-Type": "application/json" },
@@ -1903,12 +3319,21 @@ async function autoAssignPendingTasks(
1903
3319
  break;
1904
3320
  }
1905
3321
 
1906
- const nextAssignment = taskRouter
3322
+ const candidateAssignments = taskRouter
1907
3323
  .autoAssignPendingTasks(state.tasks, state.workers)
1908
- .filter(({ worker }) => !preferredWorkerId || worker.id === preferredWorkerId)
1909
- .find(({ task, worker }) => !attemptedPairs.has(`${task.id}:${worker.id}`));
3324
+ .filter(({ task, worker }) => !attemptedPairs.has(`${task.id}:${worker.id}`));
3325
+
3326
+ const nextAssignment = (
3327
+ (preferredWorkerId
3328
+ ? candidateAssignments.find(({ worker }) => worker.id === preferredWorkerId)
3329
+ : undefined)
3330
+ ?? candidateAssignments[0]
3331
+ );
1910
3332
 
1911
3333
  if (!nextAssignment) {
3334
+ scheduleProvisioningReconcile(deps, preferredWorkerId
3335
+ ? `auto-assign-wait:${preferredWorkerId}`
3336
+ : "auto-assign-wait");
1912
3337
  break;
1913
3338
  }
1914
3339
 
@@ -1964,6 +3389,19 @@ export function createControllerHttpServer(deps: ControllerHttpDeps): http.Serve
1964
3389
  // Attach WebSocket
1965
3390
  wsServer.attach(server);
1966
3391
 
3392
+ setTimeout(() => {
3393
+ const state = deps.getTeamState();
3394
+ const pendingCount = state
3395
+ ? Object.values(state.tasks).filter((t) => t.status === "pending" && !t.assignedWorkerId).length
3396
+ : 0;
3397
+ if (pendingCount > 0) {
3398
+ logger.info(`Controller: startup reconciliation — ${pendingCount} pending tasks, triggering auto-assign`);
3399
+ void autoAssignPendingTasks(deps).catch((err) => {
3400
+ logger.warn(`Controller: startup auto-assign failed: ${String(err)}`);
3401
+ });
3402
+ }
3403
+ }, 2000);
3404
+
1967
3405
  return server;
1968
3406
  }
1969
3407
 
@@ -1975,6 +3413,7 @@ async function handleRequest(
1975
3413
  ): Promise<void> {
1976
3414
  const { config, logger, getTeamState, updateTeamState, taskRouter, messageRouter, wsServer } = deps;
1977
3415
  const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
3416
+ const currentState = getTeamState();
1978
3417
 
1979
3418
  // ==================== Web UI ====================
1980
3419
  if (req.method === "GET" && pathname === "/") {
@@ -1997,6 +3436,12 @@ async function handleRequest(
1997
3436
  serveStaticFile(res, path.join(uiPath, file), "text/css; charset=utf-8");
1998
3437
  } else if (file.endsWith(".js")) {
1999
3438
  serveStaticFile(res, path.join(uiPath, file), "application/javascript; charset=utf-8");
3439
+ } else if (file.endsWith(".png")) {
3440
+ serveStaticFile(res, path.join(uiPath, file), "image/png");
3441
+ } else if (file.endsWith(".svg")) {
3442
+ serveStaticFile(res, path.join(uiPath, file), "image/svg+xml; charset=utf-8");
3443
+ } else if (file.endsWith(".ico")) {
3444
+ serveStaticFile(res, path.join(uiPath, file), "image/x-icon");
2000
3445
  } else {
2001
3446
  serveStaticFile(res, path.join(uiPath, file), "application/octet-stream");
2002
3447
  }
@@ -2007,7 +3452,24 @@ async function handleRequest(
2007
3452
 
2008
3453
  if (req.method === "GET" && pathname === "/api/v1/workspace/tree") {
2009
3454
  try {
2010
- sendJson(res, 200, await listWorkspaceTree());
3455
+ // depth=N limits tree expansion (default 1 for lazy loading)
3456
+ const depthParam = requestUrl.searchParams.get("depth");
3457
+ const maxDepth = depthParam !== null ? Math.max(0, Math.min(parseInt(depthParam, 10) || 1, 8)) : 1;
3458
+ sendJson(res, 200, await listWorkspaceTree(maxDepth));
3459
+ } catch (err) {
3460
+ sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err));
3461
+ }
3462
+ return;
3463
+ }
3464
+
3465
+ if (req.method === "GET" && pathname === "/api/v1/workspace/subtree") {
3466
+ const dirPath = requestUrl.searchParams.get("path") ?? "";
3467
+ if (!dirPath) {
3468
+ sendError(res, 400, "path is required");
3469
+ return;
3470
+ }
3471
+ try {
3472
+ sendJson(res, 200, { entries: await listWorkspaceSubtree(dirPath) });
2011
3473
  } catch (err) {
2012
3474
  sendError(res, workspaceRequestErrorStatus(err), workspaceRequestErrorMessage(err));
2013
3475
  }
@@ -2102,11 +3564,6 @@ async function handleRequest(
2102
3564
  // DELETE /api/v1/workers/:id
2103
3565
  if (req.method === "DELETE" && pathname.match(/^\/api\/v1\/workers\/[^/]+$/)) {
2104
3566
  const workerId = pathname.split("/").pop()!;
2105
- if (deps.localWorkerManager?.isLocalWorkerId(workerId)) {
2106
- sendError(res, 400, "Local workers are managed by controller config");
2107
- return;
2108
- }
2109
-
2110
3567
  if (deps.workerProvisioningManager?.hasManagedWorker(workerId)) {
2111
3568
  await deps.workerProvisioningManager.onWorkerRemoved(workerId, "worker delete requested");
2112
3569
  }
@@ -2203,6 +3660,13 @@ async function handleRequest(
2203
3660
  sendError(res, 400, "title is required");
2204
3661
  return;
2205
3662
  }
3663
+ if (createdBy === "controller") {
3664
+ const readinessGate = shouldBlockControllerUntilProvisioningReady(deps, currentState);
3665
+ if (readinessGate.blocked && readinessGate.readiness) {
3666
+ sendError(res, 503, buildStartupReadinessMessage(readinessGate.readiness));
3667
+ return;
3668
+ }
3669
+ }
2206
3670
  if (createdBy === "controller" && shouldBlockControllerWithoutWorkers(deps.config, getTeamState())) {
2207
3671
  sendError(res, 409, buildControllerNoWorkersMessage());
2208
3672
  return;
@@ -2212,6 +3676,17 @@ async function handleRequest(
2212
3676
  const now = Date.now();
2213
3677
  const repoState = await refreshControllerRepoState(deps);
2214
3678
 
3679
+ // Resolve project directory: inherit from parent run, or generate from title
3680
+ let projectDir: string | undefined;
3681
+ if (controllerSessionKey) {
3682
+ const runId = findLatestControllerRunIdForSession(controllerSessionKey, getTeamState(), { preferActive: true });
3683
+ const parentRun = runId ? getTeamState()?.controllerRuns[runId] : undefined;
3684
+ projectDir = parentRun?.projectDir;
3685
+ }
3686
+ if (!projectDir) {
3687
+ projectDir = deriveProjectSlug(title);
3688
+ }
3689
+
2215
3690
  const task: TaskInfo = {
2216
3691
  id: taskId,
2217
3692
  title,
@@ -2222,6 +3697,7 @@ async function handleRequest(
2222
3697
  createdBy,
2223
3698
  recommendedSkills: recommendedSkills.length > 0 ? recommendedSkills : undefined,
2224
3699
  controllerSessionKey,
3700
+ projectDir,
2225
3701
  createdAt: now,
2226
3702
  updatedAt: now,
2227
3703
  };
@@ -2536,8 +4012,18 @@ async function handleRequest(
2536
4012
  if (!task) {
2537
4013
  return;
2538
4014
  }
2539
- task.resultContract = contract;
4015
+ task.resultContract = filterStaleDeliverables(contract, task.projectDir);
2540
4016
  task.updatedAt = Date.now();
4017
+ // Enrich deliverables with preview inference so PreviewManager can auto-launch.
4018
+ // Workers typically don't include artifactType/previewCommand in their contracts.
4019
+ const existingResult = task.result ?? "";
4020
+ let enriched = enrichDeliverablesWithPreviewInference(task.resultContract, existingResult);
4021
+ if (!enriched) {
4022
+ enriched = enrichWithFilesystemHtmlScan(contract, task.projectDir);
4023
+ }
4024
+ if (enriched) {
4025
+ task.resultContract = enriched;
4026
+ }
2541
4027
  });
2542
4028
  recordTaskExecutionEvent(taskId, {
2543
4029
  type: "output",
@@ -2548,6 +4034,7 @@ async function handleRequest(
2548
4034
  workerId,
2549
4035
  role: currentTask.assignedRole,
2550
4036
  }, deps);
4037
+ consolidateDiscoveredPatterns(contract, taskId, currentTask.assignedRole, logger);
2551
4038
  sendJson(res, 201, { task: serializeTask(state.tasks[taskId]) });
2552
4039
  return;
2553
4040
  }
@@ -2557,7 +4044,7 @@ async function handleRequest(
2557
4044
  const taskId = pathname.split("/")[4]!;
2558
4045
  const body = await parseJsonBody(req);
2559
4046
  const result = typeof body.result === "string" ? body.result : "";
2560
- const error = typeof body.error === "string" ? body.error : undefined;
4047
+ let error = typeof body.error === "string" ? body.error : undefined;
2561
4048
  const workerId = typeof body.workerId === "string" ? body.workerId : undefined;
2562
4049
  const currentTask = getTeamState()?.tasks[taskId];
2563
4050
  if (!currentTask) {
@@ -2585,14 +4072,72 @@ async function handleRequest(
2585
4072
  phase: "result_contract_recorded",
2586
4073
  source: "worker",
2587
4074
  status: error ? "failed" : "running",
2588
- message: submittedContract.summary,
4075
+ message: submittedContract.summary,
4076
+ workerId,
4077
+ role: currentTask.assignedRole,
4078
+ }, deps);
4079
+ }
4080
+
4081
+ if (!error && submittedContract?.outcome === "blocked") {
4082
+ const requested = await requestTaskClarification({
4083
+ taskId,
4084
+ requestedBy: workerId ?? "worker",
4085
+ requestedByWorkerId: workerId,
4086
+ requestedByRole: currentTask.assignedRole,
4087
+ question: submittedContract.questions[0]
4088
+ ?? "This task is blocked and needs a human decision before work can continue. What should TeamClaw do next?",
4089
+ blockingReason: submittedContract.blockers[0] ?? submittedContract.summary,
4090
+ context: [
4091
+ submittedContract.notes,
4092
+ result.trim(),
4093
+ submittedContract.keyPoints.length > 0
4094
+ ? `Worker-provided commands/details:\n${submittedContract.keyPoints.join("\n")}`
4095
+ : "",
4096
+ ].filter(Boolean).join("\n\n"),
4097
+ }, deps);
4098
+ if (requested.status === "missing-task") {
4099
+ sendError(res, 404, "Task not found");
4100
+ return;
4101
+ }
4102
+ if (requested.status === "conflict") {
4103
+ sendError(res, 409, "Cannot request clarification for a completed task");
4104
+ return;
4105
+ }
4106
+ sendJson(res, requested.status === "already-pending" ? 200 : 201, {
4107
+ status: requested.status,
4108
+ clarification: requested.clarification,
4109
+ task: serializeTask(requested.task),
4110
+ });
4111
+ return;
4112
+ }
4113
+
4114
+ const gatedTask = getTeamState()?.tasks[taskId];
4115
+ if (
4116
+ !error
4117
+ && gatedTask
4118
+ && taskRequiresMeaningfulProjectChangeGate(gatedTask)
4119
+ && !projectHasMeaningfulFileChanges(gatedTask)
4120
+ && !allowsNoChangeCompletion(gatedTask, submittedContract, result)
4121
+ ) {
4122
+ error = "Task reported completion but no meaningful project file changes were detected in the assigned project directory.";
4123
+ recordTaskExecutionEvent(taskId, {
4124
+ type: "warning",
4125
+ phase: "completion_gate",
4126
+ source: "controller",
4127
+ status: "failed",
4128
+ message: error,
2589
4129
  workerId,
2590
- role: currentTask.assignedRole,
4130
+ role: gatedTask.assignedRole,
2591
4131
  }, deps);
2592
4132
  }
2593
4133
 
2594
4134
  const updatedTask = applyTaskResult(taskId, result, error, deps);
2595
4135
  ensureTaskResultContract(taskId, result, error, deps);
4136
+ if (!error) {
4137
+ deps.previewManager?.syncTaskPreviews(taskId).catch((err) => {
4138
+ logger.warn(`Controller: failed to sync previews for ${taskId}: ${String(err)}`);
4139
+ });
4140
+ }
2596
4141
  if (!workerId || workerId !== previousWorkerId) {
2597
4142
  await cancelTaskExecution(taskId, previousWorkerId, "manual result submission", deps);
2598
4143
  }
@@ -2644,6 +4189,14 @@ async function handleRequest(
2644
4189
  return;
2645
4190
  }
2646
4191
 
4192
+ // When a result contract is backfilled, the task is effectively complete —
4193
+ // trigger preview sync so that any web-app deliverables get previewed.
4194
+ if (recorded.event?.phase === "result_contract_backfilled") {
4195
+ deps.previewManager?.syncTaskPreviews(taskId).catch((err) => {
4196
+ logger.warn(`Controller: failed to sync previews for ${taskId}: ${String(err)}`);
4197
+ });
4198
+ }
4199
+
2647
4200
  sendJson(res, 201, {
2648
4201
  task: serializeTask(recorded.task),
2649
4202
  execution: buildTaskExecutionSummary(recorded.task.execution),
@@ -2674,6 +4227,19 @@ async function handleRequest(
2674
4227
 
2675
4228
  const updatedRun = updateControllerRun(runId, deps, (run) => {
2676
4229
  run.manifest = manifest;
4230
+
4231
+ if (run.projectDir) {
4232
+ const state = deps.getTeamState();
4233
+ if (state) {
4234
+ for (const taskId of run.createdTaskIds) {
4235
+ const task = state.tasks[taskId];
4236
+ if (task && !task.projectDir) {
4237
+ task.projectDir = run.projectDir;
4238
+ }
4239
+ }
4240
+ }
4241
+ }
4242
+
2677
4243
  appendControllerRunEvent(run, {
2678
4244
  type: "output",
2679
4245
  phase: "manifest_recorded",
@@ -2689,6 +4255,18 @@ async function handleRequest(
2689
4255
  return;
2690
4256
  }
2691
4257
 
4258
+ if (manifest.requirementFullyComplete) {
4259
+ logger.info(`Controller: requirement fully complete for session ${sessionKey} (run ${runId})`);
4260
+ wsServer.broadcastUpdate({
4261
+ type: "requirement:complete",
4262
+ data: {
4263
+ runId,
4264
+ sessionKey,
4265
+ requirementSummary: manifest.requirementSummary,
4266
+ },
4267
+ });
4268
+ }
4269
+
2692
4270
  sendJson(res, 201, {
2693
4271
  controllerRun: serializeControllerRun(updatedRun),
2694
4272
  manifest,
@@ -2698,7 +4276,7 @@ async function handleRequest(
2698
4276
 
2699
4277
  // GET /api/v1/controller/runs
2700
4278
  if (req.method === "GET" && pathname === "/api/v1/controller/runs") {
2701
- const state = getTeamState();
4279
+ const state = reconcileControllerClarifications(deps);
2702
4280
  const controllerRuns = state
2703
4281
  ? Object.values(state.controllerRuns)
2704
4282
  .sort((left, right) => right.updatedAt - left.updatedAt)
@@ -2710,6 +4288,11 @@ async function handleRequest(
2710
4288
 
2711
4289
  // POST /api/v1/controller/intake
2712
4290
  if (req.method === "POST" && pathname === "/api/v1/controller/intake") {
4291
+ const readinessGate = shouldBlockControllerUntilProvisioningReady(deps, currentState);
4292
+ if (readinessGate.blocked && readinessGate.readiness) {
4293
+ sendError(res, 503, buildStartupReadinessMessage(readinessGate.readiness));
4294
+ return;
4295
+ }
2713
4296
  const body = await parseJsonBody(req);
2714
4297
  const message = typeof body.message === "string" ? body.message.trim() : "";
2715
4298
  if (!message) {
@@ -2858,95 +4441,49 @@ async function handleRequest(
2858
4441
  const question = typeof body.question === "string" ? body.question.trim() : "";
2859
4442
  const blockingReason = typeof body.blockingReason === "string" ? body.blockingReason.trim() : "";
2860
4443
  const context = typeof body.context === "string" && body.context.trim() ? body.context.trim() : undefined;
4444
+ const questionSchema = normalizeClarificationQuestionSchema(body.questionSchema);
2861
4445
 
2862
4446
  if (!taskId || !question || !blockingReason) {
2863
4447
  sendError(res, 400, "taskId, question, and blockingReason are required");
2864
4448
  return;
2865
4449
  }
2866
4450
 
2867
- const currentState = getTeamState();
2868
- const currentTask = currentState?.tasks[taskId];
2869
- if (!currentTask) {
2870
- sendError(res, 404, "Task not found");
2871
- return;
2872
- }
2873
-
2874
- if (currentTask.clarificationRequestId) {
2875
- const existing = currentState?.clarifications[currentTask.clarificationRequestId];
2876
- if (existing?.status === "pending") {
2877
- sendJson(res, 200, { clarification: existing, task: currentTask, status: "already-pending" });
2878
- return;
2879
- }
2880
- }
2881
-
2882
- if (currentTask.status === "completed" || currentTask.status === "failed") {
2883
- sendError(res, 409, "Cannot request clarification for a completed task");
2884
- return;
2885
- }
2886
-
2887
- const previousWorkerId = currentTask.assignedWorkerId;
2888
-
2889
- const clarificationId = generateId();
2890
- const now = Date.now();
2891
- const clarification: ClarificationRequest = {
2892
- id: clarificationId,
4451
+ const requested = await requestTaskClarification({
2893
4452
  taskId,
2894
4453
  requestedBy,
2895
4454
  requestedByWorkerId,
2896
4455
  requestedByRole,
2897
4456
  question,
4457
+ questionSchema,
2898
4458
  blockingReason,
2899
4459
  context,
2900
- status: "pending",
2901
- createdAt: now,
2902
- updatedAt: now,
2903
- };
2904
-
2905
- const state = updateTeamState((s) => {
2906
- s.clarifications[clarificationId] = clarification;
2907
- const task = s.tasks[taskId];
2908
- if (!task) {
2909
- return;
2910
- }
2911
-
2912
- const assignedWorkerId = task.assignedWorkerId;
2913
- task.status = "blocked";
2914
- task.progress = `Awaiting clarification: ${question}`;
2915
- task.clarificationRequestId = clarificationId;
2916
- task.assignedWorkerId = undefined;
2917
- task.updatedAt = now;
2918
-
2919
- if (assignedWorkerId && s.workers[assignedWorkerId]) {
2920
- const assignedWorker = s.workers[assignedWorkerId];
2921
- if (assignedWorker.status !== "offline") {
2922
- assignedWorker.status = "idle";
2923
- }
2924
- assignedWorker.currentTaskId = undefined;
2925
- }
2926
- });
2927
-
2928
- await cancelTaskExecution(taskId, previousWorkerId, "clarification request", deps);
2929
-
2930
- const updatedTask = state.tasks[taskId];
2931
- wsServer.broadcastUpdate({ type: "clarification:requested", data: clarification });
2932
- if (updatedTask) {
2933
- recordTaskExecutionEvent(taskId, {
2934
- type: "lifecycle",
2935
- phase: "clarification_requested",
2936
- source: "controller",
2937
- message: `Clarification requested: ${question}`,
2938
- role: clarification.requestedByRole,
2939
- workerId: clarification.requestedByWorkerId,
2940
- }, deps);
2941
- wsServer.broadcastUpdate({ type: "task:updated", data: serializeTask(updatedTask) });
4460
+ }, deps);
4461
+ if (requested.status === "missing-task") {
4462
+ sendError(res, 404, "Task not found");
4463
+ return;
4464
+ }
4465
+ if (requested.status === "conflict") {
4466
+ sendError(res, 409, "Cannot request clarification for a completed task");
4467
+ return;
4468
+ }
4469
+ if (requested.status === "already-pending") {
4470
+ sendJson(res, 200, {
4471
+ clarification: requested.clarification,
4472
+ task: serializeTask(requested.task),
4473
+ status: "already-pending",
4474
+ });
4475
+ return;
2942
4476
  }
2943
- sendJson(res, 201, { clarification, task: serializeTask(updatedTask) });
4477
+ sendJson(res, 201, {
4478
+ clarification: requested.clarification,
4479
+ task: serializeTask(requested.task),
4480
+ });
2944
4481
  return;
2945
4482
  }
2946
4483
 
2947
4484
  // GET /api/v1/clarifications
2948
4485
  if (req.method === "GET" && pathname === "/api/v1/clarifications") {
2949
- const state = getTeamState();
4486
+ const state = reconcileControllerClarifications(deps);
2950
4487
  const clarifications = state
2951
4488
  ? Object.values(state.clarifications).sort((left, right) => right.createdAt - left.createdAt)
2952
4489
  : [];
@@ -2961,16 +4498,20 @@ async function handleRequest(
2961
4498
  if (req.method === "POST" && pathname.match(/^\/api\/v1\/clarifications\/[^/]+\/answer$/)) {
2962
4499
  const clarificationId = pathname.split("/")[4]!;
2963
4500
  const body = await parseJsonBody(req);
2964
- const answer = typeof body.answer === "string" ? body.answer.trim() : "";
4501
+ const answerValue = typeof body.answerValue === "string" ? body.answerValue.trim() : undefined;
4502
+ const answerValues = Array.isArray(body.answerValues)
4503
+ ? body.answerValues.map((entry) => String(entry || "").trim()).filter(Boolean)
4504
+ : undefined;
4505
+ const answerNumber = typeof body.answerNumber === "number" && Number.isFinite(body.answerNumber)
4506
+ ? body.answerNumber
4507
+ : undefined;
4508
+ const answerComment = typeof body.answerComment === "string" && body.answerComment.trim()
4509
+ ? body.answerComment.trim()
4510
+ : undefined;
2965
4511
  const answeredBy = typeof body.answeredBy === "string" && body.answeredBy.trim()
2966
4512
  ? body.answeredBy.trim()
2967
4513
  : "human";
2968
4514
 
2969
- if (!answer) {
2970
- sendError(res, 400, "answer is required");
2971
- return;
2972
- }
2973
-
2974
4515
  const currentState = getTeamState();
2975
4516
  const currentClarification = currentState?.clarifications[clarificationId];
2976
4517
  if (!currentClarification) {
@@ -2978,6 +4519,19 @@ async function handleRequest(
2978
4519
  return;
2979
4520
  }
2980
4521
 
4522
+ const answer = buildStructuredClarificationAnswer(currentClarification, {
4523
+ answer: typeof body.answer === "string" ? body.answer.trim() : "",
4524
+ answerValue,
4525
+ answerValues,
4526
+ answerNumber,
4527
+ answerComment,
4528
+ });
4529
+
4530
+ if (!answer) {
4531
+ sendError(res, 400, "answer is required");
4532
+ return;
4533
+ }
4534
+
2981
4535
  if (currentClarification.status === "answered") {
2982
4536
  sendError(res, 409, "Clarification request already answered");
2983
4537
  return;
@@ -2992,6 +4546,10 @@ async function handleRequest(
2992
4546
 
2993
4547
  clarification.status = "answered";
2994
4548
  clarification.answer = answer;
4549
+ clarification.answerValue = answerValue;
4550
+ clarification.answerValues = answerValues;
4551
+ clarification.answerNumber = answerNumber;
4552
+ clarification.answerComment = answerComment;
2995
4553
  clarification.answeredBy = answeredBy;
2996
4554
  clarification.answeredAt = now;
2997
4555
  clarification.updatedAt = now;
@@ -3009,7 +4567,7 @@ async function handleRequest(
3009
4567
  });
3010
4568
 
3011
4569
  const clarification = state.clarifications[clarificationId];
3012
- const task = clarification ? state.tasks[clarification.taskId] : undefined;
4570
+ const task = clarification?.taskId ? state.tasks[clarification.taskId] : undefined;
3013
4571
 
3014
4572
  let responseMessage: TeamMessage | undefined;
3015
4573
  if (clarification?.requestedByRole && task) {
@@ -3043,6 +4601,13 @@ async function handleRequest(
3043
4601
 
3044
4602
  let resumedTask = task;
3045
4603
  let resumedWorker: WorkerInfo | null = null;
4604
+ let continuedControllerRun: {
4605
+ sessionKey: string;
4606
+ controllerRunId?: string;
4607
+ runId?: string;
4608
+ reply?: string;
4609
+ queued?: boolean;
4610
+ } | null = null;
3046
4611
  if (task) {
3047
4612
  const latestState = getTeamState()!;
3048
4613
  if (clarification?.requestedByWorkerId && latestState.workers[clarification.requestedByWorkerId]?.status === "idle") {
@@ -3056,6 +4621,25 @@ async function handleRequest(
3056
4621
  assignedRole: task.assignedRole,
3057
4622
  });
3058
4623
  }
4624
+ } else if (clarification?.controllerRunId) {
4625
+ const targetSessionKey = clarification.controllerSessionKey
4626
+ || state.controllerRuns[clarification.controllerRunId]?.sessionKey;
4627
+ if (targetSessionKey) {
4628
+ continuedControllerRun = {
4629
+ sessionKey: targetSessionKey,
4630
+ controllerRunId: clarification.controllerRunId,
4631
+ queued: true,
4632
+ };
4633
+ void runControllerIntake(
4634
+ buildControllerClarificationAnswerMessage(clarification, answer, answeredBy),
4635
+ targetSessionKey,
4636
+ deps,
4637
+ ).catch((err) => {
4638
+ deps.logger.warn(
4639
+ `Controller: failed to continue intake after clarification ${clarification.id}: ${String(err)}`,
4640
+ );
4641
+ });
4642
+ }
3059
4643
  }
3060
4644
 
3061
4645
  wsServer.broadcastUpdate({ type: "clarification:answered", data: clarification });
@@ -3075,6 +4659,7 @@ async function handleRequest(
3075
4659
  clarification,
3076
4660
  task: serializeTask(resumedTask),
3077
4661
  resumedWorker,
4662
+ controllerRun: continuedControllerRun,
3078
4663
  message: responseMessage,
3079
4664
  });
3080
4665
  return;
@@ -3180,7 +4765,10 @@ async function handleRequest(
3180
4765
 
3181
4766
  // GET /api/v1/team/status
3182
4767
  if (req.method === "GET" && pathname === "/api/v1/team/status") {
3183
- const state = getTeamState();
4768
+ const state = reconcileControllerClarifications(deps);
4769
+ const startupReadiness = getEffectiveStartupReadiness(deps, state);
4770
+ const modelReadiness = getTeamClawModelReadiness();
4771
+ const externalWorkerInstall = buildExternalWorkerInstallInfo(req, config);
3184
4772
  if (!state) {
3185
4773
  sendJson(res, 200, {
3186
4774
  teamName: config.teamName,
@@ -3189,8 +4777,12 @@ async function handleRequest(
3189
4777
  controllerRuns: [],
3190
4778
  messages: [],
3191
4779
  clarifications: [],
4780
+ previews: [],
3192
4781
  repo: null,
3193
4782
  pendingClarificationCount: 0,
4783
+ modelReadiness,
4784
+ externalWorkerInstall,
4785
+ provisioning: startupReadiness ? { startupReadiness } : undefined,
3194
4786
  });
3195
4787
  return;
3196
4788
  }
@@ -3205,7 +4797,14 @@ async function handleRequest(
3205
4797
  .map((run) => serializeControllerRun(run)),
3206
4798
  messages: state.messages,
3207
4799
  clarifications,
4800
+ previews: Object.values(state.previews ?? {}),
3208
4801
  repo: state.repo ?? null,
4802
+ modelReadiness,
4803
+ externalWorkerInstall,
4804
+ provisioning: {
4805
+ ...(state.provisioning ?? { workers: {} }),
4806
+ startupReadiness,
4807
+ },
3209
4808
  taskCount: Object.keys(state.tasks).length,
3210
4809
  workerCount: Object.keys(state.workers).length,
3211
4810
  pendingClarificationCount: clarifications.filter((item) => item.status === "pending").length,
@@ -3219,9 +4818,145 @@ async function handleRequest(
3219
4818
  return;
3220
4819
  }
3221
4820
 
4821
+ // /api/v1/previews/:id/* — proxy to preview subprocess
4822
+ if (req.method === "GET" || req.method === "HEAD" || req.method === "POST" || req.method === "PUT" || req.method === "DELETE") {
4823
+ const previewPrefix = "/api/v1/previews/";
4824
+ if (pathname.startsWith(previewPrefix)) {
4825
+ const remaining = pathname.slice(previewPrefix.length);
4826
+ const slashIdx = remaining.indexOf("/");
4827
+ const previewId = slashIdx >= 0 ? remaining.slice(0, slashIdx) : remaining;
4828
+ const subPath = slashIdx >= 0 ? remaining.slice(slashIdx) : "/";
4829
+
4830
+ const state = deps.getTeamState();
4831
+ const preview = state?.previews?.[previewId];
4832
+ if (!preview || preview.status !== "healthy") {
4833
+ const status = preview?.status ?? "unknown";
4834
+ const errMsg = preview?.lastError;
4835
+ sendJson(res, 503, { error: "Preview not available", previewId, status, lastError: errMsg ?? undefined });
4836
+ return;
4837
+ }
4838
+
4839
+ const proxyPathPrefix = `/api/v1/previews/${encodeURIComponent(previewId)}`;
4840
+ const proxyReq = http.request(
4841
+ {
4842
+ hostname: "127.0.0.1",
4843
+ port: preview.targetPort,
4844
+ path: subPath + (requestUrl.search || ""),
4845
+ method: req.method,
4846
+ headers: {
4847
+ ...req.headers,
4848
+ host: `127.0.0.1:${preview.targetPort}`,
4849
+ "accept-encoding": "identity",
4850
+ "x-forwarded-for": req.socket.remoteAddress ?? "",
4851
+ "x-forwarded-host": req.headers.host ?? "",
4852
+ "x-forwarded-proto": "http",
4853
+ },
4854
+ },
4855
+ (proxyRes: http.IncomingMessage) => {
4856
+ const contentType = (proxyRes.headers["content-type"] ?? "").toLowerCase();
4857
+ if (proxyRes.statusCode !== 200 && proxyRes.statusCode !== 201 && proxyRes.statusCode !== 301 && proxyRes.statusCode !== 302 && proxyRes.statusCode !== 304) {
4858
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
4859
+ proxyRes.pipe(res);
4860
+ return;
4861
+ }
4862
+
4863
+ if (contentType.includes("text/html")) {
4864
+ const chunks: Buffer[] = [];
4865
+ proxyRes.on("data", (chunk: Buffer) => chunks.push(chunk));
4866
+ proxyRes.on("end", () => {
4867
+ let raw = Buffer.concat(chunks);
4868
+ // Decompress if the upstream response is compressed (brotli/gzip/deflate).
4869
+ const encoding = (proxyRes.headers["content-encoding"] ?? "").toLowerCase();
4870
+ try {
4871
+ if (encoding === "br") {
4872
+ raw = zlib.brotliDecompressSync(raw);
4873
+ } else if (encoding === "gzip") {
4874
+ raw = zlib.gunzipSync(raw);
4875
+ } else if (encoding === "deflate") {
4876
+ raw = zlib.inflateSync(raw);
4877
+ }
4878
+ } catch {
4879
+ // If decompression fails, use raw bytes as-is.
4880
+ }
4881
+ const body = raw.toString("utf-8");
4882
+ // Rewrite absolute-path references (href="/...", src="/...", action="/...")
4883
+ // to include the proxy prefix so all navigation stays within the proxy.
4884
+ const rewritten = body.replace(
4885
+ /\b(href|src|action)\s*=\s*"\/([^"]*)"/g,
4886
+ `$1="${proxyPathPrefix}/$2"`,
4887
+ ).replace(
4888
+ /\b(href|src|action)\s*=\s*'\/([^']*)'/g,
4889
+ `$1='${proxyPathPrefix}/$2'`,
4890
+ );
4891
+ const resHeaders = { ...proxyRes.headers };
4892
+ // We've decoded and rewritten the body — remove upstream encoding headers.
4893
+ delete resHeaders["content-encoding"];
4894
+ delete resHeaders["transfer-encoding"];
4895
+ resHeaders["content-length"] = String(Buffer.byteLength(rewritten, "utf-8"));
4896
+ res.writeHead(proxyRes.statusCode ?? 200, resHeaders);
4897
+ res.end(rewritten);
4898
+ });
4899
+ } else {
4900
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
4901
+ proxyRes.pipe(res);
4902
+ }
4903
+ },
4904
+ );
4905
+ proxyReq.on("error", (err: Error) => {
4906
+ deps.logger.warn(`Controller: preview proxy error for ${previewId}: ${String(err)}`);
4907
+ if (!res.headersSent) {
4908
+ sendJson(res, 502, { error: "Preview proxy error", previewId, message: String(err) });
4909
+ }
4910
+ });
4911
+ req.pipe(proxyReq);
4912
+ return;
4913
+ }
4914
+ }
4915
+
4916
+ // GET /api/v1/reports — list all delivery reports
4917
+ if (req.method === "GET" && pathname === "/api/v1/reports") {
4918
+ const state = deps.getTeamState();
4919
+ const reports = Object.values(state?.reports ?? {}).sort((a, b) => b.generatedAt - a.generatedAt);
4920
+ sendJson(res, 200, { reports });
4921
+ return;
4922
+ }
4923
+
4924
+ // GET /api/v1/reports/:sessionKey — serve delivery report page
4925
+ if (req.method === "GET" && pathname.startsWith("/api/v1/reports/")) {
4926
+ const sessionKey = decodeURIComponent(pathname.slice("/api/v1/reports/".length));
4927
+ const state = deps.getTeamState();
4928
+ if (!state) {
4929
+ sendError(res, 503, "Team state not loaded");
4930
+ return;
4931
+ }
4932
+ const report = generateDeliveryReport(sessionKey, state, normalizeControllerIntakeSessionKey);
4933
+ if (!report) {
4934
+ sendError(res, 404, "No report found for this session");
4935
+ return;
4936
+ }
4937
+ const accept = (req.headers["accept"] ?? "").toLowerCase();
4938
+ if (accept.includes("application/json")) {
4939
+ sendJson(res, 200, { report });
4940
+ } else {
4941
+ const html = renderReportHtml(report);
4942
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": Buffer.byteLength(html, "utf-8") });
4943
+ res.end(html);
4944
+ }
4945
+ return;
4946
+ }
4947
+
3222
4948
  // GET /api/v1/health
3223
4949
  if (req.method === "GET" && pathname === "/api/v1/health") {
3224
- sendJson(res, 200, { status: "ok", mode: "controller", timestamp: Date.now() });
4950
+ const readiness = getEffectiveStartupReadiness(deps, currentState);
4951
+ const modelReadiness = getTeamClawModelReadiness();
4952
+ const statusCode = readiness && readiness.status !== "ready" ? 503 : 200;
4953
+ sendJson(res, statusCode, {
4954
+ status: readiness?.status === "degraded" ? "degraded" : readiness?.status === "checking" ? "starting" : "ok",
4955
+ mode: "controller",
4956
+ timestamp: Date.now(),
4957
+ provisioningReadiness: readiness,
4958
+ modelReadiness,
4959
+ });
3225
4960
  return;
3226
4961
  }
3227
4962