u-foo 1.7.4 → 1.8.0

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 (45) hide show
  1. package/README.md +9 -1
  2. package/README.zh-CN.md +9 -1
  3. package/bin/ufoo.js +4 -2
  4. package/package.json +1 -1
  5. package/src/agent/cliRunner.js +3 -2
  6. package/src/agent/ucodeBootstrap.js +5 -3
  7. package/src/agent/ufooAgent.js +185 -6
  8. package/src/assistant/constants.js +1 -1
  9. package/src/assistant/engine.js +1 -6
  10. package/src/chat/commandExecutor.js +116 -19
  11. package/src/chat/commands.js +8 -1
  12. package/src/chat/completionController.js +40 -0
  13. package/src/chat/cronScheduler.js +37 -6
  14. package/src/chat/daemonMessageRouter.js +23 -3
  15. package/src/chat/dashboardKeyController.js +48 -59
  16. package/src/chat/dashboardView.js +31 -39
  17. package/src/chat/index.js +154 -77
  18. package/src/chat/inputListenerController.js +14 -0
  19. package/src/chat/inputSubmitHandler.js +9 -5
  20. package/src/chat/settingsController.js +0 -28
  21. package/src/chat/transientAgentState.js +64 -0
  22. package/src/cli/groupCoreCommands.js +21 -12
  23. package/src/cli.js +23 -1
  24. package/src/daemon/cronOps.js +48 -11
  25. package/src/daemon/groupOrchestrator.js +581 -97
  26. package/src/daemon/index.js +420 -5
  27. package/src/daemon/ops.js +25 -7
  28. package/src/daemon/promptLoop.js +16 -0
  29. package/src/daemon/promptRequest.js +126 -2
  30. package/src/daemon/reporting.js +18 -0
  31. package/src/daemon/soloBootstrap.js +435 -0
  32. package/src/daemon/status.js +7 -1
  33. package/src/globalMode.js +33 -0
  34. package/src/group/bootstrap.js +157 -0
  35. package/src/group/promptProfiles.js +646 -0
  36. package/src/group/templateValidation.js +99 -0
  37. package/src/group/validateTemplate.js +36 -5
  38. package/src/init/index.js +13 -7
  39. package/src/report/store.js +6 -0
  40. package/src/shared/eventContract.js +1 -0
  41. package/templates/groups/{dev-basic.json → build-lane.json} +38 -34
  42. package/templates/groups/product-discovery.json +79 -0
  43. package/templates/groups/ui-polish.json +87 -0
  44. package/templates/groups/verify-ship.json +79 -0
  45. package/templates/groups/research-quick.json +0 -49
@@ -1,6 +1,7 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
- const { spawnSync } = require("child_process");
3
+ const net = require("net");
4
+ const { spawn, spawnSync } = require("child_process");
4
5
  const { runUfooAgent } = require("../agent/ufooAgent");
5
6
  const { launchAgent, closeAgent, getRecoverableAgents, resumeAgents } = require("./ops");
6
7
  const { buildStatus } = require("./status");
@@ -21,6 +22,16 @@ const { runAssistantTask } = require("../assistant/bridge");
21
22
  const { runPromptWithAssistant } = require("./promptLoop");
22
23
  const { handlePromptRequest } = require("./promptRequest");
23
24
  const { recordAgentReport } = require("./reporting");
25
+ const { isGlobalControllerProjectRoot } = require("../globalMode");
26
+ const {
27
+ assignSoloRoleToExistingAgent,
28
+ resolveSoloPromptProfile,
29
+ buildSoloBootstrap,
30
+ prepareSoloUcodeBootstrap,
31
+ persistSoloRoleMetadata,
32
+ buildSoloBootstrapFingerprint,
33
+ rollbackLaunchAfterRoleAssignmentFailure,
34
+ } = require("./soloBootstrap");
24
35
 
25
36
  let providerSessions = null;
26
37
  let probeHandles = new Map();
@@ -83,6 +94,16 @@ async function renameSpawnedAgent(projectRoot, agentType, nickname, startIso) {
83
94
  return { ok: false, nickname, error: lastError || "rename timeout" };
84
95
  }
85
96
 
97
+ function pickLaunchSubscriber(projectRoot, launchResult = {}, fallbackTarget = "") {
98
+ if (launchResult && Array.isArray(launchResult.subscriber_ids) && launchResult.subscriber_ids.length > 0) {
99
+ return String(launchResult.subscriber_ids[0] || "").trim();
100
+ }
101
+ if (launchResult && launchResult.agent_id) {
102
+ return String(launchResult.agent_id || "").trim();
103
+ }
104
+ return String(fallbackTarget || "").trim();
105
+ }
106
+
86
107
  function ensureDir(dir) {
87
108
  fs.mkdirSync(dir, { recursive: true });
88
109
  }
@@ -196,6 +217,129 @@ function removeSocket(projectRoot) {
196
217
  if (fs.existsSync(sock)) fs.unlinkSync(sock);
197
218
  }
198
219
 
220
+ function connectProjectSocket(sockPath, timeoutMs = 8000) {
221
+ return new Promise((resolve, reject) => {
222
+ let timeoutHandle = null;
223
+ const client = net.createConnection(sockPath, () => {
224
+ if (timeoutHandle) {
225
+ clearTimeout(timeoutHandle);
226
+ timeoutHandle = null;
227
+ }
228
+ resolve(client);
229
+ });
230
+ client.on("error", (err) => {
231
+ if (timeoutHandle) {
232
+ clearTimeout(timeoutHandle);
233
+ timeoutHandle = null;
234
+ }
235
+ reject(err);
236
+ });
237
+ timeoutHandle = setTimeout(() => {
238
+ const err = new Error(`connect timeout after ${timeoutMs}ms`);
239
+ err.code = "ETIMEDOUT";
240
+ try {
241
+ client.destroy(err);
242
+ } catch {
243
+ // ignore
244
+ }
245
+ reject(err);
246
+ }, timeoutMs);
247
+ if (typeof timeoutHandle.unref === "function") timeoutHandle.unref();
248
+ });
249
+ }
250
+
251
+ async function connectProjectSocketWithRetry(sockPath, retries = 25, delayMs = 200, timeoutMs = 8000) {
252
+ for (let i = 0; i < retries; i += 1) {
253
+ try {
254
+ // eslint-disable-next-line no-await-in-loop
255
+ return await connectProjectSocket(sockPath, timeoutMs);
256
+ } catch {
257
+ // eslint-disable-next-line no-await-in-loop
258
+ await sleep(delayMs);
259
+ }
260
+ }
261
+ return null;
262
+ }
263
+
264
+ async function sendPromptRequestToProject(targetProjectRoot, payload, timeoutMs = 12000) {
265
+ const sock = socketPath(targetProjectRoot);
266
+ const client = await connectProjectSocketWithRetry(sock, 25, 200, 8000);
267
+ if (!client) {
268
+ return { ok: false, error: "Failed to connect target project daemon" };
269
+ }
270
+
271
+ return new Promise((resolve) => {
272
+ let buffer = "";
273
+ let settled = false;
274
+ const timeout = setTimeout(() => {
275
+ if (settled) return;
276
+ settled = true;
277
+ try {
278
+ client.destroy();
279
+ } catch {
280
+ // ignore
281
+ }
282
+ resolve({ ok: false, error: "Target project daemon request timeout" });
283
+ }, timeoutMs);
284
+ if (typeof timeout.unref === "function") timeout.unref();
285
+
286
+ const cleanup = () => {
287
+ clearTimeout(timeout);
288
+ client.removeAllListeners();
289
+ try {
290
+ client.end();
291
+ } catch {
292
+ // ignore
293
+ }
294
+ };
295
+
296
+ client.on("data", (data) => {
297
+ buffer += data.toString("utf8");
298
+ const lines = buffer.split(/\r?\n/);
299
+ buffer = lines.pop() || "";
300
+ for (const line of lines) {
301
+ if (!line.trim()) continue;
302
+ let msg = null;
303
+ try {
304
+ msg = JSON.parse(line);
305
+ } catch {
306
+ continue;
307
+ }
308
+ if (msg.type === IPC_RESPONSE_TYPES.RESPONSE) {
309
+ if (settled) return;
310
+ settled = true;
311
+ cleanup();
312
+ resolve({
313
+ ok: true,
314
+ payload: msg.data || {},
315
+ opsResults: msg.opsResults || [],
316
+ });
317
+ return;
318
+ }
319
+ if (msg.type === IPC_RESPONSE_TYPES.ERROR) {
320
+ if (settled) return;
321
+ settled = true;
322
+ cleanup();
323
+ resolve({
324
+ ok: false,
325
+ error: msg.error || "Target project daemon error",
326
+ });
327
+ return;
328
+ }
329
+ }
330
+ });
331
+
332
+ client.on("error", (err) => {
333
+ if (settled) return;
334
+ settled = true;
335
+ cleanup();
336
+ resolve({ ok: false, error: err && err.message ? err.message : "Target project daemon error" });
337
+ });
338
+
339
+ client.write(`${JSON.stringify(payload)}\n`);
340
+ });
341
+ }
342
+
199
343
  function parseJsonLines(buffer) {
200
344
  const lines = buffer.split(/\r?\n/).filter(Boolean);
201
345
  const items = [];
@@ -328,6 +472,10 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
328
472
  const launchResult = await launchAgent(projectRoot, agent, count, nickname, processManager, {
329
473
  launchScope: op.launch_scope || "",
330
474
  terminalApp: op.terminal_app || "",
475
+ extraEnv:
476
+ op.extra_env && typeof op.extra_env === "object"
477
+ ? op.extra_env
478
+ : ((op.extraEnv && typeof op.extraEnv === "object") ? op.extraEnv : null),
331
479
  hostInjectSock: op.host_inject_sock || op.hostInjectSock || "",
332
480
  hostDaemonSock: op.host_daemon_sock || op.hostDaemonSock || "",
333
481
  hostName: op.host_name || op.hostName || "",
@@ -447,6 +595,44 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
447
595
  error: err && err.message ? err.message : String(err || "rename failed"),
448
596
  });
449
597
  }
598
+ } else if (op.action === "role") {
599
+ const roleTarget = String(op.target || op.agent_id || "").trim();
600
+ const roleProfile = String(op.prompt_profile || op.profile || "").trim();
601
+ if (!roleTarget || !roleProfile) {
602
+ results.push({
603
+ action: "role",
604
+ ok: false,
605
+ error: "role requires target and prompt_profile",
606
+ });
607
+ continue;
608
+ }
609
+ try {
610
+ const roleResult = await assignSoloRoleToExistingAgent(projectRoot, roleTarget, roleProfile, {
611
+ bootstrapOptions: {
612
+ timeoutMs: 15000,
613
+ retryDelayMs: 250,
614
+ protectionMs: 3000,
615
+ workingGraceMs: 10000,
616
+ },
617
+ });
618
+ results.push({
619
+ action: "role",
620
+ ok: roleResult.ok !== false,
621
+ target: roleTarget,
622
+ prompt_profile: roleProfile,
623
+ resolved_profile: roleResult.resolved_profile || "",
624
+ skipped: roleResult.skipped || false,
625
+ error: roleResult.error || "",
626
+ });
627
+ } catch (err) {
628
+ results.push({
629
+ action: "role",
630
+ ok: false,
631
+ target: roleTarget,
632
+ prompt_profile: roleProfile,
633
+ error: err && err.message ? err.message : String(err || "role assignment failed"),
634
+ });
635
+ }
450
636
  } else if (op.action === "cron") {
451
637
  if (!daemonCronController) {
452
638
  results.push({
@@ -697,6 +883,9 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
697
883
  logFile.write(`[daemon] ${new Date().toISOString()} ${msg}\n`);
698
884
  };
699
885
  const publishProjectRuntime = (status = "running") => {
886
+ if (isGlobalControllerProjectRoot(projectRoot)) {
887
+ return;
888
+ }
700
889
  try {
701
890
  upsertProjectRuntime({
702
891
  projectRoot,
@@ -800,6 +989,54 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
800
989
  log,
801
990
  });
802
991
  },
992
+ forwardProjectPrompt: async ({
993
+ targetProjectRoot,
994
+ targetProjectName,
995
+ prompt,
996
+ routeReason,
997
+ requestMeta = {},
998
+ }) => {
999
+ const root = String(targetProjectRoot || "").trim();
1000
+ if (!root) {
1001
+ return { ok: false, error: "target project root is required" };
1002
+ }
1003
+ if (!fs.existsSync(root)) {
1004
+ return { ok: false, error: `target project not found: ${root}` };
1005
+ }
1006
+ const targetPaths = getUfooPaths(root);
1007
+ if (!fs.existsSync(targetPaths.ufooDir)) {
1008
+ const repoRoot = path.join(__dirname, "..", "..");
1009
+ const init = new (require("../init"))(repoRoot);
1010
+ await init.init({ modules: "context,bus", project: root });
1011
+ }
1012
+ if (!isRunning(root)) {
1013
+ const daemonBin = path.join(__dirname, "..", "..", "bin", "ufoo.js");
1014
+ const child = spawn(process.execPath, [daemonBin, "daemon", "--start"], {
1015
+ detached: true,
1016
+ stdio: "ignore",
1017
+ cwd: root,
1018
+ env: process.env,
1019
+ });
1020
+ child.unref();
1021
+ }
1022
+
1023
+ const nextMeta = {
1024
+ ...(requestMeta && typeof requestMeta === "object" ? requestMeta : {}),
1025
+ via_global_router: true,
1026
+ global_controller_project_root: projectRoot,
1027
+ routed_project_root: root,
1028
+ routed_project_name: targetProjectName || path.basename(root),
1029
+ routed_reason: routeReason || "",
1030
+ };
1031
+ delete nextMeta.force_project_root;
1032
+ delete nextMeta.force_project_name;
1033
+
1034
+ return sendPromptRequestToProject(root, {
1035
+ type: IPC_REQUEST_TYPES.PROMPT,
1036
+ text: String(prompt || ""),
1037
+ request_meta: nextMeta,
1038
+ });
1039
+ },
803
1040
  log,
804
1041
  });
805
1042
  return;
@@ -831,6 +1068,15 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
831
1068
  })}
832
1069
  `,
833
1070
  );
1071
+ ipcServer.sendToSockets({
1072
+ type: IPC_RESPONSE_TYPES.BUS,
1073
+ data: {
1074
+ event: "controller_report",
1075
+ publisher: entry.agent_id,
1076
+ message: entry.summary || entry.message || entry.task_id,
1077
+ report: entry,
1078
+ },
1079
+ });
834
1080
  ipcServer.sendToSockets({
835
1081
  type: IPC_RESPONSE_TYPES.STATUS,
836
1082
  data: buildRuntimeStatus(),
@@ -915,9 +1161,9 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
915
1161
  }
916
1162
  } else if (result.operation === "start" && result.task) {
917
1163
  if (result.task.mode === "once") {
918
- reply = `Cron scheduled ${result.task.id} at ${result.task.onceAt || result.task.onceAtMs}`;
1164
+ reply = `Cron scheduled ${result.task.id}: ${result.task.label || result.task.onceAt || result.task.onceAtMs}`;
919
1165
  } else {
920
- reply = `Cron started ${result.task.id}: every ${result.task.interval || result.task.intervalMs}`;
1166
+ reply = `Cron started ${result.task.id}: ${result.task.label || result.task.interval || result.task.intervalMs}`;
921
1167
  }
922
1168
  } else {
923
1169
  reply = "Cron updated";
@@ -1001,6 +1247,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1001
1247
  agent,
1002
1248
  count,
1003
1249
  nickname,
1250
+ prompt_profile,
1004
1251
  launch_scope,
1005
1252
  terminal_app,
1006
1253
  host_inject_sock,
@@ -1022,6 +1269,17 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1022
1269
  }
1023
1270
  const parsedCount = parseInt(count, 10);
1024
1271
  const finalCount = Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 1;
1272
+ const requestedProfile = String(prompt_profile || "").trim();
1273
+ if (requestedProfile && finalCount > 1) {
1274
+ socket.write(
1275
+ `${JSON.stringify({
1276
+ type: IPC_RESPONSE_TYPES.ERROR,
1277
+ error: "prompt_profile requires count=1",
1278
+ })}
1279
+ `,
1280
+ );
1281
+ return;
1282
+ }
1025
1283
  const op = {
1026
1284
  action: "launch",
1027
1285
  agent: normalizedAgent,
@@ -1038,9 +1296,101 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1038
1296
  ? host_capabilities
1039
1297
  : null,
1040
1298
  };
1299
+ let soloLaunchBootstrap = null;
1300
+ if (requestedProfile && normalizedAgent === "ufoo") {
1301
+ const profileResult = resolveSoloPromptProfile(projectRoot, requestedProfile);
1302
+ if (!profileResult.ok) {
1303
+ socket.write(
1304
+ `${JSON.stringify({
1305
+ type: IPC_RESPONSE_TYPES.ERROR,
1306
+ error: profileResult.error || "prompt profile resolution failed",
1307
+ })}
1308
+ `,
1309
+ );
1310
+ return;
1311
+ }
1312
+ const built = buildSoloBootstrap({
1313
+ nickname: nickname || "ucode",
1314
+ agentType: "ufoo-code",
1315
+ requestedProfile: profileResult.requested_profile,
1316
+ profile: profileResult.profile,
1317
+ });
1318
+ if (built.required) {
1319
+ try {
1320
+ const prepared = prepareSoloUcodeBootstrap(projectRoot, nickname || "ucode", built.promptText);
1321
+ op.extra_env = {
1322
+ ...(op.extra_env && typeof op.extra_env === "object" ? op.extra_env : {}),
1323
+ UFOO_UCODE_BOOTSTRAP_FILE: prepared.file,
1324
+ };
1325
+ soloLaunchBootstrap = {
1326
+ requested_profile: profileResult.requested_profile,
1327
+ resolved_profile: profileResult.profile.id,
1328
+ promptText: built.promptText,
1329
+ };
1330
+ } catch (err) {
1331
+ socket.write(
1332
+ `${JSON.stringify({
1333
+ type: IPC_RESPONSE_TYPES.ERROR,
1334
+ error: err.message || "failed to prepare ucode bootstrap",
1335
+ })}
1336
+ `,
1337
+ );
1338
+ return;
1339
+ }
1340
+ }
1341
+ }
1041
1342
  try {
1042
1343
  const opsResults = await handleOps(projectRoot, [op], processManager);
1043
1344
  const launchResult = opsResults.find((r) => r.action === "launch");
1345
+ if (soloLaunchBootstrap && launchResult && launchResult.ok !== false) {
1346
+ const subscriberId = pickLaunchSubscriber(projectRoot, launchResult, nickname || "");
1347
+ if (subscriberId) {
1348
+ persistSoloRoleMetadata(projectRoot, subscriberId, {
1349
+ requested_profile: soloLaunchBootstrap.requested_profile,
1350
+ resolved_profile: soloLaunchBootstrap.resolved_profile,
1351
+ bootstrap_fingerprint: buildSoloBootstrapFingerprint({
1352
+ subscriberId,
1353
+ requestedProfile: soloLaunchBootstrap.requested_profile,
1354
+ resolvedProfile: soloLaunchBootstrap.resolved_profile,
1355
+ promptText: soloLaunchBootstrap.promptText,
1356
+ }),
1357
+ bootstrapped_subscriber_id: subscriberId,
1358
+ });
1359
+ }
1360
+ } else if (requestedProfile && launchResult && launchResult.ok !== false) {
1361
+ const roleTarget = pickLaunchSubscriber(projectRoot, launchResult, nickname || "");
1362
+ const roleResult = await assignSoloRoleToExistingAgent(projectRoot, roleTarget, requestedProfile, {
1363
+ bootstrapOptions: {
1364
+ timeoutMs: 15000,
1365
+ retryDelayMs: 250,
1366
+ protectionMs: 3000,
1367
+ workingGraceMs: 10000,
1368
+ },
1369
+ });
1370
+ if (!roleResult.ok) {
1371
+ const rollback = await rollbackLaunchAfterRoleAssignmentFailure(
1372
+ projectRoot,
1373
+ launchResult,
1374
+ roleTarget,
1375
+ handleOps,
1376
+ processManager
1377
+ );
1378
+ const roleError = roleResult.error || "role assignment failed";
1379
+ const error = rollback.skipped
1380
+ ? roleError
1381
+ : (rollback.ok
1382
+ ? `${roleError}; launched agent rolled back: ${rollback.target}`
1383
+ : `${roleError}; rollback failed for ${rollback.target || "unknown"}: ${rollback.error || "close failed"}`);
1384
+ socket.write(
1385
+ `${JSON.stringify({
1386
+ type: IPC_RESPONSE_TYPES.ERROR,
1387
+ error,
1388
+ })}
1389
+ `,
1390
+ );
1391
+ return;
1392
+ }
1393
+ }
1044
1394
  const ok = launchResult ? launchResult.ok !== false : true;
1045
1395
  const reply = ok
1046
1396
  ? `Launched ${op.count} ${agent} agent(s)`
@@ -1068,6 +1418,66 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1068
1418
  type: IPC_RESPONSE_TYPES.ERROR,
1069
1419
  error: err.message || "launch_agent failed",
1070
1420
  })}
1421
+ `,
1422
+ );
1423
+ }
1424
+ return;
1425
+ }
1426
+ if (req.type === IPC_REQUEST_TYPES.ASSIGN_ROLE) {
1427
+ const target = String(req.target || "").trim();
1428
+ const promptProfile = String(req.prompt_profile || req.profile || "").trim();
1429
+ if (!target || !promptProfile) {
1430
+ socket.write(
1431
+ `${JSON.stringify({
1432
+ type: IPC_RESPONSE_TYPES.ERROR,
1433
+ error: "assign_role requires target and prompt_profile",
1434
+ })}
1435
+ `,
1436
+ );
1437
+ return;
1438
+ }
1439
+ try {
1440
+ const result = await assignSoloRoleToExistingAgent(projectRoot, target, promptProfile, {
1441
+ bootstrapOptions: {
1442
+ timeoutMs: 15000,
1443
+ retryDelayMs: 250,
1444
+ protectionMs: 3000,
1445
+ workingGraceMs: 10000,
1446
+ },
1447
+ });
1448
+ if (!result.ok) {
1449
+ socket.write(
1450
+ `${JSON.stringify({
1451
+ type: IPC_RESPONSE_TYPES.ERROR,
1452
+ error: result.error || "role assignment failed",
1453
+ })}
1454
+ `,
1455
+ );
1456
+ return;
1457
+ }
1458
+ const reply = result.skipped
1459
+ ? `Role already applied: ${result.resolved_profile}`
1460
+ : `Assigned role ${result.resolved_profile} to ${result.subscriber_id}`;
1461
+ socket.write(
1462
+ `${JSON.stringify({
1463
+ type: IPC_RESPONSE_TYPES.RESPONSE,
1464
+ data: {
1465
+ reply,
1466
+ role: result,
1467
+ },
1468
+ })}
1469
+ `,
1470
+ );
1471
+ ipcServer.sendToSockets({
1472
+ type: IPC_RESPONSE_TYPES.STATUS,
1473
+ data: buildRuntimeStatus(),
1474
+ });
1475
+ } catch (err) {
1476
+ socket.write(
1477
+ `${JSON.stringify({
1478
+ type: IPC_RESPONSE_TYPES.ERROR,
1479
+ error: err.message || "assign_role failed",
1480
+ })}
1071
1481
  `,
1072
1482
  );
1073
1483
  }
@@ -1259,6 +1669,7 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1259
1669
  source: result.entry?.source || "",
1260
1670
  filePath: result.entry?.filePath || "",
1261
1671
  errors: result.errors || [],
1672
+ prompt_profiles: result.promptProfiles || [],
1262
1673
  },
1263
1674
  },
1264
1675
  })}
@@ -1731,7 +2142,9 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
1731
2142
  log(`Shutting down daemon (managed agents: ${processManager.count()})`);
1732
2143
  clearInterval(runtimeHeartbeat);
1733
2144
  try {
1734
- markProjectStopped(projectRoot);
2145
+ if (!isGlobalControllerProjectRoot(projectRoot)) {
2146
+ markProjectStopped(projectRoot);
2147
+ }
1735
2148
  } catch {
1736
2149
  // ignore cleanup errors
1737
2150
  }
@@ -1821,7 +2234,9 @@ function stopDaemon(projectRoot) {
1821
2234
  }
1822
2235
 
1823
2236
  try {
1824
- markProjectStopped(projectRoot);
2237
+ if (!isGlobalControllerProjectRoot(projectRoot)) {
2238
+ markProjectStopped(projectRoot);
2239
+ }
1825
2240
  } catch {
1826
2241
  // ignore
1827
2242
  }
package/src/daemon/ops.js CHANGED
@@ -191,6 +191,14 @@ function escapeAppleScriptString(str) {
191
191
  return String(str).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
192
192
  }
193
193
 
194
+ function buildShellEnvPrefix(extraEnv = {}) {
195
+ if (!extraEnv || typeof extraEnv !== "object") return "";
196
+ return Object.entries(extraEnv)
197
+ .filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(String(key || "")))
198
+ .map(([key, value]) => `${key}=${shellEscape(String(value ?? ""))}`)
199
+ .join(" ");
200
+ }
201
+
194
202
  function runAppleScript(lines) {
195
203
  return new Promise((resolve, reject) => {
196
204
  const proc = spawn("osascript", lines.flatMap((l) => ["-e", l]));
@@ -494,7 +502,7 @@ async function spawnManagedHostAgent(
494
502
  return { child: null, subscriberId: subscriberId || null, sessionId, injectSock };
495
503
  }
496
504
 
497
- async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "", processManager = null) {
505
+ async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "", processManager = null, extraEnv = {}) {
498
506
  const runner = path.join(projectRoot, "bin", "ufoo.js");
499
507
  const logDir = getUfooPaths(projectRoot).runDir;
500
508
  fs.mkdirSync(logDir, { recursive: true });
@@ -549,6 +557,7 @@ async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "",
549
557
  cwd: projectRoot,
550
558
  env: {
551
559
  ...process.env,
560
+ ...(extraEnv && typeof extraEnv === "object" ? extraEnv : {}),
552
561
  UFOO_INTERNAL_AGENT: "1",
553
562
  UFOO_INTERNAL_PTY: usePty ? "1" : "0",
554
563
  UFOO_SUBSCRIBER_ID: subscriberId, // 直接传递 subscriber ID
@@ -711,13 +720,22 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
711
720
  const mode = resolveConfiguredLaunchMode(config.launchMode, options);
712
721
  const launchScope = normalizeLaunchScope(options.launchScope, "inplace");
713
722
  const terminalApp = normalizeTerminalAppPreference(options.terminalApp);
723
+ const extraEnvObject = options.extraEnv && typeof options.extraEnv === "object" ? options.extraEnv : {};
724
+ const extraEnvPrefix = buildShellEnvPrefix(extraEnvObject);
714
725
  const normalizedAgent = normalizeLaunchAgent(agent);
715
726
  if (!normalizedAgent) {
716
727
  throw new Error(`unsupported agent type: ${agent}`);
717
728
  }
718
729
 
719
730
  if (mode === "internal") {
720
- const result = await spawnInternalAgent(projectRoot, normalizedAgent, count, nickname, processManager);
731
+ const result = await spawnInternalAgent(
732
+ projectRoot,
733
+ normalizedAgent,
734
+ count,
735
+ nickname,
736
+ processManager,
737
+ extraEnvObject
738
+ );
721
739
  return { mode: "internal", launchScope, subscriberIds: result.subscriberIds };
722
740
  }
723
741
  if (mode === "tmux") {
@@ -750,15 +768,15 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
750
768
  const nick = count > 1 ? `${nickname || defaultNick}-${i + 1}` : (nickname || "");
751
769
  if (useSeparateWindow) {
752
770
  // eslint-disable-next-line no-await-in-loop
753
- await spawnTmuxWindow(projectRoot, normalizedAgent, nick);
771
+ await spawnTmuxWindow(projectRoot, normalizedAgent, nick, [], extraEnvPrefix);
754
772
  } else {
755
773
  try {
756
774
  // eslint-disable-next-line no-await-in-loop
757
- await spawnTmuxPane(projectRoot, normalizedAgent, nick, [], "", paneTarget);
775
+ await spawnTmuxPane(projectRoot, normalizedAgent, nick, [], extraEnvPrefix, paneTarget);
758
776
  } catch {
759
777
  // Fallback to new window when current pane target cannot be resolved.
760
778
  // eslint-disable-next-line no-await-in-loop
761
- await spawnTmuxWindow(projectRoot, normalizedAgent, nick);
779
+ await spawnTmuxWindow(projectRoot, normalizedAgent, nick, [], extraEnvPrefix);
762
780
  }
763
781
  }
764
782
  }
@@ -777,7 +795,7 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
777
795
  nick,
778
796
  processManager,
779
797
  [],
780
- "",
798
+ extraEnvPrefix,
781
799
  hostContext
782
800
  );
783
801
  if (result.subscriberId) subscriberIds.push(result.subscriberId);
@@ -801,7 +819,7 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
801
819
  nick,
802
820
  processManager,
803
821
  [],
804
- "",
822
+ extraEnvPrefix,
805
823
  launchScope,
806
824
  terminalApp
807
825
  );
@@ -120,7 +120,16 @@ async function finalizePromptRun({
120
120
  dispatchMessages,
121
121
  handleOps,
122
122
  markPending,
123
+ finalizeLocally = true,
123
124
  }) {
125
+ if (finalizeLocally === false) {
126
+ return {
127
+ ok: true,
128
+ payload,
129
+ opsResults: [],
130
+ };
131
+ }
132
+
124
133
  for (const item of payload.dispatch || []) {
125
134
  if (item && item.target && item.target !== "broadcast") {
126
135
  markPending(item.target);
@@ -151,12 +160,15 @@ async function runPromptWithAssistant({
151
160
  reportTaskStatus = () => {},
152
161
  maxAssistantLoops = 2,
153
162
  log = () => {},
163
+ ufooAgentOptions = {},
164
+ finalizeLocally = true,
154
165
  }) {
155
166
  const firstResult = await runUfooAgent({
156
167
  projectRoot,
157
168
  prompt: prompt || "",
158
169
  provider,
159
170
  model,
171
+ ...ufooAgentOptions,
160
172
  });
161
173
 
162
174
  if (!firstResult || !firstResult.ok) {
@@ -183,6 +195,7 @@ async function runPromptWithAssistant({
183
195
  dispatchMessages,
184
196
  handleOps,
185
197
  markPending,
198
+ finalizeLocally,
186
199
  });
187
200
  }
188
201
 
@@ -275,6 +288,7 @@ async function runPromptWithAssistant({
275
288
  prompt: continuationPrompt,
276
289
  provider,
277
290
  model,
291
+ ...ufooAgentOptions,
278
292
  });
279
293
 
280
294
  if (!secondResult || !secondResult.ok) {
@@ -286,6 +300,7 @@ async function runPromptWithAssistant({
286
300
  dispatchMessages,
287
301
  handleOps,
288
302
  markPending,
303
+ finalizeLocally,
289
304
  });
290
305
  }
291
306
 
@@ -305,6 +320,7 @@ async function runPromptWithAssistant({
305
320
  dispatchMessages,
306
321
  handleOps,
307
322
  markPending,
323
+ finalizeLocally,
308
324
  });
309
325
  }
310
326