u-foo 1.5.0 → 1.7.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/modules/AGENTS.template.md +4 -102
- package/package.json +1 -1
- 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 +110 -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/activate.js +22 -2
- package/src/bus/daemon.js +1 -1
- package/src/bus/inject.js +29 -10
- package/src/bus/message.js +1 -9
- package/src/bus/subscriber.js +34 -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/commandExecutor.js +15 -0
- package/src/chat/daemonConnection.js +45 -7
- package/src/chat/daemonMessageRouter.js +22 -0
- package/src/chat/daemonTransport.js +13 -2
- package/src/chat/daemonTransportDefaults.js +1 -0
- package/src/chat/dashboardKeyController.js +9 -0
- package/src/chat/dashboardView.js +32 -9
- package/src/chat/index.js +176 -8
- package/src/chat/projectCloseController.js +119 -0
- package/src/chat/projectRuntimes.js +55 -0
- package/src/chat/statusLineController.js +52 -6
- package/src/chat/transport.js +41 -5
- package/src/cli.js +14 -0
- package/src/config.js +1 -0
- package/src/daemon/index.js +63 -5
- package/src/daemon/ipcServer.js +6 -1
- package/src/daemon/ops.js +189 -14
- package/src/daemon/status.js +17 -1
- package/src/init/index.js +32 -3
- package/src/terminal/adapterRouter.js +13 -1
- package/src/terminal/adapters/hostAdapter.js +409 -0
- package/src/ufoo/agentsStore.js +44 -0
|
@@ -25,8 +25,17 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
25
25
|
appendStreamDelta = () => {},
|
|
26
26
|
finalizeStream = () => {},
|
|
27
27
|
hasStream = () => false,
|
|
28
|
+
setTransientAgentState = () => {},
|
|
29
|
+
clearTransientAgentState = () => {},
|
|
30
|
+
refreshDashboard = () => {},
|
|
28
31
|
} = options;
|
|
29
32
|
|
|
33
|
+
function isLikelySubscriberId(value) {
|
|
34
|
+
const text = String(value || "");
|
|
35
|
+
if (!text) return false;
|
|
36
|
+
return text.includes(":") && !text.includes(" ");
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
function normalizeDisplayMessage(raw) {
|
|
31
40
|
let displayMessage = raw || "";
|
|
32
41
|
let streamPayload = null;
|
|
@@ -53,6 +62,14 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
53
62
|
if (typeof data.phase === "string") {
|
|
54
63
|
const text = data.text || "";
|
|
55
64
|
const item = { key: data.key, text };
|
|
65
|
+
const key = typeof data.key === "string" ? data.key : "";
|
|
66
|
+
if (isLikelySubscriberId(key)) {
|
|
67
|
+
if (data.phase === BUS_STATUS_PHASES.START) {
|
|
68
|
+
setTransientAgentState(key, "working");
|
|
69
|
+
} else if (data.phase === BUS_STATUS_PHASES.DONE || data.phase === BUS_STATUS_PHASES.ERROR) {
|
|
70
|
+
clearTransientAgentState(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
56
73
|
if (data.phase === BUS_STATUS_PHASES.START) {
|
|
57
74
|
enqueueBusStatus(item);
|
|
58
75
|
} else if (data.phase === BUS_STATUS_PHASES.DONE || data.phase === BUS_STATUS_PHASES.ERROR) {
|
|
@@ -66,6 +83,7 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
66
83
|
} else {
|
|
67
84
|
enqueueBusStatus(item);
|
|
68
85
|
}
|
|
86
|
+
refreshDashboard();
|
|
69
87
|
renderScreen();
|
|
70
88
|
return false;
|
|
71
89
|
}
|
|
@@ -301,6 +319,10 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
301
319
|
|
|
302
320
|
function handleBusMessage(msg) {
|
|
303
321
|
const data = msg.data || {};
|
|
322
|
+
if (data.event === "activity_state_changed") {
|
|
323
|
+
requestStatus();
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
304
326
|
const prefix = data.event === "broadcast" ? "{gray-fg}⇢{/gray-fg}" : "{gray-fg}↔{/gray-fg}";
|
|
305
327
|
const publisher = data.publisher && data.publisher !== "unknown"
|
|
306
328
|
? data.publisher
|
|
@@ -11,6 +11,7 @@ function createDaemonTransport(options = {}) {
|
|
|
11
11
|
secondaryRetries = DAEMON_TRANSPORT_DEFAULTS.secondaryRetries,
|
|
12
12
|
retryDelayMs = DAEMON_TRANSPORT_DEFAULTS.retryDelayMs,
|
|
13
13
|
restartDelayMs = DAEMON_TRANSPORT_DEFAULTS.restartDelayMs,
|
|
14
|
+
connectTimeoutMs = DAEMON_TRANSPORT_DEFAULTS.connectTimeoutMs,
|
|
14
15
|
} = options;
|
|
15
16
|
|
|
16
17
|
let activeProjectRoot = projectRoot;
|
|
@@ -25,14 +26,24 @@ function createDaemonTransport(options = {}) {
|
|
|
25
26
|
|
|
26
27
|
async function connectClientForTarget(override = {}) {
|
|
27
28
|
const target = resolveTarget(override);
|
|
28
|
-
let client = await connectWithRetry(
|
|
29
|
+
let client = await connectWithRetry(
|
|
30
|
+
target.sockPath,
|
|
31
|
+
primaryRetries,
|
|
32
|
+
retryDelayMs,
|
|
33
|
+
{ timeoutMs: connectTimeoutMs }
|
|
34
|
+
);
|
|
29
35
|
if (!client) {
|
|
30
36
|
// Retry once with a fresh daemon start and longer wait.
|
|
31
37
|
if (!isRunning(target.projectRoot)) {
|
|
32
38
|
startDaemon(target.projectRoot);
|
|
33
39
|
await new Promise((resolve) => setTimeout(resolve, restartDelayMs));
|
|
34
40
|
}
|
|
35
|
-
client = await connectWithRetry(
|
|
41
|
+
client = await connectWithRetry(
|
|
42
|
+
target.sockPath,
|
|
43
|
+
secondaryRetries,
|
|
44
|
+
retryDelayMs,
|
|
45
|
+
{ timeoutMs: connectTimeoutMs }
|
|
46
|
+
);
|
|
36
47
|
}
|
|
37
48
|
return client;
|
|
38
49
|
}
|
|
@@ -23,6 +23,7 @@ function createDashboardKeyController(options = {}) {
|
|
|
23
23
|
clampAgentWindow = () => {},
|
|
24
24
|
clampAgentWindowWithSelection = () => {},
|
|
25
25
|
requestProjectSwitch = () => {},
|
|
26
|
+
requestCloseProject = () => {},
|
|
26
27
|
renderDashboard = () => {},
|
|
27
28
|
renderAgentDashboard = () => {},
|
|
28
29
|
renderScreen = () => {},
|
|
@@ -385,6 +386,14 @@ function createDashboardKeyController(options = {}) {
|
|
|
385
386
|
return true;
|
|
386
387
|
}
|
|
387
388
|
|
|
389
|
+
if (key.name === "x" && key.ctrl) {
|
|
390
|
+
const current = Number.isFinite(state.selectedProjectIndex) ? state.selectedProjectIndex : 0;
|
|
391
|
+
if (current >= 0 && current < projects.length) {
|
|
392
|
+
requestCloseProject(current);
|
|
393
|
+
}
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
388
397
|
if (key.name === "down") {
|
|
389
398
|
state.dashboardView = "agents";
|
|
390
399
|
if (!Array.isArray(state.activeAgents) || state.activeAgents.length === 0) {
|
|
@@ -22,17 +22,35 @@ function ensureAtPrefix(value) {
|
|
|
22
22
|
return text.startsWith("@") ? text : `@${text}`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function activityMarker(state = "") {
|
|
26
|
+
const normalized = String(state || "").trim().toLowerCase();
|
|
27
|
+
if (normalized === "working") return "*";
|
|
28
|
+
if (normalized === "waiting_input") return "?";
|
|
29
|
+
if (normalized === "blocked") return "!";
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function withActivityMarker(label = "", state = "") {
|
|
34
|
+
const marker = activityMarker(state);
|
|
35
|
+
if (!marker) return label;
|
|
36
|
+
return `${marker}${label}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
function buildSummaryLine(options = {}) {
|
|
26
40
|
const {
|
|
27
41
|
activeAgents = [],
|
|
28
42
|
getAgentLabel = (id) => id,
|
|
43
|
+
getAgentState = () => "",
|
|
29
44
|
launchMode = "terminal",
|
|
30
45
|
agentProvider = "codex-cli",
|
|
31
46
|
assistantEngine = "auto",
|
|
32
47
|
cronTasks = [],
|
|
33
48
|
} = options;
|
|
34
49
|
const agents = activeAgents.length > 0
|
|
35
|
-
? activeAgents.slice(0, 3)
|
|
50
|
+
? activeAgents.slice(0, 3)
|
|
51
|
+
.map((id) => withActivityMarker(ensureAtPrefix(getAgentLabel(id)), getAgentState(id)))
|
|
52
|
+
.join(", ")
|
|
53
|
+
+ (activeAgents.length > 3 ? ` +${activeAgents.length - 3}` : "")
|
|
36
54
|
: "none";
|
|
37
55
|
return `{gray-fg}Agents:{/gray-fg} {cyan-fg}${agents}{/cyan-fg}`
|
|
38
56
|
+ ` {gray-fg}Mode:{/gray-fg} {cyan-fg}${launchMode}{/cyan-fg}`
|
|
@@ -86,17 +104,13 @@ function buildProjectRailLine(options = {}) {
|
|
|
86
104
|
const rowRoot = String((row && row.project_root) || "");
|
|
87
105
|
const isActiveProject = Boolean(activeRoot && rowRoot === activeRoot);
|
|
88
106
|
const isSelected = absoluteIndex === safeSelectedIndex;
|
|
89
|
-
const displayName = isActiveProject ? `[${name}]` : name;
|
|
90
107
|
if (projectsFocused && isSelected) {
|
|
91
|
-
return `{inverse}${
|
|
108
|
+
return `{inverse}${name}{/inverse}`;
|
|
92
109
|
}
|
|
93
110
|
if (isActiveProject) {
|
|
94
|
-
return `{cyan-fg}${
|
|
95
|
-
}
|
|
96
|
-
if (isSelected) {
|
|
97
|
-
return `{inverse}${displayName}{/inverse}`;
|
|
111
|
+
return `{bold}{cyan-fg}${name}{/cyan-fg}{/bold}`;
|
|
98
112
|
}
|
|
99
|
-
return `{cyan-fg}${
|
|
113
|
+
return `{cyan-fg}${name}{/cyan-fg}`;
|
|
100
114
|
});
|
|
101
115
|
|
|
102
116
|
const leftMore = start > 0 ? "{gray-fg}<{/gray-fg} " : "";
|
|
@@ -117,6 +131,7 @@ function buildDashboardDetailLine(options = {}) {
|
|
|
117
131
|
agentListWindowStart = 0,
|
|
118
132
|
maxAgentWindow = 4,
|
|
119
133
|
getAgentLabel = (id) => id,
|
|
134
|
+
getAgentState = () => "",
|
|
120
135
|
selectedModeIndex = 0,
|
|
121
136
|
selectedProviderIndex = 0,
|
|
122
137
|
selectedAssistantIndex = 0,
|
|
@@ -203,7 +218,10 @@ function buildDashboardDetailLine(options = {}) {
|
|
|
203
218
|
const visibleAgents = activeAgents.slice(start, end);
|
|
204
219
|
const agentParts = visibleAgents.map((agent, i) => {
|
|
205
220
|
const absoluteIndex = start + i;
|
|
206
|
-
const label =
|
|
221
|
+
const label = withActivityMarker(
|
|
222
|
+
ensureAtPrefix(getAgentLabel(agent)),
|
|
223
|
+
getAgentState(agent)
|
|
224
|
+
);
|
|
207
225
|
if (absoluteIndex === selectedAgentIndex) {
|
|
208
226
|
return `{inverse}${label}{/inverse}`;
|
|
209
227
|
}
|
|
@@ -238,6 +256,7 @@ function computeDashboardContent(options = {}) {
|
|
|
238
256
|
agentListWindowStart = 0,
|
|
239
257
|
maxAgentWindow = 4,
|
|
240
258
|
getAgentLabel = (id) => id,
|
|
259
|
+
getAgentState = () => "",
|
|
241
260
|
launchMode = "terminal",
|
|
242
261
|
agentProvider = "codex-cli",
|
|
243
262
|
assistantEngine = "auto",
|
|
@@ -275,6 +294,7 @@ function computeDashboardContent(options = {}) {
|
|
|
275
294
|
const line2 = buildSummaryLine({
|
|
276
295
|
activeAgents,
|
|
277
296
|
getAgentLabel,
|
|
297
|
+
getAgentState,
|
|
278
298
|
launchMode,
|
|
279
299
|
agentProvider,
|
|
280
300
|
assistantEngine,
|
|
@@ -294,6 +314,7 @@ function computeDashboardContent(options = {}) {
|
|
|
294
314
|
agentListWindowStart,
|
|
295
315
|
maxAgentWindow,
|
|
296
316
|
getAgentLabel,
|
|
317
|
+
getAgentState,
|
|
297
318
|
selectedModeIndex,
|
|
298
319
|
selectedProviderIndex,
|
|
299
320
|
selectedAssistantIndex,
|
|
@@ -320,6 +341,7 @@ function computeDashboardContent(options = {}) {
|
|
|
320
341
|
agentListWindowStart,
|
|
321
342
|
maxAgentWindow,
|
|
322
343
|
getAgentLabel,
|
|
344
|
+
getAgentState,
|
|
323
345
|
selectedModeIndex,
|
|
324
346
|
selectedProviderIndex,
|
|
325
347
|
selectedAssistantIndex,
|
|
@@ -337,6 +359,7 @@ function computeDashboardContent(options = {}) {
|
|
|
337
359
|
content += buildSummaryLine({
|
|
338
360
|
activeAgents,
|
|
339
361
|
getAgentLabel,
|
|
362
|
+
getAgentState,
|
|
340
363
|
launchMode,
|
|
341
364
|
agentProvider,
|
|
342
365
|
assistantEngine,
|
package/src/chat/index.js
CHANGED
|
@@ -38,13 +38,19 @@ const { createChatLogController } = require("./chatLogController");
|
|
|
38
38
|
const { createPasteController } = require("./pasteController");
|
|
39
39
|
const { createAgentViewController } = require("./agentViewController");
|
|
40
40
|
const { createSettingsController } = require("./settingsController");
|
|
41
|
+
const { createProjectCloseController } = require("./projectCloseController");
|
|
41
42
|
const { createChatLayout } = require("./layout");
|
|
42
43
|
const { createDaemonCoordinator } = require("./daemonCoordinator");
|
|
43
44
|
const { IPC_REQUEST_TYPES } = require("../shared/eventContract");
|
|
44
45
|
const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
|
|
45
46
|
const { createDaemonTransport } = require("./daemonTransport");
|
|
46
|
-
const { listProjectRuntimes } = require("../projects/registry");
|
|
47
|
+
const { listProjectRuntimes, resolveRuntimeDir } = require("../projects/registry");
|
|
47
48
|
const { canonicalProjectRoot, buildProjectId } = require("../projects/projectId");
|
|
49
|
+
const {
|
|
50
|
+
sortProjectRuntimes,
|
|
51
|
+
parseTimestampMs,
|
|
52
|
+
filterVisibleProjectRuntimes,
|
|
53
|
+
} = require("./projectRuntimes");
|
|
48
54
|
|
|
49
55
|
async function runChat(projectRoot, options = {}) {
|
|
50
56
|
const globalMode = options && options.globalMode === true;
|
|
@@ -655,6 +661,7 @@ async function runChat(projectRoot, options = {}) {
|
|
|
655
661
|
let activeAgents = [];
|
|
656
662
|
let activeAgentLabelMap = new Map();
|
|
657
663
|
let activeAgentMetaMap = new Map(); // Store full meta including launch_mode
|
|
664
|
+
const transientAgentStateMap = new Map();
|
|
658
665
|
let agentListWindowStart = 0;
|
|
659
666
|
const MAX_AGENT_WINDOW = 4;
|
|
660
667
|
let projectRuntimes = [];
|
|
@@ -698,7 +705,7 @@ async function runChat(projectRoot, options = {}) {
|
|
|
698
705
|
cron: "Ctrl+X close · ↑ back",
|
|
699
706
|
resume: "",
|
|
700
707
|
projects: "Use /project switch <index|path>",
|
|
701
|
-
projectsFocus: "←/→ switch · ↓ second row · Enter confirm · ↑ back",
|
|
708
|
+
projectsFocus: "←/→ switch · Ctrl+X close · ↓ second row · Enter confirm · ↑ back",
|
|
702
709
|
projectsEmpty: "Run ufoo chat or ufoo daemon start in project directories",
|
|
703
710
|
};
|
|
704
711
|
const AGENT_BAR_HINTS = {
|
|
@@ -718,7 +725,7 @@ async function runChat(projectRoot, options = {}) {
|
|
|
718
725
|
if (!terminalAdapterRouter) return null;
|
|
719
726
|
const meta = activeAgentMetaMap ? activeAgentMetaMap.get(agentId) : null;
|
|
720
727
|
const agentLaunchMode = (meta && meta.launch_mode) || launchMode || "";
|
|
721
|
-
return terminalAdapterRouter.getAdapter({ launchMode: agentLaunchMode, agentId });
|
|
728
|
+
return terminalAdapterRouter.getAdapter({ launchMode: agentLaunchMode, agentId, meta });
|
|
722
729
|
}
|
|
723
730
|
|
|
724
731
|
function getViewingAgentAdapter() {
|
|
@@ -927,6 +934,7 @@ async function runChat(projectRoot, options = {}) {
|
|
|
927
934
|
} catch {
|
|
928
935
|
rows = [];
|
|
929
936
|
}
|
|
937
|
+
rows = filterVisibleProjectRuntimes(rows);
|
|
930
938
|
const normalizedActive = String(activeProjectRoot || "");
|
|
931
939
|
if (
|
|
932
940
|
normalizedActive
|
|
@@ -939,7 +947,27 @@ async function runChat(projectRoot, options = {}) {
|
|
|
939
947
|
last_seen: null,
|
|
940
948
|
});
|
|
941
949
|
}
|
|
942
|
-
projectRuntimes =
|
|
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
|
+
});
|
|
943
971
|
|
|
944
972
|
if (projectRuntimes.length === 0) {
|
|
945
973
|
selectedProjectIndex = -1;
|
|
@@ -1040,8 +1068,6 @@ async function runChat(projectRoot, options = {}) {
|
|
|
1040
1068
|
logMessage("error", "{white-fg}✗{/white-fg} No agent selected");
|
|
1041
1069
|
return;
|
|
1042
1070
|
}
|
|
1043
|
-
const label = getAgentLabel(agentId);
|
|
1044
|
-
logMessage("status", `{white-fg}⚙{/white-fg} Closing ${label}...`);
|
|
1045
1071
|
send({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
|
|
1046
1072
|
}
|
|
1047
1073
|
|
|
@@ -1135,6 +1161,18 @@ async function runChat(projectRoot, options = {}) {
|
|
|
1135
1161
|
agentListWindowStart,
|
|
1136
1162
|
maxAgentWindow: MAX_AGENT_WINDOW,
|
|
1137
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
|
+
},
|
|
1138
1176
|
launchMode,
|
|
1139
1177
|
agentProvider,
|
|
1140
1178
|
assistantEngine,
|
|
@@ -1162,8 +1200,36 @@ async function runChat(projectRoot, options = {}) {
|
|
|
1162
1200
|
dashboard.setContent(dashboardContent);
|
|
1163
1201
|
}
|
|
1164
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;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1165
1223
|
function updateDashboard(status) {
|
|
1166
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
|
+
}
|
|
1167
1233
|
if (globalMode) {
|
|
1168
1234
|
refreshProjectRuntimes();
|
|
1169
1235
|
}
|
|
@@ -1187,7 +1253,35 @@ async function runChat(projectRoot, options = {}) {
|
|
|
1187
1253
|
}
|
|
1188
1254
|
const maps = agentDirectory.buildAgentMaps(activeAgents, metaList, fallbackMap);
|
|
1189
1255
|
activeAgentLabelMap = maps.labelMap;
|
|
1190
|
-
|
|
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
|
+
}
|
|
1191
1285
|
clampAgentWindow();
|
|
1192
1286
|
// If viewing agent went offline, exit view
|
|
1193
1287
|
const currentView = getCurrentView();
|
|
@@ -1320,6 +1414,7 @@ async function runChat(projectRoot, options = {}) {
|
|
|
1320
1414
|
clampAgentWindow,
|
|
1321
1415
|
clampAgentWindowWithSelection,
|
|
1322
1416
|
requestProjectSwitch: requestProjectSwitchByIndex,
|
|
1417
|
+
requestCloseProject: requestCloseProjectByIndex,
|
|
1323
1418
|
renderDashboard,
|
|
1324
1419
|
renderAgentDashboard,
|
|
1325
1420
|
renderScreen: () => screen.render(),
|
|
@@ -1379,6 +1474,15 @@ async function runChat(projectRoot, options = {}) {
|
|
|
1379
1474
|
agentListWindowStart = value;
|
|
1380
1475
|
},
|
|
1381
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
|
+
},
|
|
1382
1486
|
setDashboardView: (value) => {
|
|
1383
1487
|
dashboardView = value;
|
|
1384
1488
|
},
|
|
@@ -1444,6 +1548,21 @@ async function runChat(projectRoot, options = {}) {
|
|
|
1444
1548
|
appendStreamDelta,
|
|
1445
1549
|
finalizeStream,
|
|
1446
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
|
+
},
|
|
1447
1566
|
});
|
|
1448
1567
|
|
|
1449
1568
|
daemonCoordinator = createDaemonCoordinator({
|
|
@@ -1677,6 +1796,29 @@ async function runChat(projectRoot, options = {}) {
|
|
|
1677
1796
|
return { ok: false, error: "switch did not complete" };
|
|
1678
1797
|
}
|
|
1679
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
|
+
|
|
1680
1822
|
const commandExecutor = createCommandExecutor({
|
|
1681
1823
|
projectRoot,
|
|
1682
1824
|
parseCommand,
|
|
@@ -1856,7 +1998,33 @@ async function runChat(projectRoot, options = {}) {
|
|
|
1856
1998
|
if (daemonCoordinator && daemonCoordinator.isConnected()) {
|
|
1857
1999
|
requestStatus();
|
|
1858
2000
|
}
|
|
1859
|
-
},
|
|
2001
|
+
}, 5000);
|
|
2002
|
+
|
|
2003
|
+
// Global mode: watch runtime registry for new/removed projects
|
|
2004
|
+
if (globalMode) {
|
|
2005
|
+
const runtimeDir = resolveRuntimeDir();
|
|
2006
|
+
if (!fs.existsSync(runtimeDir)) {
|
|
2007
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
2008
|
+
}
|
|
2009
|
+
let runtimeWatchDebounce = null;
|
|
2010
|
+
try {
|
|
2011
|
+
const watcher = fs.watch(runtimeDir, () => {
|
|
2012
|
+
if (runtimeWatchDebounce) return;
|
|
2013
|
+
runtimeWatchDebounce = setTimeout(() => {
|
|
2014
|
+
runtimeWatchDebounce = null;
|
|
2015
|
+
const prevCount = projectRuntimes.length;
|
|
2016
|
+
refreshProjectRuntimes();
|
|
2017
|
+
if (projectRuntimes.length !== prevCount) {
|
|
2018
|
+
renderDashboard();
|
|
2019
|
+
screen.render();
|
|
2020
|
+
}
|
|
2021
|
+
}, 300);
|
|
2022
|
+
});
|
|
2023
|
+
screen.on("destroy", () => watcher.close());
|
|
2024
|
+
} catch {
|
|
2025
|
+
// Fallback: ignore if fs.watch not supported
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
1860
2028
|
screen.on("resize", () => {
|
|
1861
2029
|
if (handleResizeInAgentView()) {
|
|
1862
2030
|
return;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
function normalizeIndex(value, length) {
|
|
2
|
+
const parsed = Number(value);
|
|
3
|
+
const nextIndex = Number.isFinite(parsed) ? Math.trunc(parsed) : Number.NaN;
|
|
4
|
+
if (!Number.isFinite(nextIndex) || nextIndex < 0 || nextIndex >= length) {
|
|
5
|
+
return -1;
|
|
6
|
+
}
|
|
7
|
+
return nextIndex;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function defaultResolveProjectRoot(row = {}) {
|
|
11
|
+
return String((row && row.project_root) || "");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function createProjectCloseController(options = {}) {
|
|
15
|
+
const {
|
|
16
|
+
getProjects = () => [],
|
|
17
|
+
getActiveProjectRoot = () => "",
|
|
18
|
+
resolveProjectRoot = defaultResolveProjectRoot,
|
|
19
|
+
isRunning = () => false,
|
|
20
|
+
stopDaemon = () => false,
|
|
21
|
+
switchProject = async () => ({ ok: false, error: "project switching unavailable" }),
|
|
22
|
+
refreshProjects = () => {},
|
|
23
|
+
renderDashboard = () => {},
|
|
24
|
+
renderScreen = () => {},
|
|
25
|
+
logMessage = () => {},
|
|
26
|
+
escapeBlessed = (value) => String(value || ""),
|
|
27
|
+
} = options;
|
|
28
|
+
|
|
29
|
+
let closingProject = false;
|
|
30
|
+
|
|
31
|
+
function pickFallbackProjectRoot(targetProjectRoot) {
|
|
32
|
+
const rows = Array.isArray(getProjects()) ? getProjects() : [];
|
|
33
|
+
for (const row of rows) {
|
|
34
|
+
const root = resolveProjectRoot(row);
|
|
35
|
+
if (!root || root === targetProjectRoot) continue;
|
|
36
|
+
return root;
|
|
37
|
+
}
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function requestCloseProject(index) {
|
|
42
|
+
if (closingProject) {
|
|
43
|
+
return { ok: false, error: "project close already in progress" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const rows = Array.isArray(getProjects()) ? getProjects() : [];
|
|
47
|
+
const nextIndex = normalizeIndex(index, rows.length);
|
|
48
|
+
if (nextIndex < 0) {
|
|
49
|
+
return { ok: false, error: "project index out of range" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const target = rows[nextIndex] || {};
|
|
53
|
+
const projectRoot = resolveProjectRoot(target);
|
|
54
|
+
if (!projectRoot) {
|
|
55
|
+
return { ok: false, error: "project root unavailable" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const projectName = String(target.project_name || projectRoot);
|
|
59
|
+
const escapedName = escapeBlessed(projectName);
|
|
60
|
+
const activeProjectRoot = String(getActiveProjectRoot() || "");
|
|
61
|
+
|
|
62
|
+
closingProject = true;
|
|
63
|
+
try {
|
|
64
|
+
logMessage("status", `{white-fg}⚙{/white-fg} Closing project ${escapedName} daemon and agents...`);
|
|
65
|
+
|
|
66
|
+
let switchedTo = "";
|
|
67
|
+
if (activeProjectRoot === projectRoot) {
|
|
68
|
+
const fallbackRoot = pickFallbackProjectRoot(projectRoot);
|
|
69
|
+
if (!fallbackRoot) {
|
|
70
|
+
const error = "Cannot close current project; switch to another project first";
|
|
71
|
+
logMessage("error", `{white-fg}✗{/white-fg} ${escapeBlessed(error)}`);
|
|
72
|
+
return { ok: false, error };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const switched = await Promise.resolve(switchProject(fallbackRoot));
|
|
76
|
+
if (!switched || switched.ok !== true) {
|
|
77
|
+
const reason = String((switched && switched.error) || "switch failed");
|
|
78
|
+
logMessage("error", `{white-fg}✗{/white-fg} Failed to switch project before close: ${escapeBlessed(reason)}`);
|
|
79
|
+
return { ok: false, error: reason };
|
|
80
|
+
}
|
|
81
|
+
switchedTo = fallbackRoot;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const wasRunning = Boolean(isRunning(projectRoot));
|
|
85
|
+
stopDaemon(projectRoot);
|
|
86
|
+
|
|
87
|
+
refreshProjects();
|
|
88
|
+
renderDashboard();
|
|
89
|
+
renderScreen();
|
|
90
|
+
|
|
91
|
+
if (wasRunning) {
|
|
92
|
+
logMessage("status", `{white-fg}✓{/white-fg} Closed project ${escapedName} daemon and agents`);
|
|
93
|
+
} else {
|
|
94
|
+
logMessage("status", `{white-fg}✓{/white-fg} Project ${escapedName} daemon already stopped`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
project_root: projectRoot,
|
|
100
|
+
switched_to: switchedTo || undefined,
|
|
101
|
+
};
|
|
102
|
+
} catch (err) {
|
|
103
|
+
const message = err && err.message ? err.message : String(err || "project close failed");
|
|
104
|
+
logMessage("error", `{white-fg}✗{/white-fg} Failed to close project: ${escapeBlessed(message)}`);
|
|
105
|
+
return { ok: false, error: message };
|
|
106
|
+
} finally {
|
|
107
|
+
closingProject = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
requestCloseProject,
|
|
113
|
+
pickFallbackProjectRoot,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
createProjectCloseController,
|
|
119
|
+
};
|