u-foo 1.5.0 → 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/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 +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/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 +148 -6
- 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/daemon/index.js +12 -3
- package/src/daemon/ipcServer.js +6 -1
- package/src/daemon/ops.js +46 -12
- package/src/daemon/status.js +3 -1
- package/src/init/index.js +32 -3
- package/src/ufoo/agentsStore.js +44 -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,6 +38,7 @@ 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");
|
|
@@ -45,6 +46,11 @@ const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
|
|
|
45
46
|
const { createDaemonTransport } = require("./daemonTransport");
|
|
46
47
|
const { listProjectRuntimes } = 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 = {
|
|
@@ -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,7 @@ async function runChat(projectRoot, options = {}) {
|
|
|
1856
1998
|
if (daemonCoordinator && daemonCoordinator.isConnected()) {
|
|
1857
1999
|
requestStatus();
|
|
1858
2000
|
}
|
|
1859
|
-
},
|
|
2001
|
+
}, 5000);
|
|
1860
2002
|
screen.on("resize", () => {
|
|
1861
2003
|
if (handleResizeInAgentView()) {
|
|
1862
2004
|
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
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
function parseTimestampMs(value) {
|
|
2
|
+
const parsed = Date.parse(String(value || ""));
|
|
3
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function projectLabel(row = {}) {
|
|
7
|
+
return String(row.project_name || row.project_root || "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeInteractionMs(value) {
|
|
11
|
+
const num = Number(value);
|
|
12
|
+
if (!Number.isFinite(num) || num < 0) return 0;
|
|
13
|
+
return num;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function filterVisibleProjectRuntimes(rows = []) {
|
|
17
|
+
const sourceRows = Array.isArray(rows) ? rows : [];
|
|
18
|
+
return sourceRows.filter((row) => {
|
|
19
|
+
const status = String((row && row.status) || "").trim().toLowerCase();
|
|
20
|
+
return status !== "stopped";
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sortProjectRuntimes(options = {}) {
|
|
25
|
+
const {
|
|
26
|
+
rows = [],
|
|
27
|
+
activeProjectRoot = "",
|
|
28
|
+
resolveProjectRoot = (row) => String((row && row.project_root) || ""),
|
|
29
|
+
getInteractionMs = () => 0,
|
|
30
|
+
} = options;
|
|
31
|
+
const sourceRows = Array.isArray(rows) ? rows.slice() : [];
|
|
32
|
+
// Keep arg usage for backward compatibility with existing callers/tests.
|
|
33
|
+
void activeProjectRoot;
|
|
34
|
+
void resolveProjectRoot;
|
|
35
|
+
|
|
36
|
+
sourceRows.sort((a, b) => {
|
|
37
|
+
const bInteraction = normalizeInteractionMs(getInteractionMs(b));
|
|
38
|
+
const aInteraction = normalizeInteractionMs(getInteractionMs(a));
|
|
39
|
+
if (bInteraction !== aInteraction) return bInteraction - aInteraction;
|
|
40
|
+
|
|
41
|
+
const bSeen = parseTimestampMs(b && b.last_seen);
|
|
42
|
+
const aSeen = parseTimestampMs(a && a.last_seen);
|
|
43
|
+
if (bSeen !== aSeen) return bSeen - aSeen;
|
|
44
|
+
|
|
45
|
+
return projectLabel(a).localeCompare(projectLabel(b), "en", { sensitivity: "base" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return sourceRows;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
sortProjectRuntimes,
|
|
53
|
+
parseTimestampMs,
|
|
54
|
+
filterVisibleProjectRuntimes,
|
|
55
|
+
};
|
|
@@ -107,20 +107,66 @@ function createStatusLineController(options = {}) {
|
|
|
107
107
|
renderStatusLine();
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
function
|
|
111
|
-
|
|
110
|
+
function normalizePendingItem(text, options = {}) {
|
|
111
|
+
const key = options && typeof options.key === "string"
|
|
112
|
+
? options.key.trim()
|
|
113
|
+
: "";
|
|
114
|
+
return {
|
|
115
|
+
text: text || "",
|
|
116
|
+
key,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function headPendingText() {
|
|
121
|
+
if (pendingStatusLines.length === 0) return "";
|
|
122
|
+
const item = pendingStatusLines[0];
|
|
123
|
+
return item && typeof item.text === "string" ? item.text : "";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function queueStatusLine(text, options = {}) {
|
|
127
|
+
const item = normalizePendingItem(text, options);
|
|
128
|
+
if (item.key) {
|
|
129
|
+
const existingIndex = pendingStatusLines.findIndex((entry) => entry.key === item.key);
|
|
130
|
+
if (existingIndex >= 0) {
|
|
131
|
+
pendingStatusLines[existingIndex] = item;
|
|
132
|
+
if (existingIndex === 0) {
|
|
133
|
+
setPrimaryStatus(item.text, { pending: true });
|
|
134
|
+
renderScreen();
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
pendingStatusLines.push(item);
|
|
112
141
|
if (pendingStatusLines.length === 1) {
|
|
113
|
-
setPrimaryStatus(
|
|
142
|
+
setPrimaryStatus(item.text, { pending: true });
|
|
114
143
|
renderScreen();
|
|
115
144
|
}
|
|
116
145
|
}
|
|
117
146
|
|
|
118
|
-
function resolveStatusLine(text) {
|
|
147
|
+
function resolveStatusLine(text, options = {}) {
|
|
148
|
+
const key = options && typeof options.key === "string"
|
|
149
|
+
? options.key.trim()
|
|
150
|
+
: "";
|
|
151
|
+
let removedHead = false;
|
|
152
|
+
|
|
119
153
|
if (pendingStatusLines.length > 0) {
|
|
120
|
-
|
|
154
|
+
if (key) {
|
|
155
|
+
const index = pendingStatusLines.findIndex((entry) => entry.key === key);
|
|
156
|
+
if (index >= 0) {
|
|
157
|
+
pendingStatusLines.splice(index, 1);
|
|
158
|
+
removedHead = index === 0;
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
pendingStatusLines.shift();
|
|
162
|
+
removedHead = true;
|
|
163
|
+
}
|
|
121
164
|
}
|
|
165
|
+
|
|
122
166
|
if (pendingStatusLines.length > 0) {
|
|
123
|
-
|
|
167
|
+
if (removedHead || !primaryStatusPending) {
|
|
168
|
+
setPrimaryStatus(headPendingText(), { pending: true });
|
|
169
|
+
}
|
|
124
170
|
} else {
|
|
125
171
|
setPrimaryStatus(text || "", { pending: false });
|
|
126
172
|
}
|
package/src/chat/transport.js
CHANGED
|
@@ -3,10 +3,46 @@ const path = require("path");
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const { spawn, spawnSync } = require("child_process");
|
|
5
5
|
|
|
6
|
-
function connectSocket(sockPath) {
|
|
6
|
+
function connectSocket(sockPath, options = {}) {
|
|
7
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
|
8
|
+
? Math.trunc(options.timeoutMs)
|
|
9
|
+
: 0;
|
|
7
10
|
return new Promise((resolve, reject) => {
|
|
8
|
-
|
|
9
|
-
client.
|
|
11
|
+
let timeoutHandle = null;
|
|
12
|
+
const client = net.createConnection(sockPath, () => {
|
|
13
|
+
if (timeoutHandle) {
|
|
14
|
+
clearTimeout(timeoutHandle);
|
|
15
|
+
}
|
|
16
|
+
resolve(client);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const cleanup = () => {
|
|
20
|
+
if (timeoutHandle) {
|
|
21
|
+
clearTimeout(timeoutHandle);
|
|
22
|
+
timeoutHandle = null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
client.on("error", (err) => {
|
|
27
|
+
cleanup();
|
|
28
|
+
reject(err);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (timeoutMs > 0) {
|
|
32
|
+
timeoutHandle = setTimeout(() => {
|
|
33
|
+
const err = new Error(`connect timeout after ${timeoutMs}ms`);
|
|
34
|
+
err.code = "ETIMEDOUT";
|
|
35
|
+
try {
|
|
36
|
+
client.destroy(err);
|
|
37
|
+
} catch {
|
|
38
|
+
// ignore
|
|
39
|
+
}
|
|
40
|
+
reject(err);
|
|
41
|
+
}, timeoutMs);
|
|
42
|
+
if (typeof timeoutHandle.unref === "function") {
|
|
43
|
+
timeoutHandle.unref();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
10
46
|
});
|
|
11
47
|
}
|
|
12
48
|
|
|
@@ -38,11 +74,11 @@ function stopDaemon(projectRoot) {
|
|
|
38
74
|
});
|
|
39
75
|
}
|
|
40
76
|
|
|
41
|
-
async function connectWithRetry(sockPath, retries, delayMs) {
|
|
77
|
+
async function connectWithRetry(sockPath, retries, delayMs, options = {}) {
|
|
42
78
|
for (let i = 0; i < retries; i += 1) {
|
|
43
79
|
try {
|
|
44
80
|
// eslint-disable-next-line no-await-in-loop
|
|
45
|
-
const client = await connectSocket(sockPath);
|
|
81
|
+
const client = await connectSocket(sockPath, options);
|
|
46
82
|
return client;
|
|
47
83
|
} catch {
|
|
48
84
|
// eslint-disable-next-line no-await-in-loop
|