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.
Files changed (42) hide show
  1. package/README.md +21 -0
  2. package/README.zh-CN.md +21 -0
  3. package/modules/AGENTS.template.md +4 -102
  4. package/package.json +1 -1
  5. package/src/agent/activityDetector.js +328 -0
  6. package/src/agent/activityStatePublisher.js +67 -0
  7. package/src/agent/activityStateWriter.js +40 -0
  8. package/src/agent/internalRunner.js +13 -0
  9. package/src/agent/launcher.js +110 -7
  10. package/src/agent/notifier.js +73 -4
  11. package/src/agent/ptyRunner.js +81 -34
  12. package/src/agent/ufooAgent.js +192 -6
  13. package/src/bus/activate.js +22 -2
  14. package/src/bus/daemon.js +1 -1
  15. package/src/bus/inject.js +29 -10
  16. package/src/bus/message.js +1 -9
  17. package/src/bus/subscriber.js +34 -0
  18. package/src/bus/utils.js +10 -0
  19. package/src/chat/agentBar.js +21 -3
  20. package/src/chat/agentViewController.js +2 -0
  21. package/src/chat/commandExecutor.js +15 -0
  22. package/src/chat/daemonConnection.js +45 -7
  23. package/src/chat/daemonMessageRouter.js +22 -0
  24. package/src/chat/daemonTransport.js +13 -2
  25. package/src/chat/daemonTransportDefaults.js +1 -0
  26. package/src/chat/dashboardKeyController.js +9 -0
  27. package/src/chat/dashboardView.js +32 -9
  28. package/src/chat/index.js +176 -8
  29. package/src/chat/projectCloseController.js +119 -0
  30. package/src/chat/projectRuntimes.js +55 -0
  31. package/src/chat/statusLineController.js +52 -6
  32. package/src/chat/transport.js +41 -5
  33. package/src/cli.js +14 -0
  34. package/src/config.js +1 -0
  35. package/src/daemon/index.js +63 -5
  36. package/src/daemon/ipcServer.js +6 -1
  37. package/src/daemon/ops.js +189 -14
  38. package/src/daemon/status.js +17 -1
  39. package/src/init/index.js +32 -3
  40. package/src/terminal/adapterRouter.js +13 -1
  41. package/src/terminal/adapters/hostAdapter.js +409 -0
  42. 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 queueStatusLine(text) {
111
- pendingStatusLines.push(text || "");
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(pendingStatusLines[0], { pending: true });
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
- pendingStatusLines.shift();
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
- setPrimaryStatus(pendingStatusLines[0], { pending: true });
167
+ if (removedHead || !primaryStatusPending) {
168
+ setPrimaryStatus(headPendingText(), { pending: true });
169
+ }
124
170
  } else {
125
171
  setPrimaryStatus(text || "", { pending: false });
126
172
  }
@@ -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
- const client = net.createConnection(sockPath, () => resolve(client));
9
- client.on("error", reject);
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
@@ -27,6 +27,7 @@ function normalizeLaunchMode(value) {
27
27
  if (value === "internal") return "internal";
28
28
  if (value === "tmux") return "tmux";
29
29
  if (value === "terminal") return "terminal";
30
+ if (value === "host") return "host";
30
31
  return "auto";
31
32
  }
32
33
 
@@ -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 ok = await closeAgent(projectRoot, op.agent_id);
382
- results.push({ action: "close", ok, agent_id: op.agent_id });
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
- ? `Closed ${agent_id}`
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 { agent, count, nickname, launch_scope, terminal_app } = req;
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 { agentType, nickname, parentPid, launchMode, tmuxPane, tty, skipProbe } = req;
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
  };
@@ -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
- if (currentActiveJson !== lastActiveJson) {
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 || "terminal";
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 = null;
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
- if (entry.pid) pid = entry.pid;
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
- pid = null;
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 = adapter.capabilities.supportsWindowClose && tty;
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
- if (pid) {
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
- if (sentSignal || (!pid && canCloseWindow)) {
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
- return sentSignal || (!pid && canCloseWindow);
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 };
@@ -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
- return { id, nickname, display, launch_mode, tmux_pane, tty };
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 {