u-foo 1.4.1 → 1.6.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/README.md +21 -0
- package/README.zh-CN.md +21 -0
- package/bin/ufoo.js +15 -7
- package/modules/AGENTS.template.md +4 -102
- package/package.json +3 -2
- package/scripts/global-chat-switch-benchmark.js +406 -0
- package/src/agent/activityDetector.js +328 -0
- package/src/agent/activityStatePublisher.js +67 -0
- package/src/agent/activityStateWriter.js +40 -0
- package/src/agent/internalRunner.js +13 -0
- package/src/agent/launcher.js +47 -7
- package/src/agent/notifier.js +73 -4
- package/src/agent/ptyRunner.js +81 -34
- package/src/agent/ufooAgent.js +192 -6
- package/src/bus/message.js +1 -9
- package/src/bus/subscriber.js +2 -0
- package/src/bus/utils.js +10 -0
- package/src/chat/agentBar.js +21 -3
- package/src/chat/agentViewController.js +2 -0
- package/src/chat/chatLogController.js +28 -5
- package/src/chat/commandExecutor.js +127 -3
- package/src/chat/commands.js +8 -0
- package/src/chat/daemonConnection.js +77 -4
- package/src/chat/daemonCoordinator.js +36 -0
- package/src/chat/daemonMessageRouter.js +22 -0
- package/src/chat/daemonTransport.js +47 -5
- package/src/chat/daemonTransportDefaults.js +1 -0
- package/src/chat/dashboardKeyController.js +89 -1
- package/src/chat/dashboardView.js +312 -93
- package/src/chat/index.js +683 -41
- package/src/chat/inputHistoryController.js +33 -3
- package/src/chat/inputListenerController.js +22 -12
- package/src/chat/layout.js +12 -7
- package/src/chat/projectCloseController.js +119 -0
- package/src/chat/projectRuntimes.js +55 -0
- package/src/chat/statusLineController.js +52 -6
- package/src/chat/streamTracker.js +6 -0
- package/src/chat/transport.js +41 -5
- package/src/cli.js +167 -4
- package/src/daemon/index.js +54 -5
- package/src/daemon/ipcServer.js +6 -1
- package/src/daemon/ops.js +245 -35
- package/src/daemon/status.js +3 -1
- package/src/init/index.js +32 -3
- package/src/projects/projectId.js +29 -0
- package/src/projects/registry.js +279 -0
- package/src/ufoo/agentsStore.js +44 -0
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");
|
|
@@ -36,13 +38,30 @@ const { createChatLogController } = require("./chatLogController");
|
|
|
36
38
|
const { createPasteController } = require("./pasteController");
|
|
37
39
|
const { createAgentViewController } = require("./agentViewController");
|
|
38
40
|
const { createSettingsController } = require("./settingsController");
|
|
41
|
+
const { createProjectCloseController } = require("./projectCloseController");
|
|
39
42
|
const { createChatLayout } = require("./layout");
|
|
40
43
|
const { createDaemonCoordinator } = require("./daemonCoordinator");
|
|
41
44
|
const { IPC_REQUEST_TYPES } = require("../shared/eventContract");
|
|
42
45
|
const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
|
|
43
46
|
const { createDaemonTransport } = require("./daemonTransport");
|
|
47
|
+
const { listProjectRuntimes } = require("../projects/registry");
|
|
48
|
+
const { canonicalProjectRoot, buildProjectId } = require("../projects/projectId");
|
|
49
|
+
const {
|
|
50
|
+
sortProjectRuntimes,
|
|
51
|
+
parseTimestampMs,
|
|
52
|
+
filterVisibleProjectRuntimes,
|
|
53
|
+
} = require("./projectRuntimes");
|
|
54
|
+
|
|
55
|
+
async function runChat(projectRoot, options = {}) {
|
|
56
|
+
const globalMode = options && options.globalMode === true;
|
|
57
|
+
const DASHBOARD_HEIGHT = globalMode ? 2 : 1;
|
|
58
|
+
let activeProjectRoot = projectRoot;
|
|
59
|
+
try {
|
|
60
|
+
activeProjectRoot = canonicalProjectRoot(projectRoot);
|
|
61
|
+
} catch {
|
|
62
|
+
activeProjectRoot = path.resolve(projectRoot || process.cwd());
|
|
63
|
+
}
|
|
44
64
|
|
|
45
|
-
async function runChat(projectRoot) {
|
|
46
65
|
if (!fs.existsSync(getUfooPaths(projectRoot).ufooDir)) {
|
|
47
66
|
const repoRoot = path.join(__dirname, "..", "..");
|
|
48
67
|
const init = new UfooInit(repoRoot);
|
|
@@ -51,7 +70,6 @@ async function runChat(projectRoot) {
|
|
|
51
70
|
|
|
52
71
|
// Ensure subscriber ID exists for chat (persistent across restarts)
|
|
53
72
|
if (!process.env.UFOO_SUBSCRIBER_ID) {
|
|
54
|
-
const crypto = require("crypto");
|
|
55
73
|
const sessionFile = path.join(getUfooPaths(projectRoot).ufooDir, "chat", "session-id.txt");
|
|
56
74
|
const sessionDir = path.dirname(sessionFile);
|
|
57
75
|
fs.mkdirSync(sessionDir, { recursive: true });
|
|
@@ -88,10 +106,12 @@ async function runChat(projectRoot) {
|
|
|
88
106
|
let autoResume = config.autoResume !== false;
|
|
89
107
|
let cronTasks = [];
|
|
90
108
|
|
|
91
|
-
// Dynamic input height settings
|
|
92
|
-
// Layout:
|
|
93
|
-
const
|
|
94
|
-
const
|
|
109
|
+
// Dynamic input height settings.
|
|
110
|
+
// Layout: dashboard(N) + inputBottom(1) + content + inputTop(1) + status(1)
|
|
111
|
+
const MIN_INPUT_CONTENT_HEIGHT = 1;
|
|
112
|
+
const MAX_INPUT_CONTENT_HEIGHT = 6;
|
|
113
|
+
const MIN_INPUT_HEIGHT = MIN_INPUT_CONTENT_HEIGHT + DASHBOARD_HEIGHT + 2;
|
|
114
|
+
const MAX_INPUT_HEIGHT = MAX_INPUT_CONTENT_HEIGHT + DASHBOARD_HEIGHT + 2;
|
|
95
115
|
let currentInputHeight = MIN_INPUT_HEIGHT;
|
|
96
116
|
const pkg = require("../../package.json");
|
|
97
117
|
const {
|
|
@@ -108,18 +128,136 @@ async function runChat(projectRoot) {
|
|
|
108
128
|
} = createChatLayout({
|
|
109
129
|
blessed,
|
|
110
130
|
currentInputHeight,
|
|
131
|
+
dashboardHeight: DASHBOARD_HEIGHT,
|
|
111
132
|
version: pkg.version,
|
|
112
133
|
});
|
|
113
134
|
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
135
|
+
const globalChatRoot = path.join(os.homedir(), ".ufoo", "chat");
|
|
136
|
+
const globalDraftsFile = path.join(globalChatRoot, "global-drafts.json");
|
|
137
|
+
const GLOBAL_DRAFT_PERSIST_DEBOUNCE_MS = 150;
|
|
138
|
+
let globalDraftsLoaded = false;
|
|
139
|
+
let globalDraftPersistTimer = null;
|
|
140
|
+
const globalDraftMap = new Map();
|
|
141
|
+
|
|
142
|
+
function safeCanonicalProjectRoot(targetRoot) {
|
|
143
|
+
try {
|
|
144
|
+
return canonicalProjectRoot(targetRoot);
|
|
145
|
+
} catch {
|
|
146
|
+
return path.resolve(targetRoot || process.cwd());
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveHistoryContext(targetProjectRoot) {
|
|
151
|
+
const canonicalRoot = safeCanonicalProjectRoot(targetProjectRoot);
|
|
152
|
+
if (!globalMode) {
|
|
153
|
+
const localHistoryDir = path.join(getUfooPaths(canonicalRoot).ufooDir, "chat");
|
|
154
|
+
return {
|
|
155
|
+
projectRoot: canonicalRoot,
|
|
156
|
+
historyDir: localHistoryDir,
|
|
157
|
+
historyFile: path.join(localHistoryDir, "history.jsonl"),
|
|
158
|
+
inputHistoryDir: localHistoryDir,
|
|
159
|
+
inputHistoryFile: path.join(localHistoryDir, "input-history.jsonl"),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
let projectId = "";
|
|
163
|
+
try {
|
|
164
|
+
projectId = buildProjectId(canonicalRoot);
|
|
165
|
+
} catch {
|
|
166
|
+
projectId = crypto.createHash("sha256").update(canonicalRoot).digest("hex").slice(0, 16);
|
|
167
|
+
}
|
|
168
|
+
const globalHistoryDir = path.join(globalChatRoot, "global-history");
|
|
169
|
+
const globalInputHistoryDir = path.join(globalChatRoot, "global-input-history");
|
|
170
|
+
return {
|
|
171
|
+
projectRoot: canonicalRoot,
|
|
172
|
+
projectId,
|
|
173
|
+
historyDir: globalHistoryDir,
|
|
174
|
+
historyFile: path.join(globalHistoryDir, `${projectId}.jsonl`),
|
|
175
|
+
inputHistoryDir: globalInputHistoryDir,
|
|
176
|
+
inputHistoryFile: path.join(globalInputHistoryDir, `${projectId}.jsonl`),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function loadGlobalDraftsOnce() {
|
|
181
|
+
if (!globalMode || globalDraftsLoaded) return;
|
|
182
|
+
globalDraftsLoaded = true;
|
|
183
|
+
try {
|
|
184
|
+
const raw = fs.readFileSync(globalDraftsFile, "utf8");
|
|
185
|
+
const parsed = JSON.parse(String(raw || "{}"));
|
|
186
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
187
|
+
Object.entries(parsed).forEach(([projectRootKey, draft]) => {
|
|
188
|
+
if (typeof draft !== "string") return;
|
|
189
|
+
const canonicalKey = safeCanonicalProjectRoot(projectRootKey);
|
|
190
|
+
if (!canonicalKey) return;
|
|
191
|
+
globalDraftMap.set(canonicalKey, draft);
|
|
192
|
+
});
|
|
193
|
+
} catch {
|
|
194
|
+
// Ignore missing/invalid drafts file.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function writeGlobalDraftsToDisk() {
|
|
199
|
+
if (!globalMode) return;
|
|
200
|
+
const out = {};
|
|
201
|
+
for (const [projectRootKey, draft] of globalDraftMap.entries()) {
|
|
202
|
+
if (!projectRootKey) continue;
|
|
203
|
+
if (typeof draft !== "string" || draft.length === 0) continue;
|
|
204
|
+
out[projectRootKey] = draft;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
fs.mkdirSync(path.dirname(globalDraftsFile), { recursive: true });
|
|
208
|
+
fs.writeFileSync(globalDraftsFile, `${JSON.stringify(out, null, 2)}\n`, "utf8");
|
|
209
|
+
} catch {
|
|
210
|
+
// Ignore draft persistence failures.
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function persistGlobalDrafts(options = {}) {
|
|
215
|
+
if (!globalMode) return;
|
|
216
|
+
const immediate = Boolean(options.immediate);
|
|
217
|
+
if (immediate) {
|
|
218
|
+
if (globalDraftPersistTimer) {
|
|
219
|
+
clearTimeout(globalDraftPersistTimer);
|
|
220
|
+
globalDraftPersistTimer = null;
|
|
221
|
+
}
|
|
222
|
+
writeGlobalDraftsToDisk();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (globalDraftPersistTimer) {
|
|
226
|
+
clearTimeout(globalDraftPersistTimer);
|
|
227
|
+
}
|
|
228
|
+
globalDraftPersistTimer = setTimeout(() => {
|
|
229
|
+
globalDraftPersistTimer = null;
|
|
230
|
+
writeGlobalDraftsToDisk();
|
|
231
|
+
}, GLOBAL_DRAFT_PERSIST_DEBOUNCE_MS);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getProjectDraft(targetProjectRoot) {
|
|
235
|
+
if (!globalMode) return "";
|
|
236
|
+
loadGlobalDraftsOnce();
|
|
237
|
+
const canonicalRoot = safeCanonicalProjectRoot(targetProjectRoot);
|
|
238
|
+
return globalDraftMap.get(canonicalRoot) || "";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function setProjectDraft(targetProjectRoot, draft, options = {}) {
|
|
242
|
+
if (!globalMode) return;
|
|
243
|
+
loadGlobalDraftsOnce();
|
|
244
|
+
const canonicalRoot = safeCanonicalProjectRoot(targetProjectRoot);
|
|
245
|
+
const text = String(draft || "");
|
|
246
|
+
if (!text) {
|
|
247
|
+
globalDraftMap.delete(canonicalRoot);
|
|
248
|
+
} else {
|
|
249
|
+
globalDraftMap.set(canonicalRoot, text);
|
|
250
|
+
}
|
|
251
|
+
persistGlobalDrafts(options);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let currentHistoryContext = resolveHistoryContext(activeProjectRoot);
|
|
117
255
|
|
|
118
|
-
|
|
256
|
+
let chatLogController = createChatLogController({
|
|
119
257
|
logBox,
|
|
120
258
|
fsModule: fs,
|
|
121
|
-
historyDir,
|
|
122
|
-
historyFile,
|
|
259
|
+
historyDir: currentHistoryContext.historyDir,
|
|
260
|
+
historyFile: currentHistoryContext.historyFile,
|
|
123
261
|
});
|
|
124
262
|
|
|
125
263
|
const streamTracker = createStreamTracker({
|
|
@@ -293,12 +431,67 @@ async function runChat(projectRoot) {
|
|
|
293
431
|
}
|
|
294
432
|
|
|
295
433
|
inputHistoryController = createInputHistoryController({
|
|
296
|
-
inputHistoryFile,
|
|
297
|
-
historyDir,
|
|
434
|
+
inputHistoryFile: currentHistoryContext.inputHistoryFile,
|
|
435
|
+
historyDir: currentHistoryContext.inputHistoryDir,
|
|
298
436
|
setInputValue,
|
|
299
437
|
getInputValue: () => input.value || "",
|
|
300
438
|
});
|
|
301
439
|
|
|
440
|
+
function captureCurrentProjectDraft() {
|
|
441
|
+
if (!inputHistoryController || typeof inputHistoryController.getDraftForPersistence !== "function") {
|
|
442
|
+
return input.value || "";
|
|
443
|
+
}
|
|
444
|
+
return inputHistoryController.getDraftForPersistence();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function seedGlobalHistoryFromProject(nextContext) {
|
|
448
|
+
if (!globalMode || !nextContext || !nextContext.projectRoot) return;
|
|
449
|
+
const projectUfooDir = getUfooPaths(nextContext.projectRoot).ufooDir;
|
|
450
|
+
const projectChatDir = path.join(projectUfooDir, "chat");
|
|
451
|
+
const projectHistoryFile = path.join(projectChatDir, "history.jsonl");
|
|
452
|
+
const projectInputHistoryFile = path.join(projectChatDir, "input-history.jsonl");
|
|
453
|
+
try {
|
|
454
|
+
if (!fs.existsSync(nextContext.historyFile) && fs.existsSync(projectHistoryFile)) {
|
|
455
|
+
fs.mkdirSync(path.dirname(nextContext.historyFile), { recursive: true });
|
|
456
|
+
fs.copyFileSync(projectHistoryFile, nextContext.historyFile);
|
|
457
|
+
}
|
|
458
|
+
} catch {
|
|
459
|
+
// best-effort seed only
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
if (!fs.existsSync(nextContext.inputHistoryFile) && fs.existsSync(projectInputHistoryFile)) {
|
|
463
|
+
fs.mkdirSync(path.dirname(nextContext.inputHistoryFile), { recursive: true });
|
|
464
|
+
fs.copyFileSync(projectInputHistoryFile, nextContext.inputHistoryFile);
|
|
465
|
+
}
|
|
466
|
+
} catch {
|
|
467
|
+
// best-effort seed only
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function applyProjectHistoryContext(nextProjectRoot) {
|
|
472
|
+
streamTracker.discardAll();
|
|
473
|
+
const nextContext = resolveHistoryContext(nextProjectRoot);
|
|
474
|
+
seedGlobalHistoryFromProject(nextContext);
|
|
475
|
+
currentHistoryContext = nextContext;
|
|
476
|
+
chatLogController.setHistoryTarget({
|
|
477
|
+
historyDir: nextContext.historyDir,
|
|
478
|
+
historyFile: nextContext.historyFile,
|
|
479
|
+
});
|
|
480
|
+
chatLogController.resetViewState();
|
|
481
|
+
|
|
482
|
+
inputHistoryController.setHistoryTarget({
|
|
483
|
+
inputHistoryFile: nextContext.inputHistoryFile,
|
|
484
|
+
historyDir: nextContext.inputHistoryDir,
|
|
485
|
+
});
|
|
486
|
+
inputHistoryController.loadInputHistory();
|
|
487
|
+
const nextDraft = getProjectDraft(nextContext.projectRoot);
|
|
488
|
+
inputHistoryController.restoreDraft(nextDraft);
|
|
489
|
+
|
|
490
|
+
clearLog();
|
|
491
|
+
loadHistory();
|
|
492
|
+
pending = null;
|
|
493
|
+
}
|
|
494
|
+
|
|
302
495
|
function historyUp() {
|
|
303
496
|
if (!inputHistoryController) return false;
|
|
304
497
|
return inputHistoryController.historyUp();
|
|
@@ -310,6 +503,9 @@ async function runChat(projectRoot) {
|
|
|
310
503
|
}
|
|
311
504
|
|
|
312
505
|
function exitHandler() {
|
|
506
|
+
if (globalMode) {
|
|
507
|
+
setProjectDraft(activeProjectRoot, captureCurrentProjectDraft(), { immediate: true });
|
|
508
|
+
}
|
|
313
509
|
if (daemonCoordinator) {
|
|
314
510
|
daemonCoordinator.markExit();
|
|
315
511
|
}
|
|
@@ -396,8 +592,8 @@ async function runChat(projectRoot) {
|
|
|
396
592
|
if (innerWidth <= 0) return;
|
|
397
593
|
|
|
398
594
|
const numLines = countLines(input.value, innerWidth);
|
|
399
|
-
const contentHeight = Math.min(
|
|
400
|
-
const targetHeight = contentHeight +
|
|
595
|
+
const contentHeight = Math.min(MAX_INPUT_CONTENT_HEIGHT, Math.max(MIN_INPUT_CONTENT_HEIGHT, numLines));
|
|
596
|
+
const targetHeight = contentHeight + DASHBOARD_HEIGHT + 2;
|
|
401
597
|
|
|
402
598
|
if (targetHeight !== currentInputHeight) {
|
|
403
599
|
currentInputHeight = targetHeight;
|
|
@@ -408,7 +604,7 @@ async function runChat(projectRoot) {
|
|
|
408
604
|
statusLine.bottom = currentInputHeight;
|
|
409
605
|
// Reposition completion panel if active
|
|
410
606
|
if (completionController.isActive()) completionController.reflow();
|
|
411
|
-
// dashboard and inputBottomLine stay fixed at bottom
|
|
607
|
+
// dashboard and inputBottomLine stay fixed at the bottom region.
|
|
412
608
|
logBox.height = Math.max(1, screen.height - currentInputHeight - 1);
|
|
413
609
|
ensureInputCursorVisible();
|
|
414
610
|
}
|
|
@@ -450,7 +646,7 @@ async function runChat(projectRoot) {
|
|
|
450
646
|
currentInputHeight = MIN_INPUT_HEIGHT;
|
|
451
647
|
if (inputHistoryController) inputHistoryController.setIndexToEnd();
|
|
452
648
|
completionController.hide();
|
|
453
|
-
const contentHeight =
|
|
649
|
+
const contentHeight = MIN_INPUT_CONTENT_HEIGHT;
|
|
454
650
|
input.height = contentHeight;
|
|
455
651
|
promptBox.height = contentHeight;
|
|
456
652
|
inputTopLine.bottom = currentInputHeight - 1;
|
|
@@ -465,12 +661,17 @@ async function runChat(projectRoot) {
|
|
|
465
661
|
let activeAgents = [];
|
|
466
662
|
let activeAgentLabelMap = new Map();
|
|
467
663
|
let activeAgentMetaMap = new Map(); // Store full meta including launch_mode
|
|
664
|
+
const transientAgentStateMap = new Map();
|
|
468
665
|
let agentListWindowStart = 0;
|
|
469
666
|
const MAX_AGENT_WINDOW = 4;
|
|
667
|
+
let projectRuntimes = [];
|
|
668
|
+
let projectListWindowStart = 0;
|
|
669
|
+
const MAX_PROJECT_WINDOW = 5;
|
|
670
|
+
let selectedProjectIndex = -1;
|
|
470
671
|
let selectedAgentIndex = -1; // -1 = not in dashboard selection mode
|
|
471
672
|
let targetAgent = null; // Selected agent for direct messaging
|
|
472
673
|
let focusMode = "input"; // "input" or "dashboard"
|
|
473
|
-
let dashboardView = "agents"; // "agents" | "mode" | "provider" | "assistant" | "cron"
|
|
674
|
+
let dashboardView = "agents"; // "projects" | "agents" | "mode" | "provider" | "assistant" | "cron"
|
|
474
675
|
let reportPendingTotal = 0;
|
|
475
676
|
let selectedModeIndex = launchMode === "internal" ? 2 : (launchMode === "tmux" ? 1 : 0);
|
|
476
677
|
const providerOptions = [
|
|
@@ -496,12 +697,16 @@ async function runChat(projectRoot) {
|
|
|
496
697
|
let selectedResumeIndex = autoResume ? 0 : 1;
|
|
497
698
|
const DASH_HINTS = {
|
|
498
699
|
agents: "←/→ select · Enter · ↓ mode · ↑ back",
|
|
700
|
+
agentsGlobal: "←/→ select · Enter · ↓ mode · ↑ projects",
|
|
499
701
|
agentsEmpty: "↓ mode · ↑ back",
|
|
500
702
|
mode: "←/→ select · Enter · ↓ provider · ↑ back",
|
|
501
703
|
provider: "←/→ select · Enter · ↓ assistant · ↑ back",
|
|
502
704
|
assistant: "←/→ select · Enter · ↓ cron · ↑ back",
|
|
503
705
|
cron: "Ctrl+X close · ↑ back",
|
|
504
706
|
resume: "",
|
|
707
|
+
projects: "Use /project switch <index|path>",
|
|
708
|
+
projectsFocus: "←/→ switch · Ctrl+X close · ↓ second row · Enter confirm · ↑ back",
|
|
709
|
+
projectsEmpty: "Run ufoo chat or ufoo daemon start in project directories",
|
|
505
710
|
};
|
|
506
711
|
const AGENT_BAR_HINTS = {
|
|
507
712
|
normal: "↓ agents",
|
|
@@ -668,7 +873,7 @@ async function runChat(projectRoot) {
|
|
|
668
873
|
labelMap: activeAgentLabelMap,
|
|
669
874
|
lookupNickname: (nickname) => {
|
|
670
875
|
try {
|
|
671
|
-
const busPath = getUfooPaths(
|
|
876
|
+
const busPath = getUfooPaths(activeProjectRoot).agentsFile;
|
|
672
877
|
const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
|
|
673
878
|
for (const [id, meta] of Object.entries(bus.agents || {})) {
|
|
674
879
|
if (meta && meta.nickname === nickname) return id;
|
|
@@ -687,7 +892,7 @@ async function runChat(projectRoot) {
|
|
|
687
892
|
labelMap: activeAgentLabelMap,
|
|
688
893
|
lookupNicknameById: (id) => {
|
|
689
894
|
try {
|
|
690
|
-
const busPath = getUfooPaths(
|
|
895
|
+
const busPath = getUfooPaths(activeProjectRoot).agentsFile;
|
|
691
896
|
const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
|
|
692
897
|
const meta = bus.agents && bus.agents[id];
|
|
693
898
|
if (meta && meta.nickname) return meta.nickname;
|
|
@@ -712,6 +917,81 @@ async function runChat(projectRoot) {
|
|
|
712
917
|
clampAgentWindowWithSelection(selectedAgentIndex);
|
|
713
918
|
}
|
|
714
919
|
|
|
920
|
+
function resolveRuntimeProjectRoot(row = {}) {
|
|
921
|
+
const raw = row && row.project_root ? String(row.project_root) : "";
|
|
922
|
+
if (!raw) return "";
|
|
923
|
+
try {
|
|
924
|
+
return canonicalProjectRoot(raw);
|
|
925
|
+
} catch {
|
|
926
|
+
return path.resolve(raw);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function refreshProjectRuntimes() {
|
|
931
|
+
let rows = [];
|
|
932
|
+
try {
|
|
933
|
+
rows = listProjectRuntimes({ validate: true, cleanupTmp: true });
|
|
934
|
+
} catch {
|
|
935
|
+
rows = [];
|
|
936
|
+
}
|
|
937
|
+
rows = filterVisibleProjectRuntimes(rows);
|
|
938
|
+
const normalizedActive = String(activeProjectRoot || "");
|
|
939
|
+
if (
|
|
940
|
+
normalizedActive
|
|
941
|
+
&& !rows.some((row) => resolveRuntimeProjectRoot(row) === normalizedActive)
|
|
942
|
+
) {
|
|
943
|
+
rows.unshift({
|
|
944
|
+
project_root: normalizedActive,
|
|
945
|
+
project_name: path.basename(normalizedActive) || normalizedActive,
|
|
946
|
+
status: "untracked",
|
|
947
|
+
last_seen: null,
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
projectRuntimes = sortProjectRuntimes({
|
|
951
|
+
rows,
|
|
952
|
+
activeProjectRoot: normalizedActive,
|
|
953
|
+
resolveProjectRoot: resolveRuntimeProjectRoot,
|
|
954
|
+
getInteractionMs: (row) => {
|
|
955
|
+
const rowRoot = resolveRuntimeProjectRoot(row);
|
|
956
|
+
if (!rowRoot) return 0;
|
|
957
|
+
try {
|
|
958
|
+
const historyContext = resolveHistoryContext(rowRoot);
|
|
959
|
+
if (historyContext && historyContext.historyFile && fs.existsSync(historyContext.historyFile)) {
|
|
960
|
+
const stat = fs.statSync(historyContext.historyFile);
|
|
961
|
+
if (Number.isFinite(stat.mtimeMs) && stat.mtimeMs > 0) {
|
|
962
|
+
return stat.mtimeMs;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
} catch {
|
|
966
|
+
// fall through
|
|
967
|
+
}
|
|
968
|
+
return parseTimestampMs(row && row.last_seen);
|
|
969
|
+
},
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
if (projectRuntimes.length === 0) {
|
|
973
|
+
selectedProjectIndex = -1;
|
|
974
|
+
projectListWindowStart = 0;
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const activeIndex = projectRuntimes.findIndex(
|
|
978
|
+
(row) => resolveRuntimeProjectRoot(row) === normalizedActive
|
|
979
|
+
);
|
|
980
|
+
if (selectedProjectIndex < 0 || selectedProjectIndex >= projectRuntimes.length) {
|
|
981
|
+
selectedProjectIndex = activeIndex >= 0 ? activeIndex : 0;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function syncSelectedProjectToActive() {
|
|
986
|
+
if (!Array.isArray(projectRuntimes) || projectRuntimes.length === 0) return;
|
|
987
|
+
const activeIndex = projectRuntimes.findIndex(
|
|
988
|
+
(row) => resolveRuntimeProjectRoot(row) === String(activeProjectRoot || "")
|
|
989
|
+
);
|
|
990
|
+
if (activeIndex >= 0) {
|
|
991
|
+
selectedProjectIndex = activeIndex;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
715
995
|
function send(req) {
|
|
716
996
|
if (!daemonCoordinator) return;
|
|
717
997
|
daemonCoordinator.send(req);
|
|
@@ -788,8 +1068,6 @@ async function runChat(projectRoot) {
|
|
|
788
1068
|
logMessage("error", "{white-fg}✗{/white-fg} No agent selected");
|
|
789
1069
|
return;
|
|
790
1070
|
}
|
|
791
|
-
const label = getAgentLabel(agentId);
|
|
792
|
-
logMessage("status", `{white-fg}⚙{/white-fg} Closing ${label}...`);
|
|
793
1071
|
send({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
|
|
794
1072
|
}
|
|
795
1073
|
|
|
@@ -870,13 +1148,31 @@ async function runChat(projectRoot) {
|
|
|
870
1148
|
|
|
871
1149
|
function renderDashboard() {
|
|
872
1150
|
const computed = computeDashboardContent({
|
|
1151
|
+
globalMode,
|
|
873
1152
|
focusMode,
|
|
874
1153
|
dashboardView,
|
|
875
1154
|
activeAgents,
|
|
1155
|
+
projects: projectRuntimes,
|
|
1156
|
+
selectedProjectIndex,
|
|
1157
|
+
projectListWindowStart,
|
|
1158
|
+
maxProjectWindow: MAX_PROJECT_WINDOW,
|
|
1159
|
+
activeProjectRoot,
|
|
876
1160
|
selectedAgentIndex,
|
|
877
1161
|
agentListWindowStart,
|
|
878
1162
|
maxAgentWindow: MAX_AGENT_WINDOW,
|
|
879
1163
|
getAgentLabel,
|
|
1164
|
+
getAgentState: (agentId) => {
|
|
1165
|
+
let metaState = "";
|
|
1166
|
+
if (activeAgentMetaMap) {
|
|
1167
|
+
const meta = activeAgentMetaMap.get(agentId);
|
|
1168
|
+
metaState = meta && typeof meta.activity_state === "string"
|
|
1169
|
+
? String(meta.activity_state).trim()
|
|
1170
|
+
: "";
|
|
1171
|
+
}
|
|
1172
|
+
if (metaState) return metaState;
|
|
1173
|
+
const transientState = transientAgentStateMap.get(agentId);
|
|
1174
|
+
return typeof transientState === "string" ? transientState : "";
|
|
1175
|
+
},
|
|
880
1176
|
launchMode,
|
|
881
1177
|
agentProvider,
|
|
882
1178
|
assistantEngine,
|
|
@@ -892,12 +1188,51 @@ async function runChat(projectRoot) {
|
|
|
892
1188
|
pendingReports: reportPendingTotal,
|
|
893
1189
|
dashHints: DASH_HINTS,
|
|
894
1190
|
});
|
|
895
|
-
|
|
896
|
-
|
|
1191
|
+
if (globalMode && (focusMode !== "dashboard" || dashboardView === "projects")) {
|
|
1192
|
+
projectListWindowStart = computed.windowStart;
|
|
1193
|
+
} else {
|
|
1194
|
+
agentListWindowStart = computed.windowStart;
|
|
1195
|
+
}
|
|
1196
|
+
let dashboardContent = computed.content;
|
|
1197
|
+
if (globalMode && !String(dashboardContent || "").includes("\n")) {
|
|
1198
|
+
dashboardContent = `${dashboardContent}\n `;
|
|
1199
|
+
}
|
|
1200
|
+
dashboard.setContent(dashboardContent);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function readDiskMetaForActiveAgents(activeList = []) {
|
|
1204
|
+
const map = new Map();
|
|
1205
|
+
const ids = Array.isArray(activeList) ? activeList : [];
|
|
1206
|
+
if (ids.length === 0) return map;
|
|
1207
|
+
try {
|
|
1208
|
+
const busPath = getUfooPaths(activeProjectRoot).agentsFile;
|
|
1209
|
+
if (!fs.existsSync(busPath)) return map;
|
|
1210
|
+
const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
|
|
1211
|
+
const agents = bus && bus.agents && typeof bus.agents === "object" ? bus.agents : {};
|
|
1212
|
+
for (const id of ids) {
|
|
1213
|
+
const meta = agents[id];
|
|
1214
|
+
if (!meta || typeof meta !== "object") continue;
|
|
1215
|
+
map.set(id, meta);
|
|
1216
|
+
}
|
|
1217
|
+
} catch {
|
|
1218
|
+
// ignore disk fallback errors
|
|
1219
|
+
}
|
|
1220
|
+
return map;
|
|
897
1221
|
}
|
|
898
1222
|
|
|
899
1223
|
function updateDashboard(status) {
|
|
900
1224
|
activeAgents = status.active || [];
|
|
1225
|
+
if (transientAgentStateMap.size > 0) {
|
|
1226
|
+
const activeSet = new Set(activeAgents);
|
|
1227
|
+
for (const id of transientAgentStateMap.keys()) {
|
|
1228
|
+
if (!activeSet.has(id)) {
|
|
1229
|
+
transientAgentStateMap.delete(id);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (globalMode) {
|
|
1234
|
+
refreshProjectRuntimes();
|
|
1235
|
+
}
|
|
901
1236
|
reportPendingTotal = Number.isFinite(status?.reports?.pending_total)
|
|
902
1237
|
? status.reports.pending_total
|
|
903
1238
|
: 0;
|
|
@@ -906,7 +1241,7 @@ async function runChat(projectRoot) {
|
|
|
906
1241
|
let fallbackMap = null;
|
|
907
1242
|
if (metaList.length === 0 && activeAgents.length > 0) {
|
|
908
1243
|
try {
|
|
909
|
-
const busPath = getUfooPaths(
|
|
1244
|
+
const busPath = getUfooPaths(activeProjectRoot).agentsFile;
|
|
910
1245
|
const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
|
|
911
1246
|
fallbackMap = new Map();
|
|
912
1247
|
for (const [id, meta] of Object.entries(bus.agents || {})) {
|
|
@@ -918,7 +1253,35 @@ async function runChat(projectRoot) {
|
|
|
918
1253
|
}
|
|
919
1254
|
const maps = agentDirectory.buildAgentMaps(activeAgents, metaList, fallbackMap);
|
|
920
1255
|
activeAgentLabelMap = maps.labelMap;
|
|
921
|
-
|
|
1256
|
+
const diskMetaMap = readDiskMetaForActiveAgents(activeAgents);
|
|
1257
|
+
if (diskMetaMap.size > 0) {
|
|
1258
|
+
const mergedMetaMap = new Map(maps.metaMap);
|
|
1259
|
+
for (const id of activeAgents) {
|
|
1260
|
+
const currentMeta = mergedMetaMap.get(id);
|
|
1261
|
+
const diskMeta = diskMetaMap.get(id);
|
|
1262
|
+
if (!currentMeta && diskMeta) {
|
|
1263
|
+
mergedMetaMap.set(id, { id, ...diskMeta });
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
if (!currentMeta || !diskMeta) continue;
|
|
1267
|
+
const currentState = typeof currentMeta.activity_state === "string"
|
|
1268
|
+
? String(currentMeta.activity_state).trim()
|
|
1269
|
+
: "";
|
|
1270
|
+
const diskState = typeof diskMeta.activity_state === "string"
|
|
1271
|
+
? String(diskMeta.activity_state).trim()
|
|
1272
|
+
: "";
|
|
1273
|
+
if (!currentState && diskState) {
|
|
1274
|
+
mergedMetaMap.set(id, {
|
|
1275
|
+
...currentMeta,
|
|
1276
|
+
activity_state: diskState,
|
|
1277
|
+
activity_since: currentMeta.activity_since || diskMeta.activity_since || "",
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
activeAgentMetaMap = mergedMetaMap;
|
|
1282
|
+
} else {
|
|
1283
|
+
activeAgentMetaMap = maps.metaMap;
|
|
1284
|
+
}
|
|
922
1285
|
clampAgentWindow();
|
|
923
1286
|
// If viewing agent went offline, exit view
|
|
924
1287
|
const currentView = getCurrentView();
|
|
@@ -957,10 +1320,15 @@ async function runChat(projectRoot) {
|
|
|
957
1320
|
|
|
958
1321
|
function enterDashboardMode() {
|
|
959
1322
|
focusMode = "dashboard";
|
|
960
|
-
dashboardView = "agents";
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1323
|
+
dashboardView = globalMode ? "projects" : "agents";
|
|
1324
|
+
if (globalMode) {
|
|
1325
|
+
refreshProjectRuntimes();
|
|
1326
|
+
syncSelectedProjectToActive();
|
|
1327
|
+
} else {
|
|
1328
|
+
selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
|
|
1329
|
+
agentListWindowStart = 0;
|
|
1330
|
+
clampAgentWindow();
|
|
1331
|
+
}
|
|
964
1332
|
selectedModeIndex = launchMode === "internal" ? 2 : (launchMode === "tmux" ? 1 : 0);
|
|
965
1333
|
selectedProviderIndex = Math.max(0, providerOptions.findIndex((opt) => opt.value === agentProvider));
|
|
966
1334
|
selectedAssistantIndex = Math.max(
|
|
@@ -968,8 +1336,8 @@ async function runChat(projectRoot) {
|
|
|
968
1336
|
assistantOptions.findIndex((opt) => opt.value === assistantEngine)
|
|
969
1337
|
);
|
|
970
1338
|
selectedResumeIndex = autoResume ? 0 : 1;
|
|
971
|
-
// Immediately set @target when first agent is selected
|
|
972
|
-
if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
|
|
1339
|
+
// Immediately set @target when first agent is selected.
|
|
1340
|
+
if (!globalMode && selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
|
|
973
1341
|
targetAgent = activeAgents[selectedAgentIndex];
|
|
974
1342
|
updatePromptBox();
|
|
975
1343
|
}
|
|
@@ -985,6 +1353,9 @@ async function runChat(projectRoot) {
|
|
|
985
1353
|
currentView: { get: () => getCurrentView() },
|
|
986
1354
|
focusMode: { get: () => focusMode, set: (value) => { focusMode = value; } },
|
|
987
1355
|
dashboardView: { get: () => dashboardView, set: (value) => { dashboardView = value; } },
|
|
1356
|
+
selectedProjectIndex: { get: () => selectedProjectIndex, set: (value) => { selectedProjectIndex = value; } },
|
|
1357
|
+
projects: { get: () => projectRuntimes },
|
|
1358
|
+
activeProjectRoot: { get: () => activeProjectRoot },
|
|
988
1359
|
selectedAgentIndex: { get: () => selectedAgentIndex, set: (value) => { selectedAgentIndex = value; } },
|
|
989
1360
|
activeAgents: { get: () => activeAgents },
|
|
990
1361
|
viewingAgent: { get: () => getViewingAgent() },
|
|
@@ -1009,7 +1380,7 @@ async function runChat(projectRoot) {
|
|
|
1009
1380
|
|
|
1010
1381
|
function activateAgent(agentId) {
|
|
1011
1382
|
if (!agentId) return;
|
|
1012
|
-
const activator = new AgentActivator(
|
|
1383
|
+
const activator = new AgentActivator(activeProjectRoot);
|
|
1013
1384
|
activator.activate(agentId).catch(() => {});
|
|
1014
1385
|
}
|
|
1015
1386
|
|
|
@@ -1022,6 +1393,7 @@ async function runChat(projectRoot) {
|
|
|
1022
1393
|
|
|
1023
1394
|
const dashboardController = createDashboardKeyController({
|
|
1024
1395
|
state: dashboardState,
|
|
1396
|
+
globalMode,
|
|
1025
1397
|
existsSync: fs.existsSync,
|
|
1026
1398
|
getInjectSockPath,
|
|
1027
1399
|
getAgentAdapter,
|
|
@@ -1041,6 +1413,8 @@ async function runChat(projectRoot) {
|
|
|
1041
1413
|
setAutoResume,
|
|
1042
1414
|
clampAgentWindow,
|
|
1043
1415
|
clampAgentWindowWithSelection,
|
|
1416
|
+
requestProjectSwitch: requestProjectSwitchByIndex,
|
|
1417
|
+
requestCloseProject: requestCloseProjectByIndex,
|
|
1044
1418
|
renderDashboard,
|
|
1045
1419
|
renderAgentDashboard,
|
|
1046
1420
|
renderScreen: () => screen.render(),
|
|
@@ -1059,8 +1433,9 @@ async function runChat(projectRoot) {
|
|
|
1059
1433
|
updatePromptBox();
|
|
1060
1434
|
}
|
|
1061
1435
|
focusMode = "input";
|
|
1062
|
-
dashboardView = "agents";
|
|
1436
|
+
dashboardView = globalMode ? "projects" : "agents";
|
|
1063
1437
|
selectedAgentIndex = -1;
|
|
1438
|
+
// Keep selectedProjectIndex across focus transitions so global rail preserves context.
|
|
1064
1439
|
screen.grabKeys = false;
|
|
1065
1440
|
renderDashboard();
|
|
1066
1441
|
focusInput();
|
|
@@ -1075,7 +1450,7 @@ async function runChat(projectRoot) {
|
|
|
1075
1450
|
|
|
1076
1451
|
function getInjectSockPath(agentId) {
|
|
1077
1452
|
const safeName = subscriberToSafeName(agentId);
|
|
1078
|
-
return path.join(getUfooPaths(
|
|
1453
|
+
return path.join(getUfooPaths(activeProjectRoot).busQueuesDir, safeName, "inject.sock");
|
|
1079
1454
|
}
|
|
1080
1455
|
|
|
1081
1456
|
agentViewController = createAgentViewController({
|
|
@@ -1099,6 +1474,15 @@ async function runChat(projectRoot) {
|
|
|
1099
1474
|
agentListWindowStart = value;
|
|
1100
1475
|
},
|
|
1101
1476
|
getAgentLabel,
|
|
1477
|
+
getAgentStates: () => {
|
|
1478
|
+
const states = {};
|
|
1479
|
+
if (activeAgentMetaMap) {
|
|
1480
|
+
for (const [id, meta] of activeAgentMetaMap) {
|
|
1481
|
+
if (meta && meta.activity_state) states[id] = meta.activity_state;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return states;
|
|
1485
|
+
},
|
|
1102
1486
|
setDashboardView: (value) => {
|
|
1103
1487
|
dashboardView = value;
|
|
1104
1488
|
},
|
|
@@ -1164,6 +1548,21 @@ async function runChat(projectRoot) {
|
|
|
1164
1548
|
appendStreamDelta,
|
|
1165
1549
|
finalizeStream,
|
|
1166
1550
|
hasStream: (publisher) => streamTracker.hasStream(publisher),
|
|
1551
|
+
setTransientAgentState: (agentId, state) => {
|
|
1552
|
+
if (!agentId || !state) return;
|
|
1553
|
+
transientAgentStateMap.set(agentId, state);
|
|
1554
|
+
},
|
|
1555
|
+
clearTransientAgentState: (agentId) => {
|
|
1556
|
+
if (!agentId) return;
|
|
1557
|
+
transientAgentStateMap.delete(agentId);
|
|
1558
|
+
},
|
|
1559
|
+
refreshDashboard: () => {
|
|
1560
|
+
if (getCurrentView() === "agent") {
|
|
1561
|
+
renderAgentDashboard();
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
renderDashboard();
|
|
1565
|
+
},
|
|
1167
1566
|
});
|
|
1168
1567
|
|
|
1169
1568
|
daemonCoordinator = createDaemonCoordinator({
|
|
@@ -1180,8 +1579,8 @@ async function runChat(projectRoot) {
|
|
|
1180
1579
|
const connected = await daemonCoordinator.connect();
|
|
1181
1580
|
if (!connected) {
|
|
1182
1581
|
// Check if daemon failed to start
|
|
1183
|
-
if (!isRunning(
|
|
1184
|
-
const logFile = getUfooPaths(
|
|
1582
|
+
if (!isRunning(activeProjectRoot)) {
|
|
1583
|
+
const logFile = getUfooPaths(activeProjectRoot).ufooDaemonLog;
|
|
1185
1584
|
// eslint-disable-next-line no-console
|
|
1186
1585
|
console.error("Failed to start ufoo daemon. Check logs at:", logFile);
|
|
1187
1586
|
throw new Error("Daemon failed to start. Check the daemon log for details.");
|
|
@@ -1189,6 +1588,237 @@ async function runChat(projectRoot) {
|
|
|
1189
1588
|
throw new Error("Failed to connect to ufoo daemon (timeout). The daemon may still be starting.");
|
|
1190
1589
|
}
|
|
1191
1590
|
|
|
1591
|
+
function resolveProjectSwitchTarget(rawTarget) {
|
|
1592
|
+
const target = String(rawTarget || "").trim();
|
|
1593
|
+
if (!target) {
|
|
1594
|
+
throw new Error("missing target");
|
|
1595
|
+
}
|
|
1596
|
+
if (/^\d+$/.test(target)) {
|
|
1597
|
+
const index = Number.parseInt(target, 10);
|
|
1598
|
+
if (!Number.isFinite(index) || index <= 0) {
|
|
1599
|
+
throw new Error("invalid project index");
|
|
1600
|
+
}
|
|
1601
|
+
const rows = listProjectRuntimes({ validate: true, cleanupTmp: true });
|
|
1602
|
+
const item = rows[index - 1];
|
|
1603
|
+
if (!item || !item.project_root) {
|
|
1604
|
+
throw new Error("project index out of range");
|
|
1605
|
+
}
|
|
1606
|
+
return {
|
|
1607
|
+
projectRoot: canonicalProjectRoot(item.project_root),
|
|
1608
|
+
source: `index ${index}`,
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
return {
|
|
1612
|
+
projectRoot: canonicalProjectRoot(target),
|
|
1613
|
+
source: target,
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
async function switchProjectConnection(targetInput) {
|
|
1618
|
+
let targetInfo;
|
|
1619
|
+
try {
|
|
1620
|
+
targetInfo = resolveProjectSwitchTarget(targetInput);
|
|
1621
|
+
} catch (err) {
|
|
1622
|
+
return {
|
|
1623
|
+
ok: false,
|
|
1624
|
+
error: err && err.message ? err.message : "invalid project target",
|
|
1625
|
+
};
|
|
1626
|
+
}
|
|
1627
|
+
const nextProjectRoot = targetInfo.projectRoot;
|
|
1628
|
+
if (!nextProjectRoot) {
|
|
1629
|
+
return { ok: false, error: "invalid project target" };
|
|
1630
|
+
}
|
|
1631
|
+
if (nextProjectRoot === activeProjectRoot) {
|
|
1632
|
+
return { ok: true, project_root: activeProjectRoot, unchanged: true };
|
|
1633
|
+
}
|
|
1634
|
+
const outgoingDraftSnapshot = captureCurrentProjectDraft();
|
|
1635
|
+
|
|
1636
|
+
try {
|
|
1637
|
+
const nextPaths = getUfooPaths(nextProjectRoot);
|
|
1638
|
+
if (!fs.existsSync(nextPaths.ufooDir)) {
|
|
1639
|
+
const repoRoot = path.join(__dirname, "..", "..");
|
|
1640
|
+
const init = new UfooInit(repoRoot);
|
|
1641
|
+
await init.init({ modules: "context,bus", project: nextProjectRoot });
|
|
1642
|
+
}
|
|
1643
|
+
if (!isRunning(nextProjectRoot)) {
|
|
1644
|
+
startDaemon(nextProjectRoot);
|
|
1645
|
+
}
|
|
1646
|
+
const result = await daemonCoordinator.switchProject({
|
|
1647
|
+
projectRoot: nextProjectRoot,
|
|
1648
|
+
sockPath: socketPath(nextProjectRoot),
|
|
1649
|
+
});
|
|
1650
|
+
if (!result || result.ok !== true) {
|
|
1651
|
+
return {
|
|
1652
|
+
ok: false,
|
|
1653
|
+
error: (result && result.error) || "switch failed",
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
const previousProjectRoot = activeProjectRoot;
|
|
1657
|
+
if (previousProjectRoot && previousProjectRoot !== nextProjectRoot) {
|
|
1658
|
+
setProjectDraft(previousProjectRoot, outgoingDraftSnapshot);
|
|
1659
|
+
}
|
|
1660
|
+
activeProjectRoot = nextProjectRoot;
|
|
1661
|
+
applyProjectHistoryContext(nextProjectRoot);
|
|
1662
|
+
if (globalMode) {
|
|
1663
|
+
refreshProjectRuntimes();
|
|
1664
|
+
syncSelectedProjectToActive();
|
|
1665
|
+
renderDashboard();
|
|
1666
|
+
screen.render();
|
|
1667
|
+
}
|
|
1668
|
+
return {
|
|
1669
|
+
ok: true,
|
|
1670
|
+
project_root: activeProjectRoot,
|
|
1671
|
+
};
|
|
1672
|
+
} catch (err) {
|
|
1673
|
+
return {
|
|
1674
|
+
ok: false,
|
|
1675
|
+
error: err && err.message ? err.message : "switch failed",
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
let projectSwitching = false;
|
|
1681
|
+
let pendingProjectSwitchRoot = null;
|
|
1682
|
+
let projectSwitchDebounceTimer = null;
|
|
1683
|
+
let projectSwitchFlushPromise = null;
|
|
1684
|
+
const PROJECT_SWITCH_DEBOUNCE_MS = 200;
|
|
1685
|
+
|
|
1686
|
+
function cancelProjectSwitchDebounce() {
|
|
1687
|
+
if (!projectSwitchDebounceTimer) return;
|
|
1688
|
+
clearTimeout(projectSwitchDebounceTimer);
|
|
1689
|
+
projectSwitchDebounceTimer = null;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
function scheduleProjectSwitchFlush(delayMs = PROJECT_SWITCH_DEBOUNCE_MS) {
|
|
1693
|
+
cancelProjectSwitchDebounce();
|
|
1694
|
+
projectSwitchDebounceTimer = setTimeout(() => {
|
|
1695
|
+
projectSwitchDebounceTimer = null;
|
|
1696
|
+
flushPendingProjectSwitch().catch((err) => {
|
|
1697
|
+
const message = err && err.message ? err.message : String(err || "switch failed");
|
|
1698
|
+
logMessage("error", `{white-fg}✗{/white-fg} Switch failed: ${escapeBlessed(message)}`);
|
|
1699
|
+
});
|
|
1700
|
+
}, Math.max(0, Number.isFinite(delayMs) ? delayMs : PROJECT_SWITCH_DEBOUNCE_MS));
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
async function flushPendingProjectSwitch() {
|
|
1704
|
+
if (projectSwitchFlushPromise) {
|
|
1705
|
+
return projectSwitchFlushPromise;
|
|
1706
|
+
}
|
|
1707
|
+
projectSwitchFlushPromise = (async () => {
|
|
1708
|
+
projectSwitching = true;
|
|
1709
|
+
let lastResult = { ok: true, project_root: activeProjectRoot, unchanged: true };
|
|
1710
|
+
try {
|
|
1711
|
+
while (pendingProjectSwitchRoot) {
|
|
1712
|
+
const nextProjectRoot = pendingProjectSwitchRoot;
|
|
1713
|
+
pendingProjectSwitchRoot = null;
|
|
1714
|
+
if (!nextProjectRoot || nextProjectRoot === activeProjectRoot) continue;
|
|
1715
|
+
const result = await switchProjectConnection(nextProjectRoot);
|
|
1716
|
+
lastResult = result || { ok: false, error: "switch failed" };
|
|
1717
|
+
if (!result || result.ok !== true) {
|
|
1718
|
+
const reason = (result && result.error) || "switch failed";
|
|
1719
|
+
logMessage("error", `{white-fg}✗{/white-fg} Switch failed: ${escapeBlessed(reason)}`);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
return lastResult;
|
|
1723
|
+
} finally {
|
|
1724
|
+
projectSwitching = false;
|
|
1725
|
+
if (globalMode) {
|
|
1726
|
+
refreshProjectRuntimes();
|
|
1727
|
+
syncSelectedProjectToActive();
|
|
1728
|
+
renderDashboard();
|
|
1729
|
+
screen.render();
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
})();
|
|
1733
|
+
try {
|
|
1734
|
+
return await projectSwitchFlushPromise;
|
|
1735
|
+
} finally {
|
|
1736
|
+
projectSwitchFlushPromise = null;
|
|
1737
|
+
if (pendingProjectSwitchRoot && !projectSwitchDebounceTimer) {
|
|
1738
|
+
scheduleProjectSwitchFlush(0);
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function requestProjectSwitchByIndex(index) {
|
|
1744
|
+
if (!globalMode) return;
|
|
1745
|
+
const numericIndex = Number(index);
|
|
1746
|
+
const nextIndex = Number.isFinite(numericIndex) ? Math.trunc(numericIndex) : Number.NaN;
|
|
1747
|
+
if (!Number.isFinite(nextIndex) || nextIndex < 0 || nextIndex >= projectRuntimes.length) {
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
selectedProjectIndex = nextIndex;
|
|
1751
|
+
const selected = projectRuntimes[nextIndex] || {};
|
|
1752
|
+
const nextProjectRoot = resolveRuntimeProjectRoot(selected);
|
|
1753
|
+
renderDashboard();
|
|
1754
|
+
screen.render();
|
|
1755
|
+
if (!nextProjectRoot) return;
|
|
1756
|
+
pendingProjectSwitchRoot = nextProjectRoot;
|
|
1757
|
+
scheduleProjectSwitchFlush();
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
async function requestProjectSwitchByTarget(targetInput) {
|
|
1761
|
+
let targetInfo;
|
|
1762
|
+
try {
|
|
1763
|
+
targetInfo = resolveProjectSwitchTarget(targetInput);
|
|
1764
|
+
} catch (err) {
|
|
1765
|
+
return {
|
|
1766
|
+
ok: false,
|
|
1767
|
+
error: err && err.message ? err.message : "invalid project target",
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
const nextProjectRoot = targetInfo && targetInfo.projectRoot ? targetInfo.projectRoot : "";
|
|
1771
|
+
if (!nextProjectRoot) {
|
|
1772
|
+
return { ok: false, error: "invalid project target" };
|
|
1773
|
+
}
|
|
1774
|
+
if (nextProjectRoot === activeProjectRoot) {
|
|
1775
|
+
return { ok: true, project_root: activeProjectRoot, unchanged: true };
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
pendingProjectSwitchRoot = nextProjectRoot;
|
|
1779
|
+
cancelProjectSwitchDebounce();
|
|
1780
|
+
|
|
1781
|
+
let attempts = 0;
|
|
1782
|
+
while (attempts < 4) {
|
|
1783
|
+
attempts += 1;
|
|
1784
|
+
const result = await flushPendingProjectSwitch();
|
|
1785
|
+
if (activeProjectRoot === nextProjectRoot) {
|
|
1786
|
+
return { ok: true, project_root: activeProjectRoot };
|
|
1787
|
+
}
|
|
1788
|
+
if (!pendingProjectSwitchRoot) {
|
|
1789
|
+
if (result && result.ok !== true) return result;
|
|
1790
|
+
return { ok: false, error: "switch failed" };
|
|
1791
|
+
}
|
|
1792
|
+
if (pendingProjectSwitchRoot !== nextProjectRoot) {
|
|
1793
|
+
pendingProjectSwitchRoot = nextProjectRoot;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
return { ok: false, error: "switch did not complete" };
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
const projectCloseController = createProjectCloseController({
|
|
1800
|
+
getProjects: () => projectRuntimes,
|
|
1801
|
+
getActiveProjectRoot: () => activeProjectRoot,
|
|
1802
|
+
resolveProjectRoot: resolveRuntimeProjectRoot,
|
|
1803
|
+
isRunning,
|
|
1804
|
+
stopDaemon,
|
|
1805
|
+
switchProject: (targetProjectRoot) => requestProjectSwitchByTarget(targetProjectRoot),
|
|
1806
|
+
refreshProjects: () => {
|
|
1807
|
+
if (!globalMode) return;
|
|
1808
|
+
refreshProjectRuntimes();
|
|
1809
|
+
syncSelectedProjectToActive();
|
|
1810
|
+
},
|
|
1811
|
+
renderDashboard,
|
|
1812
|
+
renderScreen: () => screen.render(),
|
|
1813
|
+
logMessage,
|
|
1814
|
+
escapeBlessed,
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
function requestCloseProjectByIndex(index) {
|
|
1818
|
+
if (!globalMode) return;
|
|
1819
|
+
void projectCloseController.requestCloseProject(index);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1192
1822
|
const commandExecutor = createCommandExecutor({
|
|
1193
1823
|
projectRoot,
|
|
1194
1824
|
parseCommand,
|
|
@@ -1211,9 +1841,15 @@ async function runChat(projectRoot) {
|
|
|
1211
1841
|
});
|
|
1212
1842
|
},
|
|
1213
1843
|
activateAgent: async (target) => {
|
|
1214
|
-
const activator = new AgentActivator(
|
|
1844
|
+
const activator = new AgentActivator(activeProjectRoot);
|
|
1215
1845
|
await activator.activate(target);
|
|
1216
1846
|
},
|
|
1847
|
+
listProjects: () => listProjectRuntimes({ validate: true, cleanupTmp: true }),
|
|
1848
|
+
getCurrentProject: () => ({
|
|
1849
|
+
project_root: activeProjectRoot,
|
|
1850
|
+
project_name: path.basename(activeProjectRoot),
|
|
1851
|
+
}),
|
|
1852
|
+
switchProject: async ({ target } = {}) => requestProjectSwitchByTarget(target),
|
|
1217
1853
|
});
|
|
1218
1854
|
|
|
1219
1855
|
async function executeCommand(text) {
|
|
@@ -1246,7 +1882,7 @@ async function runChat(projectRoot) {
|
|
|
1246
1882
|
},
|
|
1247
1883
|
enterAgentView,
|
|
1248
1884
|
activateAgent: async (agentId) => {
|
|
1249
|
-
const activator = new AgentActivator(
|
|
1885
|
+
const activator = new AgentActivator(activeProjectRoot);
|
|
1250
1886
|
await activator.activate(agentId);
|
|
1251
1887
|
},
|
|
1252
1888
|
getInjectSockPath,
|
|
@@ -1347,6 +1983,12 @@ async function runChat(projectRoot) {
|
|
|
1347
1983
|
}
|
|
1348
1984
|
loadHistory();
|
|
1349
1985
|
loadInputHistory();
|
|
1986
|
+
if (globalMode) {
|
|
1987
|
+
inputHistoryController.restoreDraft(getProjectDraft(activeProjectRoot));
|
|
1988
|
+
}
|
|
1989
|
+
if (globalMode) {
|
|
1990
|
+
refreshProjectRuntimes();
|
|
1991
|
+
}
|
|
1350
1992
|
renderDashboard();
|
|
1351
1993
|
resizeInput();
|
|
1352
1994
|
requestStatus();
|
|
@@ -1356,7 +1998,7 @@ async function runChat(projectRoot) {
|
|
|
1356
1998
|
if (daemonCoordinator && daemonCoordinator.isConnected()) {
|
|
1357
1999
|
requestStatus();
|
|
1358
2000
|
}
|
|
1359
|
-
},
|
|
2001
|
+
}, 5000);
|
|
1360
2002
|
screen.on("resize", () => {
|
|
1361
2003
|
if (handleResizeInAgentView()) {
|
|
1362
2004
|
return;
|