u-foo 1.6.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/package.json +1 -1
- package/src/agent/launcher.js +63 -0
- package/src/bus/activate.js +22 -2
- package/src/bus/daemon.js +1 -1
- package/src/bus/inject.js +29 -10
- package/src/bus/subscriber.js +32 -0
- package/src/chat/commandExecutor.js +15 -0
- package/src/chat/index.js +28 -2
- package/src/cli.js +14 -0
- package/src/config.js +1 -0
- package/src/daemon/index.js +51 -2
- package/src/daemon/ops.js +143 -2
- package/src/daemon/status.js +15 -1
- package/src/terminal/adapterRouter.js +13 -1
- package/src/terminal/adapters/hostAdapter.js +409 -0
package/package.json
CHANGED
package/src/agent/launcher.js
CHANGED
|
@@ -12,6 +12,7 @@ const { ActivityDetector } = require("./activityDetector");
|
|
|
12
12
|
const { createActivityStatePublisher } = require("./activityStatePublisher");
|
|
13
13
|
const { getUfooPaths } = require("../ufoo/paths");
|
|
14
14
|
const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
|
|
15
|
+
const { probeHostCapabilities } = require("../terminal/adapters/hostAdapter");
|
|
15
16
|
const PtyWrapper = require("./ptyWrapper");
|
|
16
17
|
const ReadyDetector = require("./readyDetector");
|
|
17
18
|
|
|
@@ -152,6 +153,14 @@ function findPreviousSession(cwd, agentType, tty, tmuxPane) {
|
|
|
152
153
|
function resolveLaunchMode() {
|
|
153
154
|
const explicit = process.env.UFOO_LAUNCH_MODE || "";
|
|
154
155
|
if (explicit) return explicit;
|
|
156
|
+
if (process.env.UFOO_HOST_SESSION_ID) return "host";
|
|
157
|
+
// Deprecated: HORIZON_SESSION_ID fallback (remove after migration)
|
|
158
|
+
if (process.env.HORIZON_SESSION_ID) {
|
|
159
|
+
if (process.env.UFOO_DEBUG) {
|
|
160
|
+
console.error("[launcher] HORIZON_SESSION_ID is deprecated, use UFOO_HOST_SESSION_ID");
|
|
161
|
+
}
|
|
162
|
+
return "host";
|
|
163
|
+
}
|
|
155
164
|
if (process.env.TMUX_PANE) return "tmux";
|
|
156
165
|
return "terminal";
|
|
157
166
|
}
|
|
@@ -162,6 +171,48 @@ function shouldShowLaunchBanner(agentType = "") {
|
|
|
162
171
|
return true;
|
|
163
172
|
}
|
|
164
173
|
|
|
174
|
+
async function resolveHostRegistrationData(launchMode) {
|
|
175
|
+
if (launchMode !== "host") {
|
|
176
|
+
return {
|
|
177
|
+
hostInjectSock: "",
|
|
178
|
+
hostDaemonSock: "",
|
|
179
|
+
hostName: "",
|
|
180
|
+
hostSessionId: "",
|
|
181
|
+
hostCapabilities: null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const hostInjectSock = process.env.UFOO_HOST_INJECT_SOCK
|
|
186
|
+
|| process.env.HORIZON_INJECT_SOCK
|
|
187
|
+
|| "";
|
|
188
|
+
const hostDaemonSock = process.env.UFOO_HOST_DAEMON_SOCK || "";
|
|
189
|
+
const hostName = process.env.UFOO_HOST_NAME || "";
|
|
190
|
+
const hostSessionId = process.env.UFOO_HOST_SESSION_ID
|
|
191
|
+
|| process.env.HORIZON_SESSION_ID
|
|
192
|
+
|| "";
|
|
193
|
+
|
|
194
|
+
let hostCapabilities = null;
|
|
195
|
+
if (hostInjectSock || hostDaemonSock) {
|
|
196
|
+
try {
|
|
197
|
+
hostCapabilities = await probeHostCapabilities({
|
|
198
|
+
injectSock: hostInjectSock,
|
|
199
|
+
daemonSock: hostDaemonSock,
|
|
200
|
+
timeoutMs: 1000,
|
|
201
|
+
});
|
|
202
|
+
} catch {
|
|
203
|
+
hostCapabilities = null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
hostInjectSock,
|
|
209
|
+
hostDaemonSock,
|
|
210
|
+
hostName,
|
|
211
|
+
hostSessionId,
|
|
212
|
+
hostCapabilities,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
165
216
|
/**
|
|
166
217
|
* Agent 启动器
|
|
167
218
|
* 统一处理 agent 启动流程:初始化、daemon 注册、banner、命令执行
|
|
@@ -314,6 +365,13 @@ class AgentLauncher {
|
|
|
314
365
|
const previousSession = shouldReuse
|
|
315
366
|
? findPreviousSession(this.cwd, this.agentType, tty, tmuxPane)
|
|
316
367
|
: null;
|
|
368
|
+
const {
|
|
369
|
+
hostInjectSock,
|
|
370
|
+
hostDaemonSock,
|
|
371
|
+
hostName,
|
|
372
|
+
hostSessionId,
|
|
373
|
+
hostCapabilities,
|
|
374
|
+
} = await resolveHostRegistrationData(launchMode);
|
|
317
375
|
|
|
318
376
|
const req = {
|
|
319
377
|
type: IPC_REQUEST_TYPES.REGISTER_AGENT,
|
|
@@ -323,6 +381,11 @@ class AgentLauncher {
|
|
|
323
381
|
launchMode,
|
|
324
382
|
tmuxPane,
|
|
325
383
|
tty,
|
|
384
|
+
hostInjectSock,
|
|
385
|
+
hostDaemonSock,
|
|
386
|
+
hostName,
|
|
387
|
+
hostSessionId,
|
|
388
|
+
hostCapabilities,
|
|
326
389
|
skipProbe: process.env.UFOO_SKIP_SESSION_PROBE === "1",
|
|
327
390
|
// 传递旧 session 信息用于复用(仅 terminal/tmux 模式)
|
|
328
391
|
reuseSession: previousSession ? {
|
package/src/bus/activate.js
CHANGED
|
@@ -40,6 +40,11 @@ class AgentActivator {
|
|
|
40
40
|
tty: meta.tty || "",
|
|
41
41
|
tmux_pane: meta.tmux_pane || "",
|
|
42
42
|
launch_mode: meta.launch_mode || "",
|
|
43
|
+
host_inject_sock: meta.host_inject_sock || "",
|
|
44
|
+
host_daemon_sock: meta.host_daemon_sock || "",
|
|
45
|
+
host_name: meta.host_name || "",
|
|
46
|
+
host_session_id: meta.host_session_id || "",
|
|
47
|
+
host_capabilities: meta.host_capabilities || null,
|
|
43
48
|
};
|
|
44
49
|
} catch (err) {
|
|
45
50
|
throw new Error(`Failed to get agent info: ${err.message}`);
|
|
@@ -156,7 +161,19 @@ end tell`;
|
|
|
156
161
|
activateTerminal,
|
|
157
162
|
activateTmux,
|
|
158
163
|
});
|
|
159
|
-
const adapter = adapterRouter.getAdapter({
|
|
164
|
+
const adapter = adapterRouter.getAdapter({
|
|
165
|
+
launchMode: info.launch_mode,
|
|
166
|
+
agentId,
|
|
167
|
+
meta: info,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (info.launch_mode === "host" && typeof adapter.connect === "function") {
|
|
171
|
+
try {
|
|
172
|
+
await adapter.connect();
|
|
173
|
+
} catch {
|
|
174
|
+
// fall back to seeded capabilities from bus metadata
|
|
175
|
+
}
|
|
176
|
+
}
|
|
160
177
|
|
|
161
178
|
if (!adapter.capabilities.supportsActivate) {
|
|
162
179
|
if (adapter.capabilities.supportsInternalQueueLoop) {
|
|
@@ -165,7 +182,10 @@ end tell`;
|
|
|
165
182
|
throw new Error("Cannot activate: missing tty or tmux_pane for agent");
|
|
166
183
|
}
|
|
167
184
|
|
|
168
|
-
await adapter.activate();
|
|
185
|
+
const activated = await adapter.activate();
|
|
186
|
+
if (activated === false) {
|
|
187
|
+
throw new Error("Host activation request was rejected");
|
|
188
|
+
}
|
|
169
189
|
}
|
|
170
190
|
}
|
|
171
191
|
|
package/src/bus/daemon.js
CHANGED
|
@@ -218,7 +218,7 @@ class BusDaemon {
|
|
|
218
218
|
// - notifier/injector: terminal/tmux
|
|
219
219
|
// - internal queue loop: internal/internal-pty
|
|
220
220
|
// Bus daemon only handles legacy/unknown launch modes.
|
|
221
|
-
const adapter = this.adapterRouter.getAdapter({ launchMode, agentId: subscriber });
|
|
221
|
+
const adapter = this.adapterRouter.getAdapter({ launchMode, agentId: subscriber, meta });
|
|
222
222
|
const { supportsNotifierInjector, supportsInternalQueueLoop } = adapter.capabilities;
|
|
223
223
|
if (launchMode && (supportsNotifierInjector || supportsInternalQueueLoop)) {
|
|
224
224
|
continue;
|
package/src/bus/inject.js
CHANGED
|
@@ -184,18 +184,11 @@ class Injector {
|
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
/**
|
|
187
|
-
*
|
|
187
|
+
* 使用指定路径的 PTY socket 注入命令
|
|
188
188
|
*/
|
|
189
|
-
async
|
|
190
|
-
const sockPath = this.getInjectSockPath(subscriber);
|
|
191
|
-
|
|
192
|
-
if (!fs.existsSync(sockPath)) {
|
|
193
|
-
throw new Error(`Inject socket not found: ${sockPath}`);
|
|
194
|
-
}
|
|
195
|
-
|
|
189
|
+
async injectPtyAtPath(sockPath, command) {
|
|
196
190
|
return new Promise((resolve, reject) => {
|
|
197
191
|
const client = net.createConnection(sockPath, () => {
|
|
198
|
-
// 发送inject请求
|
|
199
192
|
client.write(JSON.stringify({ type: "inject", command }) + "\n");
|
|
200
193
|
});
|
|
201
194
|
|
|
@@ -240,6 +233,19 @@ class Injector {
|
|
|
240
233
|
});
|
|
241
234
|
}
|
|
242
235
|
|
|
236
|
+
/**
|
|
237
|
+
* 使用 PTY socket 直接注入命令(无需macOS权限)
|
|
238
|
+
*/
|
|
239
|
+
async injectPty(subscriber, command) {
|
|
240
|
+
const sockPath = this.getInjectSockPath(subscriber);
|
|
241
|
+
|
|
242
|
+
if (!fs.existsSync(sockPath)) {
|
|
243
|
+
throw new Error(`Inject socket not found: ${sockPath}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return this.injectPtyAtPath(sockPath, command);
|
|
247
|
+
}
|
|
248
|
+
|
|
243
249
|
/**
|
|
244
250
|
* 注入命令到订阅者的终端
|
|
245
251
|
*
|
|
@@ -260,10 +266,23 @@ class Injector {
|
|
|
260
266
|
const meta = this.getAgentMeta(subscriber) || {};
|
|
261
267
|
const launchMode = meta.launch_mode || "";
|
|
262
268
|
const adapterRouter = createTerminalAdapterRouter();
|
|
263
|
-
const adapter = adapterRouter.getAdapter({ launchMode, agentId: subscriber });
|
|
269
|
+
const adapter = adapterRouter.getAdapter({ launchMode, agentId: subscriber, meta });
|
|
264
270
|
const supportsSocket = adapter.capabilities.supportsSocketProtocol;
|
|
265
271
|
const supportsNotifier = adapter.capabilities.supportsNotifierInjector;
|
|
266
272
|
|
|
273
|
+
// 0. Try Terminal Host inject socket (ufoo Terminal Host Protocol)
|
|
274
|
+
const hostSock = (meta.host_inject_sock || "").toString();
|
|
275
|
+
if (hostSock && fs.existsSync(hostSock)) {
|
|
276
|
+
try {
|
|
277
|
+
logInject(`[inject] Using host inject socket: ${hostSock}`);
|
|
278
|
+
await this.injectPtyAtPath(hostSock, command);
|
|
279
|
+
logInject("[inject] Host inject success");
|
|
280
|
+
return;
|
|
281
|
+
} catch (err) {
|
|
282
|
+
logInject(`[inject] Host inject failed: ${err.message}, trying PTY socket`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
267
286
|
// 1. 优先尝试 PTY socket(无需任何macOS权限)
|
|
268
287
|
const injectSockPath = this.getInjectSockPath(subscriber);
|
|
269
288
|
if (fs.existsSync(injectSockPath)) {
|
package/src/bus/subscriber.js
CHANGED
|
@@ -197,6 +197,22 @@ class SubscriberManager {
|
|
|
197
197
|
const preservedTmuxPane = typeof existingMeta?.tmux_pane === "string" ? existingMeta.tmux_pane.trim() : "";
|
|
198
198
|
const tmuxPane = explicitTmuxPane || envTmuxPane || preservedTmuxPane;
|
|
199
199
|
|
|
200
|
+
const hostInjectSock = typeof options.hostInjectSock === "string"
|
|
201
|
+
? options.hostInjectSock.trim()
|
|
202
|
+
: "";
|
|
203
|
+
const hostDaemonSock = typeof options.hostDaemonSock === "string"
|
|
204
|
+
? options.hostDaemonSock.trim()
|
|
205
|
+
: "";
|
|
206
|
+
const hostName = typeof options.hostName === "string"
|
|
207
|
+
? options.hostName.trim()
|
|
208
|
+
: "";
|
|
209
|
+
const hostSessionId = typeof options.hostSessionId === "string"
|
|
210
|
+
? options.hostSessionId.trim()
|
|
211
|
+
: "";
|
|
212
|
+
const hostCapabilities = options.hostCapabilities && typeof options.hostCapabilities === "object"
|
|
213
|
+
? { ...options.hostCapabilities }
|
|
214
|
+
: null;
|
|
215
|
+
|
|
200
216
|
this.busData.agents[subscriber] = {
|
|
201
217
|
...preserved,
|
|
202
218
|
agent_type: agentType,
|
|
@@ -213,6 +229,22 @@ class SubscriberManager {
|
|
|
213
229
|
launch_mode: launchMode,
|
|
214
230
|
};
|
|
215
231
|
|
|
232
|
+
if (hostInjectSock) {
|
|
233
|
+
this.busData.agents[subscriber].host_inject_sock = hostInjectSock;
|
|
234
|
+
}
|
|
235
|
+
if (hostDaemonSock) {
|
|
236
|
+
this.busData.agents[subscriber].host_daemon_sock = hostDaemonSock;
|
|
237
|
+
}
|
|
238
|
+
if (hostName) {
|
|
239
|
+
this.busData.agents[subscriber].host_name = hostName;
|
|
240
|
+
}
|
|
241
|
+
if (hostSessionId) {
|
|
242
|
+
this.busData.agents[subscriber].host_session_id = hostSessionId;
|
|
243
|
+
}
|
|
244
|
+
if (hostCapabilities) {
|
|
245
|
+
this.busData.agents[subscriber].host_capabilities = hostCapabilities;
|
|
246
|
+
}
|
|
247
|
+
|
|
216
248
|
const terminalApp = options.terminalApp || detectTerminalAppFromEnv();
|
|
217
249
|
if (terminalApp) {
|
|
218
250
|
this.busData.agents[subscriber].terminal_app = terminalApp;
|
|
@@ -29,6 +29,19 @@ function defaultResolveTerminalApp() {
|
|
|
29
29
|
return "";
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function collectHostLaunchRequestContext(env = process.env) {
|
|
33
|
+
const hostInjectSock = String(env.UFOO_HOST_INJECT_SOCK || env.HORIZON_INJECT_SOCK || "").trim();
|
|
34
|
+
const hostDaemonSock = String(env.UFOO_HOST_DAEMON_SOCK || "").trim();
|
|
35
|
+
const hostName = String(env.UFOO_HOST_NAME || "").trim();
|
|
36
|
+
const hostSessionId = String(env.UFOO_HOST_SESSION_ID || env.HORIZON_SESSION_ID || "").trim();
|
|
37
|
+
const context = {};
|
|
38
|
+
if (hostInjectSock) context.host_inject_sock = hostInjectSock;
|
|
39
|
+
if (hostDaemonSock) context.host_daemon_sock = hostDaemonSock;
|
|
40
|
+
if (hostName) context.host_name = hostName;
|
|
41
|
+
if (hostSessionId) context.host_session_id = hostSessionId;
|
|
42
|
+
return context;
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
async function withCapturedConsole(capture, fn) {
|
|
33
46
|
const originalLog = console.log;
|
|
34
47
|
const originalError = console.error;
|
|
@@ -445,6 +458,7 @@ function createCommandExecutor(options = {}) {
|
|
|
445
458
|
count: Number.isFinite(count) ? count : 1,
|
|
446
459
|
nickname,
|
|
447
460
|
launch_scope: launchScope,
|
|
461
|
+
...collectHostLaunchRequestContext(),
|
|
448
462
|
};
|
|
449
463
|
const terminalApp = String(resolveTerminalApp() || "").trim().toLowerCase();
|
|
450
464
|
if (terminalApp === "terminal" || terminalApp === "iterm2") {
|
|
@@ -1126,4 +1140,5 @@ function createCommandExecutor(options = {}) {
|
|
|
1126
1140
|
|
|
1127
1141
|
module.exports = {
|
|
1128
1142
|
createCommandExecutor,
|
|
1143
|
+
collectHostLaunchRequestContext,
|
|
1129
1144
|
};
|
package/src/chat/index.js
CHANGED
|
@@ -44,7 +44,7 @@ const { createDaemonCoordinator } = require("./daemonCoordinator");
|
|
|
44
44
|
const { IPC_REQUEST_TYPES } = require("../shared/eventContract");
|
|
45
45
|
const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
|
|
46
46
|
const { createDaemonTransport } = require("./daemonTransport");
|
|
47
|
-
const { listProjectRuntimes } = require("../projects/registry");
|
|
47
|
+
const { listProjectRuntimes, resolveRuntimeDir } = require("../projects/registry");
|
|
48
48
|
const { canonicalProjectRoot, buildProjectId } = require("../projects/projectId");
|
|
49
49
|
const {
|
|
50
50
|
sortProjectRuntimes,
|
|
@@ -725,7 +725,7 @@ async function runChat(projectRoot, options = {}) {
|
|
|
725
725
|
if (!terminalAdapterRouter) return null;
|
|
726
726
|
const meta = activeAgentMetaMap ? activeAgentMetaMap.get(agentId) : null;
|
|
727
727
|
const agentLaunchMode = (meta && meta.launch_mode) || launchMode || "";
|
|
728
|
-
return terminalAdapterRouter.getAdapter({ launchMode: agentLaunchMode, agentId });
|
|
728
|
+
return terminalAdapterRouter.getAdapter({ launchMode: agentLaunchMode, agentId, meta });
|
|
729
729
|
}
|
|
730
730
|
|
|
731
731
|
function getViewingAgentAdapter() {
|
|
@@ -1999,6 +1999,32 @@ async function runChat(projectRoot, options = {}) {
|
|
|
1999
1999
|
requestStatus();
|
|
2000
2000
|
}
|
|
2001
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
|
+
}
|
|
2002
2028
|
screen.on("resize", () => {
|
|
2003
2029
|
if (handleResizeInAgentView()) {
|
|
2004
2030
|
return;
|
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"
|
|
@@ -980,7 +990,18 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
980
990
|
}
|
|
981
991
|
if (req.type === IPC_REQUEST_TYPES.LAUNCH_AGENT) {
|
|
982
992
|
log(`launch_agent received: agent=${req.agent} count=${req.count}`);
|
|
983
|
-
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;
|
|
984
1005
|
const normalizedAgent = normalizeLaunchAgent(agent);
|
|
985
1006
|
if (!normalizedAgent) {
|
|
986
1007
|
socket.write(
|
|
@@ -1001,6 +1022,14 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
1001
1022
|
nickname: nickname || "",
|
|
1002
1023
|
launch_scope: launch_scope || "",
|
|
1003
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,
|
|
1004
1033
|
};
|
|
1005
1034
|
try {
|
|
1006
1035
|
const opsResults = await handleOps(projectRoot, [op], processManager);
|
|
@@ -1403,7 +1432,20 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
1403
1432
|
}
|
|
1404
1433
|
if (req.type === IPC_REQUEST_TYPES.REGISTER_AGENT) {
|
|
1405
1434
|
// Manual agent launch requests daemon to register it
|
|
1406
|
-
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;
|
|
1407
1449
|
if (!agentType) {
|
|
1408
1450
|
socket.write(
|
|
1409
1451
|
`${JSON.stringify({
|
|
@@ -1451,6 +1493,13 @@ function startDaemon({ projectRoot, provider, model, resumeMode = "auto" }) {
|
|
|
1451
1493
|
launchMode: launchMode || "",
|
|
1452
1494
|
tmuxPane: tmuxPane || "",
|
|
1453
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,
|
|
1454
1503
|
reuseSessionId,
|
|
1455
1504
|
reuseProviderSessionId,
|
|
1456
1505
|
};
|
package/src/daemon/ops.js
CHANGED
|
@@ -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");
|
|
@@ -834,12 +973,14 @@ async function closeAgent(projectRoot, agentId) {
|
|
|
834
973
|
let tty = "";
|
|
835
974
|
let terminalApp = "";
|
|
836
975
|
let tmuxPane = "";
|
|
976
|
+
let meta = null;
|
|
837
977
|
let found = false;
|
|
838
978
|
try {
|
|
839
979
|
const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
|
|
840
980
|
const entry = bus.agents?.[resolvedId];
|
|
841
981
|
if (entry) {
|
|
842
982
|
found = true;
|
|
983
|
+
meta = entry;
|
|
843
984
|
const parsedPid = Number.parseInt(entry.pid, 10);
|
|
844
985
|
pid = Number.isFinite(parsedPid) && parsedPid > 0 ? parsedPid : 0;
|
|
845
986
|
launchMode = entry.launch_mode || "";
|
|
@@ -856,7 +997,7 @@ async function closeAgent(projectRoot, agentId) {
|
|
|
856
997
|
}
|
|
857
998
|
|
|
858
999
|
const adapterRouter = createTerminalAdapterRouter();
|
|
859
|
-
const adapter = adapterRouter.getAdapter({ launchMode, agentId: resolvedId });
|
|
1000
|
+
const adapter = adapterRouter.getAdapter({ launchMode, agentId: resolvedId, meta });
|
|
860
1001
|
const canCloseWindow = process.platform === "darwin"
|
|
861
1002
|
&& Boolean(adapter.capabilities.supportsWindowClose)
|
|
862
1003
|
&& Boolean(tty);
|
package/src/daemon/status.js
CHANGED
|
@@ -149,7 +149,21 @@ function buildStatus(projectRoot, options = {}) {
|
|
|
149
149
|
const tty = meta?.tty || "";
|
|
150
150
|
const activity_state = meta?.activity_state || "";
|
|
151
151
|
const activity_since = meta?.activity_since || "";
|
|
152
|
-
return {
|
|
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
|
+
};
|
|
153
167
|
});
|
|
154
168
|
|
|
155
169
|
return {
|
|
@@ -6,6 +6,7 @@ const { createTerminalAdapter } = require("./adapters/terminalAdapter");
|
|
|
6
6
|
const { createTmuxAdapter } = require("./adapters/tmuxAdapter");
|
|
7
7
|
const { createInternalQueueAdapter } = require("./adapters/internalQueueAdapter");
|
|
8
8
|
const { createInternalPtyAdapter } = require("./adapters/internalPtyAdapter");
|
|
9
|
+
const { createHostAdapter } = require("./adapters/hostAdapter");
|
|
9
10
|
|
|
10
11
|
function createTerminalAdapterRouter(options = {}) {
|
|
11
12
|
const {
|
|
@@ -35,7 +36,7 @@ function createTerminalAdapterRouter(options = {}) {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
function getAdapter(params = {}) {
|
|
38
|
-
const { launchMode = "", agentId = "" } = params;
|
|
39
|
+
const { launchMode = "", agentId = "", meta = null } = params;
|
|
39
40
|
|
|
40
41
|
if (launchMode === "terminal") {
|
|
41
42
|
return createTerminalAdapter({
|
|
@@ -53,6 +54,17 @@ function createTerminalAdapterRouter(options = {}) {
|
|
|
53
54
|
});
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
if (launchMode === "host") {
|
|
58
|
+
return createHostAdapter({
|
|
59
|
+
createAdapter,
|
|
60
|
+
injectSock: meta?.host_inject_sock || meta?.hostInjectSock || "",
|
|
61
|
+
daemonSock: meta?.host_daemon_sock || meta?.hostDaemonSock || "",
|
|
62
|
+
hostName: meta?.host_name || meta?.hostName || "",
|
|
63
|
+
sessionId: meta?.host_session_id || meta?.hostSessionId || "",
|
|
64
|
+
hostCapabilities: meta?.host_capabilities || meta?.hostCapabilities || null,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
56
68
|
if (launchMode === "internal-pty") {
|
|
57
69
|
return createInternalPtyAdapter({
|
|
58
70
|
sendRaw,
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
const { createTerminalCapabilities } = require("../adapterContract");
|
|
2
|
+
const net = require("net");
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Mapping from host protocol command names to ufoo capability flags.
|
|
6
|
+
* When a host reports supporting a command, the corresponding capability is enabled.
|
|
7
|
+
*/
|
|
8
|
+
const COMMAND_TO_CAPABILITY = {
|
|
9
|
+
activate: "supportsActivate",
|
|
10
|
+
snapshot: "supportsSnapshot",
|
|
11
|
+
subscribe: "supportsSubscribeFull",
|
|
12
|
+
subscribe_screen: "supportsSubscribeScreen",
|
|
13
|
+
close_session: "supportsWindowClose",
|
|
14
|
+
notify: "supportsNotifierInjector",
|
|
15
|
+
replay: "supportsReplay",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Send a JSON request to a Unix socket and return the parsed response.
|
|
20
|
+
* Expects the unified envelope: {v, request_id, ok, result|error}
|
|
21
|
+
*/
|
|
22
|
+
function sendToSocket(sockPath, request, options = {}) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
if (!sockPath) {
|
|
25
|
+
reject(new Error("socket path not set"));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
|
30
|
+
? options.timeoutMs
|
|
31
|
+
: 5000;
|
|
32
|
+
|
|
33
|
+
const client = net.createConnection(sockPath, () => {
|
|
34
|
+
client.write(JSON.stringify(request) + "\n");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
let buffer = "";
|
|
38
|
+
const timeout = setTimeout(() => {
|
|
39
|
+
client.destroy();
|
|
40
|
+
reject(new Error("host socket timeout"));
|
|
41
|
+
}, timeoutMs);
|
|
42
|
+
|
|
43
|
+
client.on("data", (data) => {
|
|
44
|
+
buffer += data.toString("utf8");
|
|
45
|
+
const lines = buffer.split("\n");
|
|
46
|
+
buffer = lines.pop() || "";
|
|
47
|
+
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
if (!line.trim()) continue;
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
try {
|
|
52
|
+
const res = JSON.parse(line);
|
|
53
|
+
client.end();
|
|
54
|
+
if (res.ok) {
|
|
55
|
+
resolve(res.result || {});
|
|
56
|
+
} else {
|
|
57
|
+
const err = new Error(res.error || "host request failed");
|
|
58
|
+
err.errorCode = res.error_code || "";
|
|
59
|
+
reject(err);
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
client.end();
|
|
63
|
+
reject(err);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
client.on("error", (err) => {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
reject(err);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
client.on("close", () => {
|
|
75
|
+
clearTimeout(timeout);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getInjectSock() {
|
|
81
|
+
return process.env.UFOO_HOST_INJECT_SOCK
|
|
82
|
+
|| process.env.HORIZON_INJECT_SOCK // deprecated fallback
|
|
83
|
+
|| "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getDaemonSock() {
|
|
87
|
+
return process.env.UFOO_HOST_DAEMON_SOCK || "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeHostValue(value) {
|
|
91
|
+
return typeof value === "string" ? value.trim() : "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeHostCapabilities(hostCapabilities) {
|
|
95
|
+
if (!hostCapabilities || typeof hostCapabilities !== "object") {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const normalized = { ...hostCapabilities };
|
|
99
|
+
const commands = Array.isArray(normalized.commands) ? normalized.commands : [];
|
|
100
|
+
const sessionCommands = Array.isArray(normalized.session_commands)
|
|
101
|
+
? normalized.session_commands
|
|
102
|
+
: [];
|
|
103
|
+
if (commands.length === 0 && sessionCommands.length > 0) {
|
|
104
|
+
normalized.commands = [...sessionCommands];
|
|
105
|
+
} else {
|
|
106
|
+
normalized.commands = [...commands];
|
|
107
|
+
}
|
|
108
|
+
return normalized;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function clearDynamicCapabilities(capabilities) {
|
|
112
|
+
for (const flag of Object.values(COMMAND_TO_CAPABILITY)) {
|
|
113
|
+
if (flag in capabilities) {
|
|
114
|
+
capabilities[flag] = false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Map host-reported commands array to ufoo capability flags.
|
|
121
|
+
* Mutates the capabilities object in-place.
|
|
122
|
+
*/
|
|
123
|
+
function applyHostCapabilities(capabilities, hostCommands) {
|
|
124
|
+
if (!Array.isArray(hostCommands)) return;
|
|
125
|
+
for (const cmd of hostCommands) {
|
|
126
|
+
const flag = COMMAND_TO_CAPABILITY[cmd];
|
|
127
|
+
if (flag && flag in capabilities) {
|
|
128
|
+
capabilities[flag] = true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function probeHostCapabilities(options = {}) {
|
|
134
|
+
const injectSock = normalizeHostValue(options.injectSock) || getInjectSock();
|
|
135
|
+
const daemonSock = normalizeHostValue(options.daemonSock) || getDaemonSock();
|
|
136
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
|
137
|
+
? options.timeoutMs
|
|
138
|
+
: 5000;
|
|
139
|
+
|
|
140
|
+
for (const sock of [injectSock, daemonSock]) {
|
|
141
|
+
if (!sock) continue;
|
|
142
|
+
try {
|
|
143
|
+
// eslint-disable-next-line no-await-in-loop
|
|
144
|
+
const result = await sendToSocket(sock, { type: "capabilities" }, { timeoutMs });
|
|
145
|
+
const normalized = normalizeHostCapabilities(result);
|
|
146
|
+
if (normalized) {
|
|
147
|
+
return normalized;
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// try next socket
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* ufoo Terminal Host adapter — connects to any terminal host's per-session
|
|
159
|
+
* inject socket via the ufoo Terminal Host Protocol.
|
|
160
|
+
*
|
|
161
|
+
* Detects UFOO_HOST_SESSION_ID and UFOO_HOST_INJECT_SOCK env vars.
|
|
162
|
+
* Works with any host that implements the protocol (Horizon, Warp, etc.).
|
|
163
|
+
*
|
|
164
|
+
* After connect(), capabilities are dynamically updated based on what
|
|
165
|
+
* the host reports in its capabilities handshake response.
|
|
166
|
+
*/
|
|
167
|
+
function createHostAdapter(options = {}) {
|
|
168
|
+
const {
|
|
169
|
+
createAdapter = () => {},
|
|
170
|
+
injectSock = "",
|
|
171
|
+
daemonSock = "",
|
|
172
|
+
hostName = "",
|
|
173
|
+
sessionId = "",
|
|
174
|
+
hostCapabilities: initialHostCapabilities = null,
|
|
175
|
+
} = options;
|
|
176
|
+
|
|
177
|
+
// Start with base capabilities; additional flags are set after connect()
|
|
178
|
+
const capabilities = createTerminalCapabilities({
|
|
179
|
+
supportsSocketProtocol: true,
|
|
180
|
+
supportsSessionReuse: true,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const explicitInjectSock = normalizeHostValue(injectSock);
|
|
184
|
+
const explicitDaemonSock = normalizeHostValue(daemonSock);
|
|
185
|
+
const explicitHostName = normalizeHostValue(hostName);
|
|
186
|
+
const explicitSessionId = normalizeHostValue(sessionId);
|
|
187
|
+
const seededHostCapabilities = normalizeHostCapabilities(initialHostCapabilities);
|
|
188
|
+
|
|
189
|
+
// Cached host capabilities from handshake
|
|
190
|
+
let hostCapabilities = seededHostCapabilities ? { ...seededHostCapabilities } : null;
|
|
191
|
+
|
|
192
|
+
function resolveInjectSock() {
|
|
193
|
+
return explicitInjectSock || getInjectSock();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function resolveDaemonSock() {
|
|
197
|
+
return explicitDaemonSock || getDaemonSock();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resetCapabilitiesToSeed() {
|
|
201
|
+
clearDynamicCapabilities(capabilities);
|
|
202
|
+
if (seededHostCapabilities) {
|
|
203
|
+
applyHostCapabilities(capabilities, seededHostCapabilities.commands);
|
|
204
|
+
hostCapabilities = { ...seededHostCapabilities };
|
|
205
|
+
} else {
|
|
206
|
+
hostCapabilities = null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
resetCapabilitiesToSeed();
|
|
211
|
+
|
|
212
|
+
return createAdapter({
|
|
213
|
+
capabilities,
|
|
214
|
+
handlers: {
|
|
215
|
+
connect: async () => {
|
|
216
|
+
clearDynamicCapabilities(capabilities);
|
|
217
|
+
const result = await probeHostCapabilities({
|
|
218
|
+
injectSock: resolveInjectSock(),
|
|
219
|
+
daemonSock: resolveDaemonSock(),
|
|
220
|
+
});
|
|
221
|
+
if (result) {
|
|
222
|
+
hostCapabilities = result;
|
|
223
|
+
applyHostCapabilities(capabilities, result.commands);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
resetCapabilitiesToSeed();
|
|
227
|
+
return false;
|
|
228
|
+
},
|
|
229
|
+
disconnect: async () => {
|
|
230
|
+
clearDynamicCapabilities(capabilities);
|
|
231
|
+
hostCapabilities = null;
|
|
232
|
+
return true;
|
|
233
|
+
},
|
|
234
|
+
send: (data) => {
|
|
235
|
+
const sock = resolveInjectSock();
|
|
236
|
+
if (!sock) return false;
|
|
237
|
+
sendToSocket(sock, { type: "inject", command: String(data) }).catch(() => {});
|
|
238
|
+
return true;
|
|
239
|
+
},
|
|
240
|
+
sendRaw: (data) => {
|
|
241
|
+
const sock = resolveInjectSock();
|
|
242
|
+
if (!sock) return false;
|
|
243
|
+
sendToSocket(sock, { type: "raw", data: String(data) }).catch(() => {});
|
|
244
|
+
return true;
|
|
245
|
+
},
|
|
246
|
+
resize: (cols, rows) => {
|
|
247
|
+
const sock = resolveInjectSock();
|
|
248
|
+
if (!sock) return false;
|
|
249
|
+
sendToSocket(sock, { type: "resize", cols, rows }).catch(() => {});
|
|
250
|
+
return true;
|
|
251
|
+
},
|
|
252
|
+
snapshot: () => {
|
|
253
|
+
if (!capabilities.supportsSnapshot) return false;
|
|
254
|
+
const sock = resolveInjectSock();
|
|
255
|
+
if (!sock) return false;
|
|
256
|
+
sendToSocket(sock, { type: "snapshot" }).catch(() => {});
|
|
257
|
+
return true;
|
|
258
|
+
},
|
|
259
|
+
subscribe: () => {
|
|
260
|
+
if (!capabilities.supportsSubscribeFull) return false;
|
|
261
|
+
const sock = resolveInjectSock();
|
|
262
|
+
if (!sock) return false;
|
|
263
|
+
sendToSocket(sock, { type: "subscribe" }).catch(() => {});
|
|
264
|
+
return true;
|
|
265
|
+
},
|
|
266
|
+
activate: async () => {
|
|
267
|
+
if (!capabilities.supportsActivate) return false;
|
|
268
|
+
const sock = resolveInjectSock();
|
|
269
|
+
if (!sock) return false;
|
|
270
|
+
try {
|
|
271
|
+
await sendToSocket(sock, { type: "activate" });
|
|
272
|
+
return true;
|
|
273
|
+
} catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
getState: () => ({
|
|
278
|
+
hostName: explicitHostName || process.env.UFOO_HOST_NAME || "",
|
|
279
|
+
sessionId: explicitSessionId || process.env.UFOO_HOST_SESSION_ID || process.env.HORIZON_SESSION_ID || "",
|
|
280
|
+
injectSock: resolveInjectSock(),
|
|
281
|
+
daemonSock: resolveDaemonSock(),
|
|
282
|
+
hostCapabilities,
|
|
283
|
+
}),
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- Host command helpers (for callers that need async results) ---
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Request a terminal snapshot from the host.
|
|
292
|
+
* @param {string} [sockPath] - Override socket path
|
|
293
|
+
* @returns {Promise<{lines: string[], cols: number, rows: number, cursor?: {x: number, y: number}}>}
|
|
294
|
+
*/
|
|
295
|
+
async function requestSnapshot(sockPath) {
|
|
296
|
+
const sock = normalizeHostValue(sockPath) || getInjectSock();
|
|
297
|
+
return sendToSocket(sock, { type: "snapshot" });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Activate (focus) the host terminal window/tab.
|
|
302
|
+
* @param {string} [sockPath] - Override socket path
|
|
303
|
+
* @returns {Promise<{}>}
|
|
304
|
+
*/
|
|
305
|
+
async function requestActivate(sockPath) {
|
|
306
|
+
const sock = normalizeHostValue(sockPath) || getInjectSock();
|
|
307
|
+
return sendToSocket(sock, { type: "activate" });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Send a notification via the host.
|
|
312
|
+
* @param {string} message - Notification message
|
|
313
|
+
* @param {object} [opts] - Options: { title, urgency }
|
|
314
|
+
* @param {string} [sockPath] - Override socket path
|
|
315
|
+
* @returns {Promise<{}>}
|
|
316
|
+
*/
|
|
317
|
+
async function requestNotify(message, opts = {}, sockPath) {
|
|
318
|
+
const sock = normalizeHostValue(sockPath) || getInjectSock();
|
|
319
|
+
return sendToSocket(sock, { type: "notify", message, ...opts });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Close a terminal session via the inject socket.
|
|
324
|
+
* @param {string} [sockPath] - Override socket path
|
|
325
|
+
* @returns {Promise<{}>}
|
|
326
|
+
*/
|
|
327
|
+
async function requestCloseSession(sockPath) {
|
|
328
|
+
const sock = normalizeHostValue(sockPath) || getInjectSock();
|
|
329
|
+
return sendToSocket(sock, { type: "close_session" });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// --- Daemon lifecycle management (per-host, not per-session) ---
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Create a new terminal session via the daemon management socket.
|
|
336
|
+
* @param {string} [daemonSock] - Override daemon socket path (defaults to env)
|
|
337
|
+
* @param {object} [opts] - Options: { group_id, source_session_id }
|
|
338
|
+
* @returns {Promise<{session_id: string, inject_sock: string}>}
|
|
339
|
+
*/
|
|
340
|
+
async function createSession(daemonSock, opts = {}) {
|
|
341
|
+
const sock = normalizeHostValue(daemonSock) || getDaemonSock();
|
|
342
|
+
const req = { type: "create_session" };
|
|
343
|
+
if (opts.group_id) req.group_id = opts.group_id;
|
|
344
|
+
if (opts.source_session_id) req.source_session_id = opts.source_session_id;
|
|
345
|
+
return sendToSocket(sock, req);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* List all terminal sessions via the daemon management socket.
|
|
350
|
+
* @param {string} [daemonSock] - Override daemon socket path
|
|
351
|
+
* @returns {Promise<{sessions: Array<{session_id: string, inject_sock: string}>}>}
|
|
352
|
+
*/
|
|
353
|
+
async function listSessions(daemonSock) {
|
|
354
|
+
const sock = normalizeHostValue(daemonSock) || getDaemonSock();
|
|
355
|
+
return sendToSocket(sock, { type: "list_sessions" });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Close a terminal session via the daemon management socket.
|
|
360
|
+
* @param {string} sessionId - Session to close
|
|
361
|
+
* @param {string} [daemonSock] - Override daemon socket path
|
|
362
|
+
* @returns {Promise<{}>}
|
|
363
|
+
*/
|
|
364
|
+
async function closeSession(sessionId, daemonSock) {
|
|
365
|
+
const sock = normalizeHostValue(daemonSock) || getDaemonSock();
|
|
366
|
+
return sendToSocket(sock, { type: "close_session", session_id: sessionId });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Query host capabilities via the daemon management socket.
|
|
371
|
+
* @param {string} [daemonSock] - Override daemon socket path
|
|
372
|
+
* @returns {Promise<object>}
|
|
373
|
+
*/
|
|
374
|
+
async function queryCapabilities(daemonSock) {
|
|
375
|
+
const sock = normalizeHostValue(daemonSock) || getDaemonSock();
|
|
376
|
+
return sendToSocket(sock, { type: "capabilities" });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Ping the daemon management socket.
|
|
381
|
+
* @param {string} [daemonSock] - Override daemon socket path
|
|
382
|
+
* @returns {Promise<{pong: true}>}
|
|
383
|
+
*/
|
|
384
|
+
async function pingDaemon(daemonSock) {
|
|
385
|
+
const sock = normalizeHostValue(daemonSock) || getDaemonSock();
|
|
386
|
+
return sendToSocket(sock, { type: "ping" });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
module.exports = {
|
|
390
|
+
createHostAdapter,
|
|
391
|
+
// Host command helpers (async, for direct use)
|
|
392
|
+
requestSnapshot,
|
|
393
|
+
requestActivate,
|
|
394
|
+
requestNotify,
|
|
395
|
+
requestCloseSession,
|
|
396
|
+
// Daemon lifecycle API
|
|
397
|
+
createSession,
|
|
398
|
+
listSessions,
|
|
399
|
+
closeSession,
|
|
400
|
+
queryCapabilities,
|
|
401
|
+
pingDaemon,
|
|
402
|
+
// For testing
|
|
403
|
+
sendToSocket,
|
|
404
|
+
COMMAND_TO_CAPABILITY,
|
|
405
|
+
applyHostCapabilities,
|
|
406
|
+
clearDynamicCapabilities,
|
|
407
|
+
normalizeHostCapabilities,
|
|
408
|
+
probeHostCapabilities,
|
|
409
|
+
};
|