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
|
@@ -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
|
package/src/cli.js
CHANGED
|
@@ -133,6 +133,19 @@ function requireOptional(name) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
function collectHostLaunchRequestContext(env = process.env) {
|
|
137
|
+
const hostInjectSock = String(env.UFOO_HOST_INJECT_SOCK || env.HORIZON_INJECT_SOCK || "").trim();
|
|
138
|
+
const hostDaemonSock = String(env.UFOO_HOST_DAEMON_SOCK || "").trim();
|
|
139
|
+
const hostName = String(env.UFOO_HOST_NAME || "").trim();
|
|
140
|
+
const hostSessionId = String(env.UFOO_HOST_SESSION_ID || env.HORIZON_SESSION_ID || "").trim();
|
|
141
|
+
const context = {};
|
|
142
|
+
if (hostInjectSock) context.host_inject_sock = hostInjectSock;
|
|
143
|
+
if (hostDaemonSock) context.host_daemon_sock = hostDaemonSock;
|
|
144
|
+
if (hostName) context.host_name = hostName;
|
|
145
|
+
if (hostSessionId) context.host_session_id = hostSessionId;
|
|
146
|
+
return context;
|
|
147
|
+
}
|
|
148
|
+
|
|
136
149
|
function collectOption(value, previous) {
|
|
137
150
|
const next = Array.isArray(previous) ? previous.slice() : [];
|
|
138
151
|
const parts = String(value || "")
|
|
@@ -424,6 +437,7 @@ async function runCli(argv) {
|
|
|
424
437
|
agent: normalizedAgent,
|
|
425
438
|
nickname: nickname || "",
|
|
426
439
|
count: 1,
|
|
440
|
+
...collectHostLaunchRequestContext(),
|
|
427
441
|
});
|
|
428
442
|
const reply = resp?.data?.reply || `Launching ${normalizedAgent} agent...`;
|
|
429
443
|
console.log(reply);
|
package/src/config.js
CHANGED
package/src/daemon/index.js
CHANGED
|
@@ -328,6 +328,16 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
|
|
|
328
328
|
const launchResult = await launchAgent(projectRoot, agent, count, nickname, processManager, {
|
|
329
329
|
launchScope: op.launch_scope || "",
|
|
330
330
|
terminalApp: op.terminal_app || "",
|
|
331
|
+
hostInjectSock: op.host_inject_sock || op.hostInjectSock || "",
|
|
332
|
+
hostDaemonSock: op.host_daemon_sock || op.hostDaemonSock || "",
|
|
333
|
+
hostName: op.host_name || op.hostName || "",
|
|
334
|
+
hostSessionId: op.host_session_id || op.hostSessionId || "",
|
|
335
|
+
hostCapabilities:
|
|
336
|
+
(op.host_capabilities && typeof op.host_capabilities === "object")
|
|
337
|
+
? op.host_capabilities
|
|
338
|
+
: ((op.hostCapabilities && typeof op.hostCapabilities === "object")
|
|
339
|
+
? op.hostCapabilities
|
|
340
|
+
: null),
|
|
331
341
|
});
|
|
332
342
|
if (launchResult.mode === "internal" && launchResult.subscriberIds && launchResult.subscriberIds.length > 0) {
|
|
333
343
|
const probeAgentType = agent === "codex"
|
|
@@ -378,8 +388,15 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
|
|
|
378
388
|
results.push({ action: "launch", ok: false, agent, count, error: err.message });
|
|
379
389
|
}
|
|
380
390
|
} else if (op.action === "close") {
|
|
381
|
-
const
|
|
382
|
-
|
|
391
|
+
const closeResult = await closeAgent(projectRoot, op.agent_id);
|
|
392
|
+
const normalizedClose = closeResult && typeof closeResult === "object"
|
|
393
|
+
? closeResult
|
|
394
|
+
: { ok: Boolean(closeResult) };
|
|
395
|
+
results.push({
|
|
396
|
+
action: "close",
|
|
397
|
+
agent_id: op.agent_id,
|
|
398
|
+
...normalizedClose,
|
|
399
|
+
});
|
|
383
400
|
} else if (op.action === "rename") {
|
|
384
401
|
const agentId = op.agent_id || "";
|
|
385
402
|
const nickname = op.nickname || "";
|
|
@@ -943,7 +960,9 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
943
960
|
const closeResult = opsResults.find((r) => r.action === "close");
|
|
944
961
|
const ok = closeResult ? closeResult.ok !== false : true;
|
|
945
962
|
const reply = ok
|
|
946
|
-
?
|
|
963
|
+
? (closeResult && closeResult.already_stopped
|
|
964
|
+
? `Closed ${agent_id} (already stopped)`
|
|
965
|
+
: `Closed ${agent_id}`)
|
|
947
966
|
: `Close failed: ${closeResult?.error || "unknown error"}`;
|
|
948
967
|
socket.write(
|
|
949
968
|
`${JSON.stringify({
|
|
@@ -971,7 +990,18 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
971
990
|
}
|
|
972
991
|
if (req.type === IPC_REQUEST_TYPES.LAUNCH_AGENT) {
|
|
973
992
|
log(`launch_agent received: agent=${req.agent} count=${req.count}`);
|
|
974
|
-
const {
|
|
993
|
+
const {
|
|
994
|
+
agent,
|
|
995
|
+
count,
|
|
996
|
+
nickname,
|
|
997
|
+
launch_scope,
|
|
998
|
+
terminal_app,
|
|
999
|
+
host_inject_sock,
|
|
1000
|
+
host_daemon_sock,
|
|
1001
|
+
host_name,
|
|
1002
|
+
host_session_id,
|
|
1003
|
+
host_capabilities,
|
|
1004
|
+
} = req;
|
|
975
1005
|
const normalizedAgent = normalizeLaunchAgent(agent);
|
|
976
1006
|
if (!normalizedAgent) {
|
|
977
1007
|
socket.write(
|
|
@@ -992,6 +1022,14 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
992
1022
|
nickname: nickname || "",
|
|
993
1023
|
launch_scope: launch_scope || "",
|
|
994
1024
|
terminal_app: terminal_app || "",
|
|
1025
|
+
host_inject_sock: host_inject_sock || "",
|
|
1026
|
+
host_daemon_sock: host_daemon_sock || "",
|
|
1027
|
+
host_name: host_name || "",
|
|
1028
|
+
host_session_id: host_session_id || "",
|
|
1029
|
+
host_capabilities:
|
|
1030
|
+
host_capabilities && typeof host_capabilities === "object"
|
|
1031
|
+
? host_capabilities
|
|
1032
|
+
: null,
|
|
995
1033
|
};
|
|
996
1034
|
try {
|
|
997
1035
|
const opsResults = await handleOps(projectRoot, [op], processManager);
|
|
@@ -1394,7 +1432,20 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
1394
1432
|
}
|
|
1395
1433
|
if (req.type === IPC_REQUEST_TYPES.REGISTER_AGENT) {
|
|
1396
1434
|
// Manual agent launch requests daemon to register it
|
|
1397
|
-
const {
|
|
1435
|
+
const {
|
|
1436
|
+
agentType,
|
|
1437
|
+
nickname,
|
|
1438
|
+
parentPid,
|
|
1439
|
+
launchMode,
|
|
1440
|
+
tmuxPane,
|
|
1441
|
+
tty,
|
|
1442
|
+
hostInjectSock,
|
|
1443
|
+
hostDaemonSock,
|
|
1444
|
+
hostName,
|
|
1445
|
+
hostSessionId,
|
|
1446
|
+
hostCapabilities,
|
|
1447
|
+
skipProbe,
|
|
1448
|
+
} = req;
|
|
1398
1449
|
if (!agentType) {
|
|
1399
1450
|
socket.write(
|
|
1400
1451
|
`${JSON.stringify({
|
|
@@ -1442,6 +1493,13 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
1442
1493
|
launchMode: launchMode || "",
|
|
1443
1494
|
tmuxPane: tmuxPane || "",
|
|
1444
1495
|
tty: tty || "",
|
|
1496
|
+
hostInjectSock: hostInjectSock || "",
|
|
1497
|
+
hostDaemonSock: hostDaemonSock || "",
|
|
1498
|
+
hostName: hostName || "",
|
|
1499
|
+
hostSessionId: hostSessionId || "",
|
|
1500
|
+
hostCapabilities: hostCapabilities && typeof hostCapabilities === "object"
|
|
1501
|
+
? hostCapabilities
|
|
1502
|
+
: null,
|
|
1445
1503
|
reuseSessionId,
|
|
1446
1504
|
reuseProviderSessionId,
|
|
1447
1505
|
};
|
package/src/daemon/ipcServer.js
CHANGED
|
@@ -28,6 +28,7 @@ function createDaemonIpcServer(options = {}) {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
let lastActiveJson = "";
|
|
31
|
+
let lastMetaJson = "";
|
|
31
32
|
const statusSyncInterval = setInterval(() => {
|
|
32
33
|
if (sockets.size === 0) return;
|
|
33
34
|
try {
|
|
@@ -38,8 +39,12 @@ function createDaemonIpcServer(options = {}) {
|
|
|
38
39
|
try {
|
|
39
40
|
const status = buildStatus(projectRoot);
|
|
40
41
|
const currentActiveJson = JSON.stringify(status.active);
|
|
41
|
-
|
|
42
|
+
const currentMetaJson = JSON.stringify(
|
|
43
|
+
(status.active_meta || []).map((m) => `${m.id}:${m.activity_state || ""}`)
|
|
44
|
+
);
|
|
45
|
+
if (currentActiveJson !== lastActiveJson || currentMetaJson !== lastMetaJson) {
|
|
42
46
|
lastActiveJson = currentActiveJson;
|
|
47
|
+
lastMetaJson = currentMetaJson;
|
|
43
48
|
sendToSockets({ type: IPC_RESPONSE_TYPES.STATUS, data: status });
|
|
44
49
|
log(`status sync: active agents changed to ${status.active.length}`);
|
|
45
50
|
}
|
package/src/daemon/ops.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { spawn } = require("child_process");
|
|
1
|
+
const { spawn, spawnSync } = require("child_process");
|
|
2
2
|
const fs = require("fs");
|
|
3
3
|
const path = require("path");
|
|
4
4
|
const { loadConfig } = require("../config");
|
|
@@ -7,6 +7,11 @@ const { loadAgentsData, saveAgentsData } = require("../ufoo/agentsStore");
|
|
|
7
7
|
const { isAgentPidAlive, getTtyProcessInfo } = require("../bus/utils");
|
|
8
8
|
const { isITerm2 } = require("../terminal/detect");
|
|
9
9
|
const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
|
|
10
|
+
const {
|
|
11
|
+
createSession: createHostSession,
|
|
12
|
+
closeSession: closeHostSession,
|
|
13
|
+
sendToSocket: sendHostSocketRequest,
|
|
14
|
+
} = require("../terminal/adapters/hostAdapter");
|
|
10
15
|
|
|
11
16
|
function normalizeLaunchAgent(agent = "") {
|
|
12
17
|
const value = String(agent || "").trim().toLowerCase();
|
|
@@ -75,6 +80,44 @@ function normalizeTerminalAppPreference(value = "") {
|
|
|
75
80
|
return "";
|
|
76
81
|
}
|
|
77
82
|
|
|
83
|
+
function normalizeOptionalString(value = "") {
|
|
84
|
+
return typeof value === "string" ? value.trim() : "";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveHostLaunchContext(options = {}) {
|
|
88
|
+
return {
|
|
89
|
+
hostInjectSock:
|
|
90
|
+
normalizeOptionalString(options.hostInjectSock)
|
|
91
|
+
|| normalizeOptionalString(process.env.UFOO_HOST_INJECT_SOCK)
|
|
92
|
+
|| normalizeOptionalString(process.env.HORIZON_INJECT_SOCK),
|
|
93
|
+
hostDaemonSock:
|
|
94
|
+
normalizeOptionalString(options.hostDaemonSock)
|
|
95
|
+
|| normalizeOptionalString(process.env.UFOO_HOST_DAEMON_SOCK),
|
|
96
|
+
hostName:
|
|
97
|
+
normalizeOptionalString(options.hostName)
|
|
98
|
+
|| normalizeOptionalString(process.env.UFOO_HOST_NAME),
|
|
99
|
+
hostSessionId:
|
|
100
|
+
normalizeOptionalString(options.hostSessionId)
|
|
101
|
+
|| normalizeOptionalString(process.env.UFOO_HOST_SESSION_ID)
|
|
102
|
+
|| normalizeOptionalString(process.env.HORIZON_SESSION_ID),
|
|
103
|
+
hostCapabilities:
|
|
104
|
+
options.hostCapabilities && typeof options.hostCapabilities === "object"
|
|
105
|
+
? { ...options.hostCapabilities }
|
|
106
|
+
: null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveConfiguredLaunchMode(configuredMode = "", options = {}) {
|
|
111
|
+
const mode = normalizeOptionalString(configuredMode);
|
|
112
|
+
if (mode === "internal" || mode === "tmux" || mode === "terminal" || mode === "host") {
|
|
113
|
+
return mode;
|
|
114
|
+
}
|
|
115
|
+
const hostContext = resolveHostLaunchContext(options);
|
|
116
|
+
if (hostContext.hostDaemonSock) return "host";
|
|
117
|
+
if (process.env.TMUX_PANE) return "tmux";
|
|
118
|
+
return "terminal";
|
|
119
|
+
}
|
|
120
|
+
|
|
78
121
|
function resolveAgentId(projectRoot, agentId) {
|
|
79
122
|
if (!agentId) return agentId;
|
|
80
123
|
if (agentId.includes(":")) return agentId;
|
|
@@ -395,6 +438,82 @@ async function spawnManagedTerminalAgent(
|
|
|
395
438
|
return { child: null, subscriberId: subscriberId || null };
|
|
396
439
|
}
|
|
397
440
|
|
|
441
|
+
async function spawnManagedHostAgent(
|
|
442
|
+
projectRoot,
|
|
443
|
+
agent,
|
|
444
|
+
nickname = "",
|
|
445
|
+
processManager = null,
|
|
446
|
+
extraArgs = [],
|
|
447
|
+
extraEnv = "",
|
|
448
|
+
hostOptions = {}
|
|
449
|
+
) {
|
|
450
|
+
void processManager;
|
|
451
|
+
const normalizedAgent = normalizeLaunchAgent(agent);
|
|
452
|
+
const binary = toTerminalBinary(normalizedAgent);
|
|
453
|
+
const agentType = toBusAgentType(normalizedAgent);
|
|
454
|
+
if (!binary || !agentType) {
|
|
455
|
+
throw new Error(`unsupported agent type: ${agent}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const hostContext = resolveHostLaunchContext(hostOptions);
|
|
459
|
+
if (!hostContext.hostDaemonSock) {
|
|
460
|
+
throw new Error("host launch requires UFOO_HOST_DAEMON_SOCK");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const existing = listSubscribers(projectRoot, agentType);
|
|
464
|
+
const createOptions = {};
|
|
465
|
+
if (hostOptions.groupId) {
|
|
466
|
+
createOptions.group_id = String(hostOptions.groupId).trim();
|
|
467
|
+
} else if (hostContext.hostSessionId) {
|
|
468
|
+
createOptions.source_session_id = hostContext.hostSessionId;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const created = await createHostSession(hostContext.hostDaemonSock, createOptions);
|
|
472
|
+
const sessionId = normalizeOptionalString(created?.session_id);
|
|
473
|
+
const injectSock = normalizeOptionalString(created?.inject_sock);
|
|
474
|
+
if (!sessionId || !injectSock) {
|
|
475
|
+
throw new Error("host create_session returned incomplete session info");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const args = Array.isArray(extraArgs) ? extraArgs : [];
|
|
479
|
+
const argText = args.length > 0 ? ` ${args.map(shellEscape).join(" ")}` : "";
|
|
480
|
+
const envParts = [
|
|
481
|
+
"UFOO_LAUNCH_MODE=host",
|
|
482
|
+
`UFOO_HOST_DAEMON_SOCK=${shellEscape(hostContext.hostDaemonSock)}`,
|
|
483
|
+
`UFOO_HOST_SESSION_ID=${shellEscape(sessionId)}`,
|
|
484
|
+
`UFOO_HOST_INJECT_SOCK=${shellEscape(injectSock)}`,
|
|
485
|
+
];
|
|
486
|
+
if (nickname) {
|
|
487
|
+
envParts.push(`UFOO_NICKNAME=${shellEscape(nickname)}`);
|
|
488
|
+
}
|
|
489
|
+
if (hostContext.hostName) {
|
|
490
|
+
envParts.push(`UFOO_HOST_NAME=${shellEscape(hostContext.hostName)}`);
|
|
491
|
+
}
|
|
492
|
+
if (extraEnv) {
|
|
493
|
+
envParts.push(String(extraEnv).trim());
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const titleCmd = buildTitleCmd(nickname);
|
|
497
|
+
const launchCmd = `${envParts.join(" ")} ${binary}${argText}`.trim();
|
|
498
|
+
const runCmd = titleCmd
|
|
499
|
+
? `cd ${shellEscape(projectRoot)} && ${titleCmd} && ${launchCmd}`
|
|
500
|
+
: `cd ${shellEscape(projectRoot)} && ${launchCmd}`;
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
await sendHostSocketRequest(injectSock, { type: "inject", command: runCmd });
|
|
504
|
+
} catch (err) {
|
|
505
|
+
try {
|
|
506
|
+
await closeHostSession(sessionId, hostContext.hostDaemonSock);
|
|
507
|
+
} catch {
|
|
508
|
+
// ignore cleanup failures
|
|
509
|
+
}
|
|
510
|
+
throw err;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const subscriberId = await waitForNewSubscriber(projectRoot, agentType, existing, 20000);
|
|
514
|
+
return { child: null, subscriberId: subscriberId || null, sessionId, injectSock };
|
|
515
|
+
}
|
|
516
|
+
|
|
398
517
|
async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "", processManager = null) {
|
|
399
518
|
const runner = path.join(projectRoot, "bin", "ufoo.js");
|
|
400
519
|
const logDir = getUfooPaths(projectRoot).runDir;
|
|
@@ -609,7 +728,7 @@ function spawnTmuxPane(projectRoot, agent, nickname = "", extraArgs = [], extraE
|
|
|
609
728
|
|
|
610
729
|
async function launchAgent(projectRoot, agent, count = 1, nickname = "", processManager = null, options = {}) {
|
|
611
730
|
const config = loadConfig(projectRoot);
|
|
612
|
-
const mode = config.launchMode
|
|
731
|
+
const mode = resolveConfiguredLaunchMode(config.launchMode, options);
|
|
613
732
|
const launchScope = normalizeLaunchScope(options.launchScope, "inplace");
|
|
614
733
|
const terminalApp = normalizeTerminalAppPreference(options.terminalApp);
|
|
615
734
|
const normalizedAgent = normalizeLaunchAgent(agent);
|
|
@@ -665,6 +784,26 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
|
|
|
665
784
|
}
|
|
666
785
|
return { mode: "tmux", launchScope, subscriberIds: [] };
|
|
667
786
|
}
|
|
787
|
+
if (mode === "host") {
|
|
788
|
+
const subscriberIds = [];
|
|
789
|
+
const hostContext = resolveHostLaunchContext(options);
|
|
790
|
+
for (let i = 0; i < count; i += 1) {
|
|
791
|
+
const defaultNick = normalizedAgent === "ufoo" ? "ucode" : normalizedAgent;
|
|
792
|
+
const nick = count > 1 ? `${nickname || defaultNick}-${i + 1}` : (nickname || "");
|
|
793
|
+
// eslint-disable-next-line no-await-in-loop
|
|
794
|
+
const result = await spawnManagedHostAgent(
|
|
795
|
+
projectRoot,
|
|
796
|
+
normalizedAgent,
|
|
797
|
+
nick,
|
|
798
|
+
processManager,
|
|
799
|
+
[],
|
|
800
|
+
"",
|
|
801
|
+
hostContext
|
|
802
|
+
);
|
|
803
|
+
if (result.subscriberId) subscriberIds.push(result.subscriberId);
|
|
804
|
+
}
|
|
805
|
+
return { mode: "host", launchScope, subscriberIds };
|
|
806
|
+
}
|
|
668
807
|
// terminal mode - daemon 作为父进程,输出到终端窗口
|
|
669
808
|
if (process.platform !== "darwin") {
|
|
670
809
|
throw new Error("launchAgent with terminal mode is only supported on macOS Terminal.app");
|
|
@@ -827,43 +966,57 @@ async function resumeAgents(projectRoot, target = "", processManager = null) {
|
|
|
827
966
|
}
|
|
828
967
|
|
|
829
968
|
async function closeAgent(projectRoot, agentId) {
|
|
830
|
-
if (process.platform !== "darwin") {
|
|
831
|
-
return false;
|
|
832
|
-
}
|
|
833
969
|
const resolvedId = resolveAgentId(projectRoot, agentId);
|
|
834
970
|
const busPath = getUfooPaths(projectRoot).agentsFile;
|
|
835
|
-
let pid =
|
|
971
|
+
let pid = 0;
|
|
836
972
|
let launchMode = "";
|
|
837
973
|
let tty = "";
|
|
838
974
|
let terminalApp = "";
|
|
975
|
+
let tmuxPane = "";
|
|
976
|
+
let meta = null;
|
|
977
|
+
let found = false;
|
|
839
978
|
try {
|
|
840
979
|
const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
|
|
841
980
|
const entry = bus.agents?.[resolvedId];
|
|
842
981
|
if (entry) {
|
|
843
|
-
|
|
982
|
+
found = true;
|
|
983
|
+
meta = entry;
|
|
984
|
+
const parsedPid = Number.parseInt(entry.pid, 10);
|
|
985
|
+
pid = Number.isFinite(parsedPid) && parsedPid > 0 ? parsedPid : 0;
|
|
844
986
|
launchMode = entry.launch_mode || "";
|
|
845
987
|
tty = entry.tty || "";
|
|
846
988
|
terminalApp = entry.terminal_app || "";
|
|
989
|
+
tmuxPane = entry.tmux_pane || "";
|
|
847
990
|
}
|
|
848
991
|
} catch {
|
|
849
|
-
|
|
992
|
+
found = false;
|
|
850
993
|
}
|
|
994
|
+
|
|
995
|
+
if (!found) {
|
|
996
|
+
return { ok: true, already_stopped: true, resolved_agent_id: resolvedId };
|
|
997
|
+
}
|
|
998
|
+
|
|
851
999
|
const adapterRouter = createTerminalAdapterRouter();
|
|
852
|
-
const adapter = adapterRouter.getAdapter({ launchMode, agentId: resolvedId });
|
|
853
|
-
const canCloseWindow =
|
|
1000
|
+
const adapter = adapterRouter.getAdapter({ launchMode, agentId: resolvedId, meta });
|
|
1001
|
+
const canCloseWindow = process.platform === "darwin"
|
|
1002
|
+
&& Boolean(adapter.capabilities.supportsWindowClose)
|
|
1003
|
+
&& Boolean(tty);
|
|
854
1004
|
|
|
855
1005
|
// Close process first for faster state transition in chat.
|
|
856
1006
|
let sentSignal = false;
|
|
857
|
-
|
|
1007
|
+
let killErr = null;
|
|
1008
|
+
if (pid > 0) {
|
|
858
1009
|
try {
|
|
859
1010
|
process.kill(pid, "SIGTERM");
|
|
860
1011
|
sentSignal = true;
|
|
861
|
-
} catch {
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
killErr = err || null;
|
|
862
1014
|
sentSignal = false;
|
|
863
1015
|
}
|
|
864
1016
|
}
|
|
865
1017
|
|
|
866
|
-
|
|
1018
|
+
const pidGone = pid > 0 && !sentSignal && !isAgentPidAlive(pid);
|
|
1019
|
+
if (sentSignal || pid === 0 || pidGone) {
|
|
867
1020
|
markAgentInactive(projectRoot, resolvedId);
|
|
868
1021
|
}
|
|
869
1022
|
|
|
@@ -872,7 +1025,29 @@ async function closeAgent(projectRoot, agentId) {
|
|
|
872
1025
|
void closeTerminalWindowByTty(tty, terminalApp).catch(() => false);
|
|
873
1026
|
}
|
|
874
1027
|
|
|
875
|
-
|
|
1028
|
+
// Tmux pane cleanup: kill the pane after sending SIGTERM to the process.
|
|
1029
|
+
if (launchMode === "tmux" && tmuxPane) {
|
|
1030
|
+
try {
|
|
1031
|
+
spawnSync("tmux", ["kill-pane", "-t", tmuxPane], { stdio: "ignore", timeout: 3000 });
|
|
1032
|
+
} catch {
|
|
1033
|
+
// ignore - pane may already be gone
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (sentSignal) {
|
|
1038
|
+
return { ok: true, resolved_agent_id: resolvedId };
|
|
1039
|
+
}
|
|
1040
|
+
if (pid === 0 || pidGone) {
|
|
1041
|
+
return { ok: true, already_stopped: true, resolved_agent_id: resolvedId };
|
|
1042
|
+
}
|
|
1043
|
+
const reason = killErr && killErr.message
|
|
1044
|
+
? killErr.message
|
|
1045
|
+
: "failed to stop process";
|
|
1046
|
+
return {
|
|
1047
|
+
ok: false,
|
|
1048
|
+
error: reason,
|
|
1049
|
+
resolved_agent_id: resolvedId,
|
|
1050
|
+
};
|
|
876
1051
|
}
|
|
877
1052
|
|
|
878
1053
|
module.exports = { launchAgent, closeAgent, getRecoverableAgents, resumeAgents };
|
package/src/daemon/status.js
CHANGED
|
@@ -147,7 +147,23 @@ function buildStatus(projectRoot, options = {}) {
|
|
|
147
147
|
const launch_mode = meta?.launch_mode || "unknown";
|
|
148
148
|
const tmux_pane = meta?.tmux_pane || "";
|
|
149
149
|
const tty = meta?.tty || "";
|
|
150
|
-
|
|
150
|
+
const activity_state = meta?.activity_state || "";
|
|
151
|
+
const activity_since = meta?.activity_since || "";
|
|
152
|
+
return {
|
|
153
|
+
id,
|
|
154
|
+
nickname,
|
|
155
|
+
display,
|
|
156
|
+
launch_mode,
|
|
157
|
+
tmux_pane,
|
|
158
|
+
tty,
|
|
159
|
+
activity_state,
|
|
160
|
+
activity_since,
|
|
161
|
+
host_inject_sock: meta?.host_inject_sock || "",
|
|
162
|
+
host_daemon_sock: meta?.host_daemon_sock || "",
|
|
163
|
+
host_name: meta?.host_name || "",
|
|
164
|
+
host_session_id: meta?.host_session_id || "",
|
|
165
|
+
host_capabilities: meta?.host_capabilities || null,
|
|
166
|
+
};
|
|
151
167
|
});
|
|
152
168
|
|
|
153
169
|
return {
|