u-foo 1.4.1 → 1.5.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.
package/src/chat/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  const path = require("path");
2
+ const os = require("os");
3
+ const crypto = require("crypto");
2
4
  const blessed = require("blessed");
3
5
  const { execSync } = require("child_process");
4
6
  const fs = require("fs");
@@ -41,8 +43,19 @@ const { createDaemonCoordinator } = require("./daemonCoordinator");
41
43
  const { IPC_REQUEST_TYPES } = require("../shared/eventContract");
42
44
  const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
43
45
  const { createDaemonTransport } = require("./daemonTransport");
46
+ const { listProjectRuntimes } = require("../projects/registry");
47
+ const { canonicalProjectRoot, buildProjectId } = require("../projects/projectId");
48
+
49
+ async function runChat(projectRoot, options = {}) {
50
+ const globalMode = options && options.globalMode === true;
51
+ const DASHBOARD_HEIGHT = globalMode ? 2 : 1;
52
+ let activeProjectRoot = projectRoot;
53
+ try {
54
+ activeProjectRoot = canonicalProjectRoot(projectRoot);
55
+ } catch {
56
+ activeProjectRoot = path.resolve(projectRoot || process.cwd());
57
+ }
44
58
 
45
- async function runChat(projectRoot) {
46
59
  if (!fs.existsSync(getUfooPaths(projectRoot).ufooDir)) {
47
60
  const repoRoot = path.join(__dirname, "..", "..");
48
61
  const init = new UfooInit(repoRoot);
@@ -51,7 +64,6 @@ async function runChat(projectRoot) {
51
64
 
52
65
  // Ensure subscriber ID exists for chat (persistent across restarts)
53
66
  if (!process.env.UFOO_SUBSCRIBER_ID) {
54
- const crypto = require("crypto");
55
67
  const sessionFile = path.join(getUfooPaths(projectRoot).ufooDir, "chat", "session-id.txt");
56
68
  const sessionDir = path.dirname(sessionFile);
57
69
  fs.mkdirSync(sessionDir, { recursive: true });
@@ -88,10 +100,12 @@ async function runChat(projectRoot) {
88
100
  let autoResume = config.autoResume !== false;
89
101
  let cronTasks = [];
90
102
 
91
- // Dynamic input height settings
92
- // Layout: topLine(1) + content + bottomLine(1) + dashboard(1)
93
- const MIN_INPUT_HEIGHT = 4; // 1 content + 3
94
- const MAX_INPUT_HEIGHT = 9; // 6 content + 3
103
+ // Dynamic input height settings.
104
+ // Layout: dashboard(N) + inputBottom(1) + content + inputTop(1) + status(1)
105
+ const MIN_INPUT_CONTENT_HEIGHT = 1;
106
+ const MAX_INPUT_CONTENT_HEIGHT = 6;
107
+ const MIN_INPUT_HEIGHT = MIN_INPUT_CONTENT_HEIGHT + DASHBOARD_HEIGHT + 2;
108
+ const MAX_INPUT_HEIGHT = MAX_INPUT_CONTENT_HEIGHT + DASHBOARD_HEIGHT + 2;
95
109
  let currentInputHeight = MIN_INPUT_HEIGHT;
96
110
  const pkg = require("../../package.json");
97
111
  const {
@@ -108,18 +122,136 @@ async function runChat(projectRoot) {
108
122
  } = createChatLayout({
109
123
  blessed,
110
124
  currentInputHeight,
125
+ dashboardHeight: DASHBOARD_HEIGHT,
111
126
  version: pkg.version,
112
127
  });
113
128
 
114
- const historyDir = path.join(getUfooPaths(projectRoot).ufooDir, "chat");
115
- const historyFile = path.join(historyDir, "history.jsonl");
116
- const inputHistoryFile = path.join(historyDir, "input-history.jsonl");
129
+ const globalChatRoot = path.join(os.homedir(), ".ufoo", "chat");
130
+ const globalDraftsFile = path.join(globalChatRoot, "global-drafts.json");
131
+ const GLOBAL_DRAFT_PERSIST_DEBOUNCE_MS = 150;
132
+ let globalDraftsLoaded = false;
133
+ let globalDraftPersistTimer = null;
134
+ const globalDraftMap = new Map();
135
+
136
+ function safeCanonicalProjectRoot(targetRoot) {
137
+ try {
138
+ return canonicalProjectRoot(targetRoot);
139
+ } catch {
140
+ return path.resolve(targetRoot || process.cwd());
141
+ }
142
+ }
143
+
144
+ function resolveHistoryContext(targetProjectRoot) {
145
+ const canonicalRoot = safeCanonicalProjectRoot(targetProjectRoot);
146
+ if (!globalMode) {
147
+ const localHistoryDir = path.join(getUfooPaths(canonicalRoot).ufooDir, "chat");
148
+ return {
149
+ projectRoot: canonicalRoot,
150
+ historyDir: localHistoryDir,
151
+ historyFile: path.join(localHistoryDir, "history.jsonl"),
152
+ inputHistoryDir: localHistoryDir,
153
+ inputHistoryFile: path.join(localHistoryDir, "input-history.jsonl"),
154
+ };
155
+ }
156
+ let projectId = "";
157
+ try {
158
+ projectId = buildProjectId(canonicalRoot);
159
+ } catch {
160
+ projectId = crypto.createHash("sha256").update(canonicalRoot).digest("hex").slice(0, 16);
161
+ }
162
+ const globalHistoryDir = path.join(globalChatRoot, "global-history");
163
+ const globalInputHistoryDir = path.join(globalChatRoot, "global-input-history");
164
+ return {
165
+ projectRoot: canonicalRoot,
166
+ projectId,
167
+ historyDir: globalHistoryDir,
168
+ historyFile: path.join(globalHistoryDir, `${projectId}.jsonl`),
169
+ inputHistoryDir: globalInputHistoryDir,
170
+ inputHistoryFile: path.join(globalInputHistoryDir, `${projectId}.jsonl`),
171
+ };
172
+ }
173
+
174
+ function loadGlobalDraftsOnce() {
175
+ if (!globalMode || globalDraftsLoaded) return;
176
+ globalDraftsLoaded = true;
177
+ try {
178
+ const raw = fs.readFileSync(globalDraftsFile, "utf8");
179
+ const parsed = JSON.parse(String(raw || "{}"));
180
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
181
+ Object.entries(parsed).forEach(([projectRootKey, draft]) => {
182
+ if (typeof draft !== "string") return;
183
+ const canonicalKey = safeCanonicalProjectRoot(projectRootKey);
184
+ if (!canonicalKey) return;
185
+ globalDraftMap.set(canonicalKey, draft);
186
+ });
187
+ } catch {
188
+ // Ignore missing/invalid drafts file.
189
+ }
190
+ }
191
+
192
+ function writeGlobalDraftsToDisk() {
193
+ if (!globalMode) return;
194
+ const out = {};
195
+ for (const [projectRootKey, draft] of globalDraftMap.entries()) {
196
+ if (!projectRootKey) continue;
197
+ if (typeof draft !== "string" || draft.length === 0) continue;
198
+ out[projectRootKey] = draft;
199
+ }
200
+ try {
201
+ fs.mkdirSync(path.dirname(globalDraftsFile), { recursive: true });
202
+ fs.writeFileSync(globalDraftsFile, `${JSON.stringify(out, null, 2)}\n`, "utf8");
203
+ } catch {
204
+ // Ignore draft persistence failures.
205
+ }
206
+ }
207
+
208
+ function persistGlobalDrafts(options = {}) {
209
+ if (!globalMode) return;
210
+ const immediate = Boolean(options.immediate);
211
+ if (immediate) {
212
+ if (globalDraftPersistTimer) {
213
+ clearTimeout(globalDraftPersistTimer);
214
+ globalDraftPersistTimer = null;
215
+ }
216
+ writeGlobalDraftsToDisk();
217
+ return;
218
+ }
219
+ if (globalDraftPersistTimer) {
220
+ clearTimeout(globalDraftPersistTimer);
221
+ }
222
+ globalDraftPersistTimer = setTimeout(() => {
223
+ globalDraftPersistTimer = null;
224
+ writeGlobalDraftsToDisk();
225
+ }, GLOBAL_DRAFT_PERSIST_DEBOUNCE_MS);
226
+ }
227
+
228
+ function getProjectDraft(targetProjectRoot) {
229
+ if (!globalMode) return "";
230
+ loadGlobalDraftsOnce();
231
+ const canonicalRoot = safeCanonicalProjectRoot(targetProjectRoot);
232
+ return globalDraftMap.get(canonicalRoot) || "";
233
+ }
234
+
235
+ function setProjectDraft(targetProjectRoot, draft, options = {}) {
236
+ if (!globalMode) return;
237
+ loadGlobalDraftsOnce();
238
+ const canonicalRoot = safeCanonicalProjectRoot(targetProjectRoot);
239
+ const text = String(draft || "");
240
+ if (!text) {
241
+ globalDraftMap.delete(canonicalRoot);
242
+ } else {
243
+ globalDraftMap.set(canonicalRoot, text);
244
+ }
245
+ persistGlobalDrafts(options);
246
+ }
247
+
248
+ let currentHistoryContext = resolveHistoryContext(activeProjectRoot);
117
249
 
118
- const chatLogController = createChatLogController({
250
+ let chatLogController = createChatLogController({
119
251
  logBox,
120
252
  fsModule: fs,
121
- historyDir,
122
- historyFile,
253
+ historyDir: currentHistoryContext.historyDir,
254
+ historyFile: currentHistoryContext.historyFile,
123
255
  });
124
256
 
125
257
  const streamTracker = createStreamTracker({
@@ -293,12 +425,67 @@ async function runChat(projectRoot) {
293
425
  }
294
426
 
295
427
  inputHistoryController = createInputHistoryController({
296
- inputHistoryFile,
297
- historyDir,
428
+ inputHistoryFile: currentHistoryContext.inputHistoryFile,
429
+ historyDir: currentHistoryContext.inputHistoryDir,
298
430
  setInputValue,
299
431
  getInputValue: () => input.value || "",
300
432
  });
301
433
 
434
+ function captureCurrentProjectDraft() {
435
+ if (!inputHistoryController || typeof inputHistoryController.getDraftForPersistence !== "function") {
436
+ return input.value || "";
437
+ }
438
+ return inputHistoryController.getDraftForPersistence();
439
+ }
440
+
441
+ function seedGlobalHistoryFromProject(nextContext) {
442
+ if (!globalMode || !nextContext || !nextContext.projectRoot) return;
443
+ const projectUfooDir = getUfooPaths(nextContext.projectRoot).ufooDir;
444
+ const projectChatDir = path.join(projectUfooDir, "chat");
445
+ const projectHistoryFile = path.join(projectChatDir, "history.jsonl");
446
+ const projectInputHistoryFile = path.join(projectChatDir, "input-history.jsonl");
447
+ try {
448
+ if (!fs.existsSync(nextContext.historyFile) && fs.existsSync(projectHistoryFile)) {
449
+ fs.mkdirSync(path.dirname(nextContext.historyFile), { recursive: true });
450
+ fs.copyFileSync(projectHistoryFile, nextContext.historyFile);
451
+ }
452
+ } catch {
453
+ // best-effort seed only
454
+ }
455
+ try {
456
+ if (!fs.existsSync(nextContext.inputHistoryFile) && fs.existsSync(projectInputHistoryFile)) {
457
+ fs.mkdirSync(path.dirname(nextContext.inputHistoryFile), { recursive: true });
458
+ fs.copyFileSync(projectInputHistoryFile, nextContext.inputHistoryFile);
459
+ }
460
+ } catch {
461
+ // best-effort seed only
462
+ }
463
+ }
464
+
465
+ function applyProjectHistoryContext(nextProjectRoot) {
466
+ streamTracker.discardAll();
467
+ const nextContext = resolveHistoryContext(nextProjectRoot);
468
+ seedGlobalHistoryFromProject(nextContext);
469
+ currentHistoryContext = nextContext;
470
+ chatLogController.setHistoryTarget({
471
+ historyDir: nextContext.historyDir,
472
+ historyFile: nextContext.historyFile,
473
+ });
474
+ chatLogController.resetViewState();
475
+
476
+ inputHistoryController.setHistoryTarget({
477
+ inputHistoryFile: nextContext.inputHistoryFile,
478
+ historyDir: nextContext.inputHistoryDir,
479
+ });
480
+ inputHistoryController.loadInputHistory();
481
+ const nextDraft = getProjectDraft(nextContext.projectRoot);
482
+ inputHistoryController.restoreDraft(nextDraft);
483
+
484
+ clearLog();
485
+ loadHistory();
486
+ pending = null;
487
+ }
488
+
302
489
  function historyUp() {
303
490
  if (!inputHistoryController) return false;
304
491
  return inputHistoryController.historyUp();
@@ -310,6 +497,9 @@ async function runChat(projectRoot) {
310
497
  }
311
498
 
312
499
  function exitHandler() {
500
+ if (globalMode) {
501
+ setProjectDraft(activeProjectRoot, captureCurrentProjectDraft(), { immediate: true });
502
+ }
313
503
  if (daemonCoordinator) {
314
504
  daemonCoordinator.markExit();
315
505
  }
@@ -396,8 +586,8 @@ async function runChat(projectRoot) {
396
586
  if (innerWidth <= 0) return;
397
587
 
398
588
  const numLines = countLines(input.value, innerWidth);
399
- const contentHeight = Math.min(MAX_INPUT_HEIGHT - 3, Math.max(1, numLines));
400
- const targetHeight = contentHeight + 3; // +1 topLine +1 bottomLine +1 dashboard
589
+ const contentHeight = Math.min(MAX_INPUT_CONTENT_HEIGHT, Math.max(MIN_INPUT_CONTENT_HEIGHT, numLines));
590
+ const targetHeight = contentHeight + DASHBOARD_HEIGHT + 2;
401
591
 
402
592
  if (targetHeight !== currentInputHeight) {
403
593
  currentInputHeight = targetHeight;
@@ -408,7 +598,7 @@ async function runChat(projectRoot) {
408
598
  statusLine.bottom = currentInputHeight;
409
599
  // Reposition completion panel if active
410
600
  if (completionController.isActive()) completionController.reflow();
411
- // dashboard and inputBottomLine stay fixed at bottom 0 and 1
601
+ // dashboard and inputBottomLine stay fixed at the bottom region.
412
602
  logBox.height = Math.max(1, screen.height - currentInputHeight - 1);
413
603
  ensureInputCursorVisible();
414
604
  }
@@ -450,7 +640,7 @@ async function runChat(projectRoot) {
450
640
  currentInputHeight = MIN_INPUT_HEIGHT;
451
641
  if (inputHistoryController) inputHistoryController.setIndexToEnd();
452
642
  completionController.hide();
453
- const contentHeight = 1; // MIN content height
643
+ const contentHeight = MIN_INPUT_CONTENT_HEIGHT;
454
644
  input.height = contentHeight;
455
645
  promptBox.height = contentHeight;
456
646
  inputTopLine.bottom = currentInputHeight - 1;
@@ -467,10 +657,14 @@ async function runChat(projectRoot) {
467
657
  let activeAgentMetaMap = new Map(); // Store full meta including launch_mode
468
658
  let agentListWindowStart = 0;
469
659
  const MAX_AGENT_WINDOW = 4;
660
+ let projectRuntimes = [];
661
+ let projectListWindowStart = 0;
662
+ const MAX_PROJECT_WINDOW = 5;
663
+ let selectedProjectIndex = -1;
470
664
  let selectedAgentIndex = -1; // -1 = not in dashboard selection mode
471
665
  let targetAgent = null; // Selected agent for direct messaging
472
666
  let focusMode = "input"; // "input" or "dashboard"
473
- let dashboardView = "agents"; // "agents" | "mode" | "provider" | "assistant" | "cron"
667
+ let dashboardView = "agents"; // "projects" | "agents" | "mode" | "provider" | "assistant" | "cron"
474
668
  let reportPendingTotal = 0;
475
669
  let selectedModeIndex = launchMode === "internal" ? 2 : (launchMode === "tmux" ? 1 : 0);
476
670
  const providerOptions = [
@@ -496,12 +690,16 @@ async function runChat(projectRoot) {
496
690
  let selectedResumeIndex = autoResume ? 0 : 1;
497
691
  const DASH_HINTS = {
498
692
  agents: "←/→ select · Enter · ↓ mode · ↑ back",
693
+ agentsGlobal: "←/→ select · Enter · ↓ mode · ↑ projects",
499
694
  agentsEmpty: "↓ mode · ↑ back",
500
695
  mode: "←/→ select · Enter · ↓ provider · ↑ back",
501
696
  provider: "←/→ select · Enter · ↓ assistant · ↑ back",
502
697
  assistant: "←/→ select · Enter · ↓ cron · ↑ back",
503
698
  cron: "Ctrl+X close · ↑ back",
504
699
  resume: "",
700
+ projects: "Use /project switch <index|path>",
701
+ projectsFocus: "←/→ switch · ↓ second row · Enter confirm · ↑ back",
702
+ projectsEmpty: "Run ufoo chat or ufoo daemon start in project directories",
505
703
  };
506
704
  const AGENT_BAR_HINTS = {
507
705
  normal: "↓ agents",
@@ -668,7 +866,7 @@ async function runChat(projectRoot) {
668
866
  labelMap: activeAgentLabelMap,
669
867
  lookupNickname: (nickname) => {
670
868
  try {
671
- const busPath = getUfooPaths(projectRoot).agentsFile;
869
+ const busPath = getUfooPaths(activeProjectRoot).agentsFile;
672
870
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
673
871
  for (const [id, meta] of Object.entries(bus.agents || {})) {
674
872
  if (meta && meta.nickname === nickname) return id;
@@ -687,7 +885,7 @@ async function runChat(projectRoot) {
687
885
  labelMap: activeAgentLabelMap,
688
886
  lookupNicknameById: (id) => {
689
887
  try {
690
- const busPath = getUfooPaths(projectRoot).agentsFile;
888
+ const busPath = getUfooPaths(activeProjectRoot).agentsFile;
691
889
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
692
890
  const meta = bus.agents && bus.agents[id];
693
891
  if (meta && meta.nickname) return meta.nickname;
@@ -712,6 +910,60 @@ async function runChat(projectRoot) {
712
910
  clampAgentWindowWithSelection(selectedAgentIndex);
713
911
  }
714
912
 
913
+ function resolveRuntimeProjectRoot(row = {}) {
914
+ const raw = row && row.project_root ? String(row.project_root) : "";
915
+ if (!raw) return "";
916
+ try {
917
+ return canonicalProjectRoot(raw);
918
+ } catch {
919
+ return path.resolve(raw);
920
+ }
921
+ }
922
+
923
+ function refreshProjectRuntimes() {
924
+ let rows = [];
925
+ try {
926
+ rows = listProjectRuntimes({ validate: true, cleanupTmp: true });
927
+ } catch {
928
+ rows = [];
929
+ }
930
+ const normalizedActive = String(activeProjectRoot || "");
931
+ if (
932
+ normalizedActive
933
+ && !rows.some((row) => resolveRuntimeProjectRoot(row) === normalizedActive)
934
+ ) {
935
+ rows.unshift({
936
+ project_root: normalizedActive,
937
+ project_name: path.basename(normalizedActive) || normalizedActive,
938
+ status: "untracked",
939
+ last_seen: null,
940
+ });
941
+ }
942
+ projectRuntimes = rows;
943
+
944
+ if (projectRuntimes.length === 0) {
945
+ selectedProjectIndex = -1;
946
+ projectListWindowStart = 0;
947
+ return;
948
+ }
949
+ const activeIndex = projectRuntimes.findIndex(
950
+ (row) => resolveRuntimeProjectRoot(row) === normalizedActive
951
+ );
952
+ if (selectedProjectIndex < 0 || selectedProjectIndex >= projectRuntimes.length) {
953
+ selectedProjectIndex = activeIndex >= 0 ? activeIndex : 0;
954
+ }
955
+ }
956
+
957
+ function syncSelectedProjectToActive() {
958
+ if (!Array.isArray(projectRuntimes) || projectRuntimes.length === 0) return;
959
+ const activeIndex = projectRuntimes.findIndex(
960
+ (row) => resolveRuntimeProjectRoot(row) === String(activeProjectRoot || "")
961
+ );
962
+ if (activeIndex >= 0) {
963
+ selectedProjectIndex = activeIndex;
964
+ }
965
+ }
966
+
715
967
  function send(req) {
716
968
  if (!daemonCoordinator) return;
717
969
  daemonCoordinator.send(req);
@@ -870,9 +1122,15 @@ async function runChat(projectRoot) {
870
1122
 
871
1123
  function renderDashboard() {
872
1124
  const computed = computeDashboardContent({
1125
+ globalMode,
873
1126
  focusMode,
874
1127
  dashboardView,
875
1128
  activeAgents,
1129
+ projects: projectRuntimes,
1130
+ selectedProjectIndex,
1131
+ projectListWindowStart,
1132
+ maxProjectWindow: MAX_PROJECT_WINDOW,
1133
+ activeProjectRoot,
876
1134
  selectedAgentIndex,
877
1135
  agentListWindowStart,
878
1136
  maxAgentWindow: MAX_AGENT_WINDOW,
@@ -892,12 +1150,23 @@ async function runChat(projectRoot) {
892
1150
  pendingReports: reportPendingTotal,
893
1151
  dashHints: DASH_HINTS,
894
1152
  });
895
- agentListWindowStart = computed.windowStart;
896
- dashboard.setContent(computed.content);
1153
+ if (globalMode && (focusMode !== "dashboard" || dashboardView === "projects")) {
1154
+ projectListWindowStart = computed.windowStart;
1155
+ } else {
1156
+ agentListWindowStart = computed.windowStart;
1157
+ }
1158
+ let dashboardContent = computed.content;
1159
+ if (globalMode && !String(dashboardContent || "").includes("\n")) {
1160
+ dashboardContent = `${dashboardContent}\n `;
1161
+ }
1162
+ dashboard.setContent(dashboardContent);
897
1163
  }
898
1164
 
899
1165
  function updateDashboard(status) {
900
1166
  activeAgents = status.active || [];
1167
+ if (globalMode) {
1168
+ refreshProjectRuntimes();
1169
+ }
901
1170
  reportPendingTotal = Number.isFinite(status?.reports?.pending_total)
902
1171
  ? status.reports.pending_total
903
1172
  : 0;
@@ -906,7 +1175,7 @@ async function runChat(projectRoot) {
906
1175
  let fallbackMap = null;
907
1176
  if (metaList.length === 0 && activeAgents.length > 0) {
908
1177
  try {
909
- const busPath = getUfooPaths(projectRoot).agentsFile;
1178
+ const busPath = getUfooPaths(activeProjectRoot).agentsFile;
910
1179
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
911
1180
  fallbackMap = new Map();
912
1181
  for (const [id, meta] of Object.entries(bus.agents || {})) {
@@ -957,10 +1226,15 @@ async function runChat(projectRoot) {
957
1226
 
958
1227
  function enterDashboardMode() {
959
1228
  focusMode = "dashboard";
960
- dashboardView = "agents";
961
- selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
962
- agentListWindowStart = 0;
963
- clampAgentWindow();
1229
+ dashboardView = globalMode ? "projects" : "agents";
1230
+ if (globalMode) {
1231
+ refreshProjectRuntimes();
1232
+ syncSelectedProjectToActive();
1233
+ } else {
1234
+ selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
1235
+ agentListWindowStart = 0;
1236
+ clampAgentWindow();
1237
+ }
964
1238
  selectedModeIndex = launchMode === "internal" ? 2 : (launchMode === "tmux" ? 1 : 0);
965
1239
  selectedProviderIndex = Math.max(0, providerOptions.findIndex((opt) => opt.value === agentProvider));
966
1240
  selectedAssistantIndex = Math.max(
@@ -968,8 +1242,8 @@ async function runChat(projectRoot) {
968
1242
  assistantOptions.findIndex((opt) => opt.value === assistantEngine)
969
1243
  );
970
1244
  selectedResumeIndex = autoResume ? 0 : 1;
971
- // Immediately set @target when first agent is selected
972
- if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
1245
+ // Immediately set @target when first agent is selected.
1246
+ if (!globalMode && selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
973
1247
  targetAgent = activeAgents[selectedAgentIndex];
974
1248
  updatePromptBox();
975
1249
  }
@@ -985,6 +1259,9 @@ async function runChat(projectRoot) {
985
1259
  currentView: { get: () => getCurrentView() },
986
1260
  focusMode: { get: () => focusMode, set: (value) => { focusMode = value; } },
987
1261
  dashboardView: { get: () => dashboardView, set: (value) => { dashboardView = value; } },
1262
+ selectedProjectIndex: { get: () => selectedProjectIndex, set: (value) => { selectedProjectIndex = value; } },
1263
+ projects: { get: () => projectRuntimes },
1264
+ activeProjectRoot: { get: () => activeProjectRoot },
988
1265
  selectedAgentIndex: { get: () => selectedAgentIndex, set: (value) => { selectedAgentIndex = value; } },
989
1266
  activeAgents: { get: () => activeAgents },
990
1267
  viewingAgent: { get: () => getViewingAgent() },
@@ -1009,7 +1286,7 @@ async function runChat(projectRoot) {
1009
1286
 
1010
1287
  function activateAgent(agentId) {
1011
1288
  if (!agentId) return;
1012
- const activator = new AgentActivator(projectRoot);
1289
+ const activator = new AgentActivator(activeProjectRoot);
1013
1290
  activator.activate(agentId).catch(() => {});
1014
1291
  }
1015
1292
 
@@ -1022,6 +1299,7 @@ async function runChat(projectRoot) {
1022
1299
 
1023
1300
  const dashboardController = createDashboardKeyController({
1024
1301
  state: dashboardState,
1302
+ globalMode,
1025
1303
  existsSync: fs.existsSync,
1026
1304
  getInjectSockPath,
1027
1305
  getAgentAdapter,
@@ -1041,6 +1319,7 @@ async function runChat(projectRoot) {
1041
1319
  setAutoResume,
1042
1320
  clampAgentWindow,
1043
1321
  clampAgentWindowWithSelection,
1322
+ requestProjectSwitch: requestProjectSwitchByIndex,
1044
1323
  renderDashboard,
1045
1324
  renderAgentDashboard,
1046
1325
  renderScreen: () => screen.render(),
@@ -1059,8 +1338,9 @@ async function runChat(projectRoot) {
1059
1338
  updatePromptBox();
1060
1339
  }
1061
1340
  focusMode = "input";
1062
- dashboardView = "agents";
1341
+ dashboardView = globalMode ? "projects" : "agents";
1063
1342
  selectedAgentIndex = -1;
1343
+ // Keep selectedProjectIndex across focus transitions so global rail preserves context.
1064
1344
  screen.grabKeys = false;
1065
1345
  renderDashboard();
1066
1346
  focusInput();
@@ -1075,7 +1355,7 @@ async function runChat(projectRoot) {
1075
1355
 
1076
1356
  function getInjectSockPath(agentId) {
1077
1357
  const safeName = subscriberToSafeName(agentId);
1078
- return path.join(getUfooPaths(projectRoot).busQueuesDir, safeName, "inject.sock");
1358
+ return path.join(getUfooPaths(activeProjectRoot).busQueuesDir, safeName, "inject.sock");
1079
1359
  }
1080
1360
 
1081
1361
  agentViewController = createAgentViewController({
@@ -1180,8 +1460,8 @@ async function runChat(projectRoot) {
1180
1460
  const connected = await daemonCoordinator.connect();
1181
1461
  if (!connected) {
1182
1462
  // Check if daemon failed to start
1183
- if (!isRunning(projectRoot)) {
1184
- const logFile = getUfooPaths(projectRoot).ufooDaemonLog;
1463
+ if (!isRunning(activeProjectRoot)) {
1464
+ const logFile = getUfooPaths(activeProjectRoot).ufooDaemonLog;
1185
1465
  // eslint-disable-next-line no-console
1186
1466
  console.error("Failed to start ufoo daemon. Check logs at:", logFile);
1187
1467
  throw new Error("Daemon failed to start. Check the daemon log for details.");
@@ -1189,6 +1469,214 @@ async function runChat(projectRoot) {
1189
1469
  throw new Error("Failed to connect to ufoo daemon (timeout). The daemon may still be starting.");
1190
1470
  }
1191
1471
 
1472
+ function resolveProjectSwitchTarget(rawTarget) {
1473
+ const target = String(rawTarget || "").trim();
1474
+ if (!target) {
1475
+ throw new Error("missing target");
1476
+ }
1477
+ if (/^\d+$/.test(target)) {
1478
+ const index = Number.parseInt(target, 10);
1479
+ if (!Number.isFinite(index) || index <= 0) {
1480
+ throw new Error("invalid project index");
1481
+ }
1482
+ const rows = listProjectRuntimes({ validate: true, cleanupTmp: true });
1483
+ const item = rows[index - 1];
1484
+ if (!item || !item.project_root) {
1485
+ throw new Error("project index out of range");
1486
+ }
1487
+ return {
1488
+ projectRoot: canonicalProjectRoot(item.project_root),
1489
+ source: `index ${index}`,
1490
+ };
1491
+ }
1492
+ return {
1493
+ projectRoot: canonicalProjectRoot(target),
1494
+ source: target,
1495
+ };
1496
+ }
1497
+
1498
+ async function switchProjectConnection(targetInput) {
1499
+ let targetInfo;
1500
+ try {
1501
+ targetInfo = resolveProjectSwitchTarget(targetInput);
1502
+ } catch (err) {
1503
+ return {
1504
+ ok: false,
1505
+ error: err && err.message ? err.message : "invalid project target",
1506
+ };
1507
+ }
1508
+ const nextProjectRoot = targetInfo.projectRoot;
1509
+ if (!nextProjectRoot) {
1510
+ return { ok: false, error: "invalid project target" };
1511
+ }
1512
+ if (nextProjectRoot === activeProjectRoot) {
1513
+ return { ok: true, project_root: activeProjectRoot, unchanged: true };
1514
+ }
1515
+ const outgoingDraftSnapshot = captureCurrentProjectDraft();
1516
+
1517
+ try {
1518
+ const nextPaths = getUfooPaths(nextProjectRoot);
1519
+ if (!fs.existsSync(nextPaths.ufooDir)) {
1520
+ const repoRoot = path.join(__dirname, "..", "..");
1521
+ const init = new UfooInit(repoRoot);
1522
+ await init.init({ modules: "context,bus", project: nextProjectRoot });
1523
+ }
1524
+ if (!isRunning(nextProjectRoot)) {
1525
+ startDaemon(nextProjectRoot);
1526
+ }
1527
+ const result = await daemonCoordinator.switchProject({
1528
+ projectRoot: nextProjectRoot,
1529
+ sockPath: socketPath(nextProjectRoot),
1530
+ });
1531
+ if (!result || result.ok !== true) {
1532
+ return {
1533
+ ok: false,
1534
+ error: (result && result.error) || "switch failed",
1535
+ };
1536
+ }
1537
+ const previousProjectRoot = activeProjectRoot;
1538
+ if (previousProjectRoot && previousProjectRoot !== nextProjectRoot) {
1539
+ setProjectDraft(previousProjectRoot, outgoingDraftSnapshot);
1540
+ }
1541
+ activeProjectRoot = nextProjectRoot;
1542
+ applyProjectHistoryContext(nextProjectRoot);
1543
+ if (globalMode) {
1544
+ refreshProjectRuntimes();
1545
+ syncSelectedProjectToActive();
1546
+ renderDashboard();
1547
+ screen.render();
1548
+ }
1549
+ return {
1550
+ ok: true,
1551
+ project_root: activeProjectRoot,
1552
+ };
1553
+ } catch (err) {
1554
+ return {
1555
+ ok: false,
1556
+ error: err && err.message ? err.message : "switch failed",
1557
+ };
1558
+ }
1559
+ }
1560
+
1561
+ let projectSwitching = false;
1562
+ let pendingProjectSwitchRoot = null;
1563
+ let projectSwitchDebounceTimer = null;
1564
+ let projectSwitchFlushPromise = null;
1565
+ const PROJECT_SWITCH_DEBOUNCE_MS = 200;
1566
+
1567
+ function cancelProjectSwitchDebounce() {
1568
+ if (!projectSwitchDebounceTimer) return;
1569
+ clearTimeout(projectSwitchDebounceTimer);
1570
+ projectSwitchDebounceTimer = null;
1571
+ }
1572
+
1573
+ function scheduleProjectSwitchFlush(delayMs = PROJECT_SWITCH_DEBOUNCE_MS) {
1574
+ cancelProjectSwitchDebounce();
1575
+ projectSwitchDebounceTimer = setTimeout(() => {
1576
+ projectSwitchDebounceTimer = null;
1577
+ flushPendingProjectSwitch().catch((err) => {
1578
+ const message = err && err.message ? err.message : String(err || "switch failed");
1579
+ logMessage("error", `{white-fg}✗{/white-fg} Switch failed: ${escapeBlessed(message)}`);
1580
+ });
1581
+ }, Math.max(0, Number.isFinite(delayMs) ? delayMs : PROJECT_SWITCH_DEBOUNCE_MS));
1582
+ }
1583
+
1584
+ async function flushPendingProjectSwitch() {
1585
+ if (projectSwitchFlushPromise) {
1586
+ return projectSwitchFlushPromise;
1587
+ }
1588
+ projectSwitchFlushPromise = (async () => {
1589
+ projectSwitching = true;
1590
+ let lastResult = { ok: true, project_root: activeProjectRoot, unchanged: true };
1591
+ try {
1592
+ while (pendingProjectSwitchRoot) {
1593
+ const nextProjectRoot = pendingProjectSwitchRoot;
1594
+ pendingProjectSwitchRoot = null;
1595
+ if (!nextProjectRoot || nextProjectRoot === activeProjectRoot) continue;
1596
+ const result = await switchProjectConnection(nextProjectRoot);
1597
+ lastResult = result || { ok: false, error: "switch failed" };
1598
+ if (!result || result.ok !== true) {
1599
+ const reason = (result && result.error) || "switch failed";
1600
+ logMessage("error", `{white-fg}✗{/white-fg} Switch failed: ${escapeBlessed(reason)}`);
1601
+ }
1602
+ }
1603
+ return lastResult;
1604
+ } finally {
1605
+ projectSwitching = false;
1606
+ if (globalMode) {
1607
+ refreshProjectRuntimes();
1608
+ syncSelectedProjectToActive();
1609
+ renderDashboard();
1610
+ screen.render();
1611
+ }
1612
+ }
1613
+ })();
1614
+ try {
1615
+ return await projectSwitchFlushPromise;
1616
+ } finally {
1617
+ projectSwitchFlushPromise = null;
1618
+ if (pendingProjectSwitchRoot && !projectSwitchDebounceTimer) {
1619
+ scheduleProjectSwitchFlush(0);
1620
+ }
1621
+ }
1622
+ }
1623
+
1624
+ function requestProjectSwitchByIndex(index) {
1625
+ if (!globalMode) return;
1626
+ const numericIndex = Number(index);
1627
+ const nextIndex = Number.isFinite(numericIndex) ? Math.trunc(numericIndex) : Number.NaN;
1628
+ if (!Number.isFinite(nextIndex) || nextIndex < 0 || nextIndex >= projectRuntimes.length) {
1629
+ return;
1630
+ }
1631
+ selectedProjectIndex = nextIndex;
1632
+ const selected = projectRuntimes[nextIndex] || {};
1633
+ const nextProjectRoot = resolveRuntimeProjectRoot(selected);
1634
+ renderDashboard();
1635
+ screen.render();
1636
+ if (!nextProjectRoot) return;
1637
+ pendingProjectSwitchRoot = nextProjectRoot;
1638
+ scheduleProjectSwitchFlush();
1639
+ }
1640
+
1641
+ async function requestProjectSwitchByTarget(targetInput) {
1642
+ let targetInfo;
1643
+ try {
1644
+ targetInfo = resolveProjectSwitchTarget(targetInput);
1645
+ } catch (err) {
1646
+ return {
1647
+ ok: false,
1648
+ error: err && err.message ? err.message : "invalid project target",
1649
+ };
1650
+ }
1651
+ const nextProjectRoot = targetInfo && targetInfo.projectRoot ? targetInfo.projectRoot : "";
1652
+ if (!nextProjectRoot) {
1653
+ return { ok: false, error: "invalid project target" };
1654
+ }
1655
+ if (nextProjectRoot === activeProjectRoot) {
1656
+ return { ok: true, project_root: activeProjectRoot, unchanged: true };
1657
+ }
1658
+
1659
+ pendingProjectSwitchRoot = nextProjectRoot;
1660
+ cancelProjectSwitchDebounce();
1661
+
1662
+ let attempts = 0;
1663
+ while (attempts < 4) {
1664
+ attempts += 1;
1665
+ const result = await flushPendingProjectSwitch();
1666
+ if (activeProjectRoot === nextProjectRoot) {
1667
+ return { ok: true, project_root: activeProjectRoot };
1668
+ }
1669
+ if (!pendingProjectSwitchRoot) {
1670
+ if (result && result.ok !== true) return result;
1671
+ return { ok: false, error: "switch failed" };
1672
+ }
1673
+ if (pendingProjectSwitchRoot !== nextProjectRoot) {
1674
+ pendingProjectSwitchRoot = nextProjectRoot;
1675
+ }
1676
+ }
1677
+ return { ok: false, error: "switch did not complete" };
1678
+ }
1679
+
1192
1680
  const commandExecutor = createCommandExecutor({
1193
1681
  projectRoot,
1194
1682
  parseCommand,
@@ -1211,9 +1699,15 @@ async function runChat(projectRoot) {
1211
1699
  });
1212
1700
  },
1213
1701
  activateAgent: async (target) => {
1214
- const activator = new AgentActivator(projectRoot);
1702
+ const activator = new AgentActivator(activeProjectRoot);
1215
1703
  await activator.activate(target);
1216
1704
  },
1705
+ listProjects: () => listProjectRuntimes({ validate: true, cleanupTmp: true }),
1706
+ getCurrentProject: () => ({
1707
+ project_root: activeProjectRoot,
1708
+ project_name: path.basename(activeProjectRoot),
1709
+ }),
1710
+ switchProject: async ({ target } = {}) => requestProjectSwitchByTarget(target),
1217
1711
  });
1218
1712
 
1219
1713
  async function executeCommand(text) {
@@ -1246,7 +1740,7 @@ async function runChat(projectRoot) {
1246
1740
  },
1247
1741
  enterAgentView,
1248
1742
  activateAgent: async (agentId) => {
1249
- const activator = new AgentActivator(projectRoot);
1743
+ const activator = new AgentActivator(activeProjectRoot);
1250
1744
  await activator.activate(agentId);
1251
1745
  },
1252
1746
  getInjectSockPath,
@@ -1347,6 +1841,12 @@ async function runChat(projectRoot) {
1347
1841
  }
1348
1842
  loadHistory();
1349
1843
  loadInputHistory();
1844
+ if (globalMode) {
1845
+ inputHistoryController.restoreDraft(getProjectDraft(activeProjectRoot));
1846
+ }
1847
+ if (globalMode) {
1848
+ refreshProjectRuntimes();
1849
+ }
1350
1850
  renderDashboard();
1351
1851
  resizeInput();
1352
1852
  requestStatus();