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
package/src/chat/index.js CHANGED
@@ -9,7 +9,6 @@ const {
9
9
  saveConfig,
10
10
  normalizeLaunchMode,
11
11
  normalizeAgentProvider,
12
- normalizeAssistantEngine,
13
12
  } = require("../config");
14
13
  const { socketPath, isRunning } = require("../daemon");
15
14
  const UfooInit = require("../init");
@@ -46,11 +45,19 @@ const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
46
45
  const { createDaemonTransport } = require("./daemonTransport");
47
46
  const { listProjectRuntimes, resolveRuntimeDir } = require("../projects/registry");
48
47
  const { canonicalProjectRoot, buildProjectId } = require("../projects/projectId");
48
+ const { loadTemplateRegistry } = require("../group/templates");
49
49
  const {
50
50
  sortProjectRuntimes,
51
51
  parseTimestampMs,
52
52
  filterVisibleProjectRuntimes,
53
53
  } = require("./projectRuntimes");
54
+ const { isGlobalControllerProjectRoot, resolveGlobalControllerProjectRoot } = require("../globalMode");
55
+ const {
56
+ DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
57
+ setTransientAgentState: setTransientAgentStateValue,
58
+ getTransientAgentState,
59
+ pruneTransientAgentStates,
60
+ } = require("./transientAgentState");
54
61
 
55
62
  const MODE_OPTIONS = ["auto", "host", "terminal", "tmux", "internal"];
56
63
 
@@ -64,10 +71,25 @@ async function runChat(projectRoot, options = {}) {
64
71
  activeProjectRoot = path.resolve(projectRoot || process.cwd());
65
72
  }
66
73
 
67
- if (!fs.existsSync(getUfooPaths(projectRoot).ufooDir)) {
74
+ let globalScope = globalMode ? "controller" : "project";
75
+
76
+ const runtimePaths = getUfooPaths(projectRoot);
77
+ const contextIndexFile = path.join(runtimePaths.ufooDir, "context", "decisions.jsonl");
78
+ const needsGlobalControllerBootstrap = globalMode && (
79
+ !fs.existsSync(runtimePaths.ufooDir)
80
+ || !fs.existsSync(runtimePaths.busDir)
81
+ || !fs.existsSync(runtimePaths.agentDir)
82
+ || !fs.existsSync(contextIndexFile)
83
+ );
84
+
85
+ if (needsGlobalControllerBootstrap || !fs.existsSync(runtimePaths.ufooDir)) {
68
86
  const repoRoot = path.join(__dirname, "..", "..");
69
87
  const init = new UfooInit(repoRoot);
70
- await init.init({ modules: "context,bus", project: projectRoot });
88
+ await init.init({
89
+ modules: "context,bus",
90
+ project: projectRoot,
91
+ controllerMode: globalMode,
92
+ });
71
93
  }
72
94
 
73
95
  // Ensure subscriber ID exists for chat (persistent across restarts)
@@ -104,7 +126,6 @@ async function runChat(projectRoot, options = {}) {
104
126
  const config = loadConfig(projectRoot);
105
127
  let launchMode = config.launchMode;
106
128
  let agentProvider = config.agentProvider;
107
- let assistantEngine = normalizeAssistantEngine(config.assistantEngine);
108
129
  let autoResume = config.autoResume !== false;
109
130
  let cronTasks = [];
110
131
 
@@ -530,6 +551,14 @@ async function runChat(projectRoot, options = {}) {
530
551
  completionPanel,
531
552
  promptBox,
532
553
  commandRegistry: COMMAND_REGISTRY,
554
+ getGroupTemplateCandidates: () => {
555
+ const registry = loadTemplateRegistry(activeProjectRoot);
556
+ return registry.templates.map((item) => ({
557
+ alias: item.alias,
558
+ name: item.templateName || item.templateId || "",
559
+ source: item.source || "",
560
+ }));
561
+ },
533
562
  getMentionCandidates: () => activeAgents.map((id) => ({
534
563
  id,
535
564
  label: getAgentLabel(id),
@@ -560,6 +589,14 @@ async function runChat(projectRoot, options = {}) {
560
589
  getSelectedAgentIndex: () => selectedAgentIndex,
561
590
  getActiveAgents: () => activeAgents,
562
591
  getTargetAgent: () => targetAgent,
592
+ getGlobalScope: () => globalScope,
593
+ clearTargetAgent,
594
+ exitProjectScope: () => {
595
+ setGlobalScope("controller").catch((err) => {
596
+ const message = err && err.message ? err.message : String(err || "scope switch failed");
597
+ logMessage("error", `{white-fg}✗{/white-fg} Scope switch failed: ${escapeBlessed(message)}`);
598
+ });
599
+ },
563
600
  requestCloseAgent,
564
601
  logMessage,
565
602
  isSuppressKeypress: () => pasteController.isSuppressKeypress(),
@@ -675,7 +712,7 @@ async function runChat(projectRoot, options = {}) {
675
712
  let selectedAgentIndex = -1; // -1 = not in dashboard selection mode
676
713
  let targetAgent = null; // Selected agent for direct messaging
677
714
  let focusMode = "input"; // "input" or "dashboard"
678
- let dashboardView = "agents"; // "projects" | "agents" | "mode" | "provider" | "assistant" | "cron"
715
+ let dashboardView = "agents"; // "projects" | "agents" | "mode" | "provider" | "cron"
679
716
  let reportPendingTotal = 0;
680
717
  let selectedModeIndex = Math.max(0, MODE_OPTIONS.indexOf(launchMode));
681
718
  const providerOptions = [
@@ -684,31 +721,21 @@ async function runChat(projectRoot, options = {}) {
684
721
  { label: "ucode", value: "ucode" },
685
722
  ];
686
723
  let selectedProviderIndex = Math.max(0, providerOptions.findIndex((opt) => opt.value === agentProvider));
687
- const assistantOptions = [
688
- { label: "auto", value: "auto" },
689
- { label: "codex", value: "codex" },
690
- { label: "claude", value: "claude" },
691
- { label: "ucode", value: "ufoo" },
692
- ];
693
- let selectedAssistantIndex = Math.max(
694
- 0,
695
- assistantOptions.findIndex((opt) => opt.value === assistantEngine)
696
- );
697
724
  const resumeOptions = [
698
725
  { label: "Resume previous session", value: true },
699
726
  { label: "Start new session", value: false },
700
727
  ];
701
728
  let selectedResumeIndex = autoResume ? 0 : 1;
729
+ let selectedCronIndex = -1;
702
730
  const DASH_HINTS = {
703
731
  agents: "←/→ select · Enter · ↓ mode · ↑ back",
704
732
  agentsGlobal: "←/→ select · Enter · ↓ mode · ↑ projects",
705
733
  agentsEmpty: "↓ mode · ↑ back",
706
734
  mode: "←/→ select · Enter · ↓ provider · ↑ back",
707
- provider: "←/→ select · Enter · ↓ assistant · ↑ back",
708
- assistant: "←/→ select · Enter · ↓ cron · ↑ back",
709
- cron: "Ctrl+X close · ↑ back",
735
+ provider: "←/→ select · Enter · ↓ cron · ↑ back",
736
+ cron: "←/→ switch · Ctrl+X stop · ↑ back",
710
737
  resume: "",
711
- projects: "Use /project switch <index|path>",
738
+ projects: "Use /open <path> or /project switch <index|path>",
712
739
  projectsFocus: "←/→ switch · Ctrl+X close · ↓ second row · Enter confirm · ↑ back",
713
740
  projectsEmpty: "Run ufoo chat or ufoo daemon start in project directories",
714
741
  };
@@ -939,9 +966,13 @@ async function runChat(projectRoot, options = {}) {
939
966
  rows = [];
940
967
  }
941
968
  rows = filterVisibleProjectRuntimes(rows);
969
+ if (globalMode) {
970
+ rows = rows.filter((row) => !isGlobalControllerProjectRoot(resolveRuntimeProjectRoot(row)));
971
+ }
942
972
  const normalizedActive = String(activeProjectRoot || "");
943
973
  if (
944
974
  normalizedActive
975
+ && !(globalMode && isGlobalControllerProjectRoot(normalizedActive))
945
976
  && !rows.some((row) => resolveRuntimeProjectRoot(row) === normalizedActive)
946
977
  ) {
947
978
  rows.unshift({
@@ -1002,18 +1033,24 @@ async function runChat(projectRoot, options = {}) {
1002
1033
  }
1003
1034
 
1004
1035
  function updatePromptBox() {
1005
- if (targetAgent) {
1006
- const label = getAgentLabel(targetAgent);
1007
- promptBox.setContent(`>@${label}`);
1008
- promptBox.width = label.length + 3; // >@name + spacer
1009
- input.left = promptBox.width;
1010
- input.width = `100%-${promptBox.width}`;
1011
- } else {
1012
- promptBox.setContent(">");
1013
- promptBox.width = 2;
1014
- input.left = 2;
1015
- input.width = "100%-2";
1036
+ // Determine scope prefix (only in global mode)
1037
+ let prefix = "";
1038
+ if (globalMode && globalScope === "controller") {
1039
+ prefix = "g";
1040
+ } else if (globalMode && globalScope === "project") {
1041
+ prefix = truncateText(path.basename(activeProjectRoot), 10, "");
1016
1042
  }
1043
+
1044
+ // Build content: [prefix]>[>@agent]
1045
+ const content = targetAgent
1046
+ ? `${prefix}>@${getAgentLabel(targetAgent)}`
1047
+ : `${prefix}>`;
1048
+
1049
+ promptBox.setContent(content);
1050
+ promptBox.width = content.length + 1; // content + spacer
1051
+ input.left = promptBox.width;
1052
+ input.width = `100%-${promptBox.width}`;
1053
+
1017
1054
  if (!input.parent || !promptBox.parent) return;
1018
1055
  resizeInput();
1019
1056
  if (typeof input._updateCursor === "function") {
@@ -1081,12 +1118,6 @@ async function runChat(projectRoot, options = {}) {
1081
1118
  }
1082
1119
  }
1083
1120
 
1084
- function setAssistantEngine(value) {
1085
- if (settingsController) {
1086
- settingsController.setAssistantEngine(value);
1087
- }
1088
- }
1089
-
1090
1121
  function setAutoResume(value) {
1091
1122
  if (settingsController) {
1092
1123
  settingsController.setAutoResume(value);
@@ -1103,7 +1134,6 @@ async function runChat(projectRoot, options = {}) {
1103
1134
  saveConfig,
1104
1135
  normalizeLaunchMode,
1105
1136
  normalizeAgentProvider,
1106
- normalizeAssistantEngine,
1107
1137
  fsModule: fs,
1108
1138
  getUfooPaths,
1109
1139
  logMessage,
@@ -1124,14 +1154,6 @@ async function runChat(projectRoot, options = {}) {
1124
1154
  setSelectedProviderIndex: (value) => {
1125
1155
  selectedProviderIndex = value;
1126
1156
  },
1127
- getAssistantEngine: () => assistantEngine,
1128
- setAssistantEngineState: (value) => {
1129
- assistantEngine = value;
1130
- },
1131
- setSelectedAssistantIndex: (value) => {
1132
- selectedAssistantIndex = value;
1133
- },
1134
- assistantOptions,
1135
1157
  providerOptions,
1136
1158
  modeOptions: MODE_OPTIONS,
1137
1159
  getAutoResume: () => autoResume,
@@ -1154,6 +1176,7 @@ async function runChat(projectRoot, options = {}) {
1154
1176
  function renderDashboard() {
1155
1177
  const computed = computeDashboardContent({
1156
1178
  globalMode,
1179
+ globalScope,
1157
1180
  focusMode,
1158
1181
  dashboardView,
1159
1182
  activeAgents,
@@ -1175,20 +1198,19 @@ async function runChat(projectRoot, options = {}) {
1175
1198
  : "";
1176
1199
  }
1177
1200
  if (metaState) return metaState;
1178
- const transientState = transientAgentStateMap.get(agentId);
1179
- return typeof transientState === "string" ? transientState : "";
1201
+ return getTransientAgentState(transientAgentStateMap, agentId, {
1202
+ ttlMs: DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
1203
+ });
1180
1204
  },
1181
1205
  launchMode,
1182
1206
  agentProvider,
1183
- assistantEngine,
1184
1207
  autoResume,
1185
1208
  selectedModeIndex,
1186
1209
  selectedProviderIndex,
1187
- selectedAssistantIndex,
1188
1210
  selectedResumeIndex,
1211
+ selectedCronIndex,
1189
1212
  cronTasks,
1190
1213
  providerOptions,
1191
- assistantOptions,
1192
1214
  resumeOptions,
1193
1215
  pendingReports: reportPendingTotal,
1194
1216
  dashHints: DASH_HINTS,
@@ -1228,20 +1250,19 @@ async function runChat(projectRoot, options = {}) {
1228
1250
 
1229
1251
  function updateDashboard(status) {
1230
1252
  activeAgents = status.active || [];
1231
- if (transientAgentStateMap.size > 0) {
1232
- const activeSet = new Set(activeAgents);
1233
- for (const id of transientAgentStateMap.keys()) {
1234
- if (!activeSet.has(id)) {
1235
- transientAgentStateMap.delete(id);
1236
- }
1237
- }
1238
- }
1253
+ pruneTransientAgentStates(transientAgentStateMap, activeAgents, {
1254
+ ttlMs: DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
1255
+ });
1239
1256
  if (globalMode) {
1240
1257
  refreshProjectRuntimes();
1241
1258
  }
1242
- reportPendingTotal = Number.isFinite(status?.reports?.pending_total)
1259
+ const publicPending = Number.isFinite(status?.reports?.pending_total)
1243
1260
  ? status.reports.pending_total
1244
1261
  : 0;
1262
+ const controllerPending = Number.isFinite(status?.controller?.pending_total)
1263
+ ? status.controller.pending_total
1264
+ : 0;
1265
+ reportPendingTotal = publicPending + controllerPending;
1245
1266
  cronTasks = Array.isArray(status?.cron?.tasks) ? status.cron.tasks : [];
1246
1267
  const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
1247
1268
  let fallbackMap = null;
@@ -1317,6 +1338,12 @@ async function runChat(projectRoot, options = {}) {
1317
1338
  selectedAgentIndex = 0;
1318
1339
  }
1319
1340
  clampAgentWindow();
1341
+ } else if (dashboardView === "cron") {
1342
+ if (cronTasks.length === 0) {
1343
+ selectedCronIndex = -1;
1344
+ } else if (selectedCronIndex < 0 || selectedCronIndex >= cronTasks.length) {
1345
+ selectedCronIndex = Math.max(0, Math.min(selectedCronIndex, cronTasks.length - 1));
1346
+ }
1320
1347
  }
1321
1348
  }
1322
1349
  syncTargetFromSelection();
@@ -1329,7 +1356,14 @@ async function runChat(projectRoot, options = {}) {
1329
1356
  dashboardView = globalMode ? "projects" : "agents";
1330
1357
  if (globalMode) {
1331
1358
  refreshProjectRuntimes();
1332
- syncSelectedProjectToActive();
1359
+ if (globalScope === "project") {
1360
+ syncSelectedProjectToActive();
1361
+ } else {
1362
+ // Controller scope: no active project in list, init to 0 for navigation
1363
+ if (projectRuntimes.length > 0 && (selectedProjectIndex < 0 || selectedProjectIndex >= projectRuntimes.length)) {
1364
+ selectedProjectIndex = 0;
1365
+ }
1366
+ }
1333
1367
  } else {
1334
1368
  selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
1335
1369
  agentListWindowStart = 0;
@@ -1337,11 +1371,8 @@ async function runChat(projectRoot, options = {}) {
1337
1371
  }
1338
1372
  selectedModeIndex = Math.max(0, MODE_OPTIONS.indexOf(launchMode));
1339
1373
  selectedProviderIndex = Math.max(0, providerOptions.findIndex((opt) => opt.value === agentProvider));
1340
- selectedAssistantIndex = Math.max(
1341
- 0,
1342
- assistantOptions.findIndex((opt) => opt.value === assistantEngine)
1343
- );
1344
1374
  selectedResumeIndex = autoResume ? 0 : 1;
1375
+ selectedCronIndex = cronTasks.length > 0 ? 0 : -1;
1345
1376
  // Immediately set @target when first agent is selected.
1346
1377
  if (!globalMode && selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
1347
1378
  targetAgent = activeAgents[selectedAgentIndex];
@@ -1368,15 +1399,13 @@ async function runChat(projectRoot, options = {}) {
1368
1399
  activeAgentMetaMap: { get: () => activeAgentMetaMap },
1369
1400
  selectedModeIndex: { get: () => selectedModeIndex, set: (value) => { selectedModeIndex = value; } },
1370
1401
  selectedProviderIndex: { get: () => selectedProviderIndex, set: (value) => { selectedProviderIndex = value; } },
1371
- selectedAssistantIndex: { get: () => selectedAssistantIndex, set: (value) => { selectedAssistantIndex = value; } },
1372
1402
  selectedResumeIndex: { get: () => selectedResumeIndex, set: (value) => { selectedResumeIndex = value; } },
1403
+ selectedCronIndex: { get: () => selectedCronIndex, set: (value) => { selectedCronIndex = value; } },
1373
1404
  launchMode: { get: () => launchMode },
1374
1405
  agentProvider: { get: () => agentProvider },
1375
- assistantEngine: { get: () => assistantEngine },
1376
1406
  autoResume: { get: () => autoResume },
1377
1407
  cronTasks: { get: () => cronTasks },
1378
1408
  providerOptions: { get: () => providerOptions },
1379
- assistantOptions: { get: () => assistantOptions },
1380
1409
  resumeOptions: { get: () => resumeOptions },
1381
1410
  agentOutputSuppressed: {
1382
1411
  get: () => getAgentOutputSuppressed(),
@@ -1415,12 +1444,24 @@ async function runChat(projectRoot, options = {}) {
1415
1444
  exitDashboardMode,
1416
1445
  setLaunchMode,
1417
1446
  setAgentProvider,
1418
- setAssistantEngine,
1419
1447
  setAutoResume,
1420
1448
  clampAgentWindow,
1421
1449
  clampAgentWindowWithSelection,
1422
1450
  requestProjectSwitch: requestProjectSwitchByIndex,
1423
1451
  requestCloseProject: requestCloseProjectByIndex,
1452
+ requestCron: (payload = {}) => {
1453
+ send({
1454
+ type: IPC_REQUEST_TYPES.CRON,
1455
+ ...payload,
1456
+ });
1457
+ },
1458
+ setGlobalScope: (scope, targetProjectRoot) => {
1459
+ setGlobalScope(scope, targetProjectRoot).catch((err) => {
1460
+ const message = err && err.message ? err.message : String(err || "scope switch failed");
1461
+ logMessage("error", `{white-fg}✗{/white-fg} Scope switch failed: ${escapeBlessed(message)}`);
1462
+ });
1463
+ },
1464
+ getGlobalScope: () => globalScope,
1424
1465
  renderDashboard,
1425
1466
  renderAgentDashboard,
1426
1467
  renderScreen: () => screen.render(),
@@ -1557,7 +1598,7 @@ async function runChat(projectRoot, options = {}) {
1557
1598
  hasStream: (publisher) => streamTracker.hasStream(publisher),
1558
1599
  setTransientAgentState: (agentId, state) => {
1559
1600
  if (!agentId || !state) return;
1560
- transientAgentStateMap.set(agentId, state);
1601
+ setTransientAgentStateValue(transientAgentStateMap, agentId, state);
1561
1602
  },
1562
1603
  clearTransientAgentState: (agentId) => {
1563
1604
  if (!agentId) return;
@@ -1669,6 +1710,7 @@ async function runChat(projectRoot, options = {}) {
1669
1710
  if (globalMode) {
1670
1711
  refreshProjectRuntimes();
1671
1712
  syncSelectedProjectToActive();
1713
+ updatePromptBox();
1672
1714
  renderDashboard();
1673
1715
  screen.render();
1674
1716
  }
@@ -1684,6 +1726,44 @@ async function runChat(projectRoot, options = {}) {
1684
1726
  }
1685
1727
  }
1686
1728
 
1729
+ async function setGlobalScope(scope, targetProjectRoot) {
1730
+ if (!globalMode) return;
1731
+
1732
+ if (scope === "controller") {
1733
+ if (globalScope === "controller") return;
1734
+ const controllerRoot = resolveGlobalControllerProjectRoot();
1735
+ if (activeProjectRoot !== controllerRoot) {
1736
+ const result = await requestProjectSwitchByTarget(controllerRoot);
1737
+ if (!result || !result.ok) {
1738
+ const reason = (result && result.error) || "switch to controller failed";
1739
+ logMessage("error", `{white-fg}✗{/white-fg} Scope switch failed: ${escapeBlessed(reason)}`);
1740
+ return;
1741
+ }
1742
+ }
1743
+ globalScope = "controller";
1744
+ targetAgent = null;
1745
+ updatePromptBox();
1746
+ if (projectRuntimes.length > 0 && (selectedProjectIndex < 0 || selectedProjectIndex >= projectRuntimes.length)) {
1747
+ selectedProjectIndex = 0;
1748
+ }
1749
+ renderDashboard();
1750
+ screen.render();
1751
+ } else if (scope === "project") {
1752
+ if (!targetProjectRoot) return;
1753
+ targetAgent = null;
1754
+ const result = await requestProjectSwitchByTarget(targetProjectRoot);
1755
+ if (!result || !result.ok) {
1756
+ const reason = (result && result.error) || "switch to project failed";
1757
+ logMessage("error", `{white-fg}✗{/white-fg} Scope switch failed: ${escapeBlessed(reason)}`);
1758
+ return;
1759
+ }
1760
+ globalScope = "project";
1761
+ updatePromptBox();
1762
+ renderDashboard();
1763
+ screen.render();
1764
+ }
1765
+ }
1766
+
1687
1767
  let projectSwitching = false;
1688
1768
  let pendingProjectSwitchRoot = null;
1689
1769
  let projectSwitchDebounceTimer = null;
@@ -1854,9 +1934,12 @@ async function runChat(projectRoot, options = {}) {
1854
1934
  listProjects: () => listProjectRuntimes({ validate: true, cleanupTmp: true }),
1855
1935
  getCurrentProject: () => ({
1856
1936
  project_root: activeProjectRoot,
1857
- project_name: path.basename(activeProjectRoot),
1937
+ project_name: globalMode && isGlobalControllerProjectRoot(activeProjectRoot)
1938
+ ? "global-controller"
1939
+ : path.basename(activeProjectRoot),
1858
1940
  }),
1859
1941
  switchProject: async ({ target } = {}) => requestProjectSwitchByTarget(target),
1942
+ globalMode,
1860
1943
  });
1861
1944
 
1862
1945
  async function executeCommand(text) {
@@ -1972,13 +2055,6 @@ async function runChat(projectRoot, options = {}) {
1972
2055
  focusInput();
1973
2056
  });
1974
2057
 
1975
- // Escape in input mode only clears @target, never exits
1976
- input.key(["escape"], () => {
1977
- if (targetAgent) {
1978
- clearTargetAgent();
1979
- }
1980
- });
1981
-
1982
2058
  focusInput();
1983
2059
  if (screen.program && typeof screen.program.decset === "function") {
1984
2060
  screen.program.decset(2004);
@@ -1996,6 +2072,7 @@ async function runChat(projectRoot, options = {}) {
1996
2072
  if (globalMode) {
1997
2073
  refreshProjectRuntimes();
1998
2074
  }
2075
+ updatePromptBox();
1999
2076
  renderDashboard();
2000
2077
  resizeInput();
2001
2078
  requestStatus();
@@ -7,6 +7,9 @@ function createInputListenerController(options = {}) {
7
7
  getSelectedAgentIndex = () => -1,
8
8
  getActiveAgents = () => [],
9
9
  getTargetAgent = () => null,
10
+ getGlobalScope = () => "",
11
+ clearTargetAgent = () => {},
12
+ exitProjectScope = () => {},
10
13
  requestCloseAgent = () => {},
11
14
  logMessage = () => {},
12
15
  isSuppressKeypress = () => false,
@@ -238,6 +241,17 @@ function createInputListenerController(options = {}) {
238
241
  }
239
242
 
240
243
  if (keyName === "escape") {
244
+ // Layer 1: clear @target agent
245
+ if (getTargetAgent && getTargetAgent()) {
246
+ clearTargetAgent();
247
+ return;
248
+ }
249
+ // Layer 2: exit project scope → global scope
250
+ if (getGlobalScope && getGlobalScope() === "project") {
251
+ exitProjectScope();
252
+ return;
253
+ }
254
+ // Layer 3: existing behavior (cancel input)
241
255
  if (textarea && typeof textarea._done === "function") {
242
256
  textarea._done(null, null);
243
257
  }
@@ -154,14 +154,18 @@ function createInputSubmitHandler(options = {}) {
154
154
  const choice = state.pending.disambiguate.candidates[idx - 1];
155
155
  if (choice) {
156
156
  queueStatusLine(`ufoo-agent processing (assigning ${choice.agent_id})`);
157
+ const requestMeta = {
158
+ source: "chat-dialog",
159
+ dispatch_default_injection_mode: "immediate",
160
+ allow_relevance_queue: true,
161
+ };
162
+ if (state.pending.project_root) {
163
+ requestMeta.force_project_root = state.pending.project_root;
164
+ }
157
165
  send({
158
166
  type: IPC_REQUEST_TYPES.PROMPT,
159
167
  text: `Use agent ${choice.agent_id} to handle: ${state.pending.original || "the request"}`,
160
- request_meta: {
161
- source: "chat-dialog",
162
- dispatch_default_injection_mode: "immediate",
163
- allow_relevance_queue: true,
164
- },
168
+ request_meta: requestMeta,
165
169
  });
166
170
  state.pending = null;
167
171
  } else {
@@ -6,7 +6,6 @@ function createSettingsController(options = {}) {
6
6
  saveConfig = () => {},
7
7
  normalizeLaunchMode = (value) => value,
8
8
  normalizeAgentProvider = (value) => value,
9
- normalizeAssistantEngine = (value) => value,
10
9
  fsModule,
11
10
  getUfooPaths = () => ({ agentDir: "" }),
12
11
  logMessage = () => {},
@@ -19,10 +18,6 @@ function createSettingsController(options = {}) {
19
18
  getAgentProvider = () => "codex-cli",
20
19
  setAgentProviderState = () => {},
21
20
  setSelectedProviderIndex = () => {},
22
- getAssistantEngine = () => "auto",
23
- setAssistantEngineState = () => {},
24
- setSelectedAssistantIndex = () => {},
25
- assistantOptions = [],
26
21
  providerOptions = [],
27
22
  modeOptions = [],
28
23
  getAutoResume = () => true,
@@ -41,14 +36,6 @@ function createSettingsController(options = {}) {
41
36
  return value === "claude-cli" ? "claude" : "codex";
42
37
  }
43
38
 
44
- function assistantLabel(value) {
45
- const normalized = normalizeAssistantEngine(value);
46
- if (normalized === "codex") return "codex";
47
- if (normalized === "claude") return "claude";
48
- if (normalized === "ufoo") return "ufoo";
49
- return "auto";
50
- }
51
-
52
39
  function clearUfooAgentIdentity() {
53
40
  const agentDir = getUfooPaths(projectRoot).agentDir;
54
41
  const stateFile = path.join(agentDir, "ufoo-agent.json");
@@ -106,26 +93,11 @@ function createSettingsController(options = {}) {
106
93
  return true;
107
94
  }
108
95
 
109
- function setAssistantEngine(engine) {
110
- const next = normalizeAssistantEngine(engine);
111
- if (next === getAssistantEngine()) return false;
112
- setAssistantEngineState(next);
113
- const idx = assistantOptions.findIndex((opt) => opt && opt.value === next);
114
- setSelectedAssistantIndex(idx >= 0 ? idx : 0);
115
- saveConfig(projectRoot, { assistantEngine: next });
116
- logMessage("status", `{white-fg}⚙{/white-fg} assistant-engine: ${assistantLabel(next)}`);
117
- renderDashboard();
118
- renderScreen();
119
- return true;
120
- }
121
-
122
96
  return {
123
97
  providerLabel,
124
- assistantLabel,
125
98
  clearUfooAgentIdentity,
126
99
  setLaunchMode,
127
100
  setAgentProvider,
128
- setAssistantEngine,
129
101
  setAutoResume,
130
102
  };
131
103
  }
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+
3
+ const DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS = 8000;
4
+
5
+ function normalizeNow(now) {
6
+ return Number.isFinite(now) ? now : Date.now();
7
+ }
8
+
9
+ function setTransientAgentState(store, agentId, state, now = Date.now()) {
10
+ if (!(store instanceof Map)) return;
11
+ const id = String(agentId || "").trim();
12
+ const nextState = String(state || "").trim();
13
+ if (!id || !nextState) return;
14
+ store.set(id, {
15
+ state: nextState,
16
+ updatedAt: normalizeNow(now),
17
+ });
18
+ }
19
+
20
+ function getTransientAgentState(store, agentId, options = {}) {
21
+ if (!(store instanceof Map)) return "";
22
+ const id = String(agentId || "").trim();
23
+ if (!id) return "";
24
+ const entry = store.get(id);
25
+ if (!entry) return "";
26
+
27
+ const ttlMs = Number.isFinite(options.ttlMs)
28
+ ? Math.max(0, Math.trunc(options.ttlMs))
29
+ : DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS;
30
+ const now = normalizeNow(options.now);
31
+ const state = typeof entry === "string" ? entry : String(entry.state || "").trim();
32
+ const updatedAt = typeof entry === "object" && Number.isFinite(entry.updatedAt)
33
+ ? entry.updatedAt
34
+ : now;
35
+
36
+ if (!state) {
37
+ store.delete(id);
38
+ return "";
39
+ }
40
+ if (ttlMs > 0 && now - updatedAt > ttlMs) {
41
+ store.delete(id);
42
+ return "";
43
+ }
44
+ return state;
45
+ }
46
+
47
+ function pruneTransientAgentStates(store, activeAgentIds = [], options = {}) {
48
+ if (!(store instanceof Map)) return;
49
+ const activeSet = new Set(Array.isArray(activeAgentIds) ? activeAgentIds : []);
50
+ for (const id of Array.from(store.keys())) {
51
+ if (!activeSet.has(id)) {
52
+ store.delete(id);
53
+ continue;
54
+ }
55
+ getTransientAgentState(store, id, options);
56
+ }
57
+ }
58
+
59
+ module.exports = {
60
+ DEFAULT_TRANSIENT_AGENT_STATE_TTL_MS,
61
+ setTransientAgentState,
62
+ getTransientAgentState,
63
+ pruneTransientAgentStates,
64
+ };