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
@@ -10,6 +10,7 @@ const {
10
10
  resolveAnthropicMessagesUrl,
11
11
  } = require("../code/nativeRunner");
12
12
  const { DEFAULT_ASSISTANT_TIMEOUT_MS } = require("../assistant/constants");
13
+ const { normalizeAgentTypeAlias } = require("../bus/utils");
13
14
 
14
15
  function loadSessionState(projectRoot) {
15
16
  const dir = getUfooPaths(projectRoot).agentDir;
@@ -28,13 +29,166 @@ function saveSessionState(projectRoot, state) {
28
29
  fs.writeFileSync(path.join(dir, "ufoo-agent.json"), JSON.stringify(state, null, 2));
29
30
  }
30
31
 
32
+ function toReportAgentSnapshot(value = {}) {
33
+ const last = value && typeof value.last === "object" ? value.last : null;
34
+ return {
35
+ agent_id: String(value && value.agent_id ? value.agent_id : ""),
36
+ pending_count: Number(value && value.pending_count ? value.pending_count : 0) || 0,
37
+ updated_at: String(value && value.updated_at ? value.updated_at : ""),
38
+ last: last
39
+ ? {
40
+ phase: String(last.phase || ""),
41
+ task_id: String(last.task_id || ""),
42
+ ok: last.ok !== false,
43
+ }
44
+ : null,
45
+ };
46
+ }
47
+
48
+ function isBusyActivityState(value = "") {
49
+ const state = String(value || "").trim().toLowerCase();
50
+ return state === "working" || state === "starting" || state === "running"
51
+ || state === "waiting_input" || state === "blocked";
52
+ }
53
+
54
+ function clipPromptText(value = "", maxChars = 240) {
55
+ const text = String(value || "").replace(/\s+/g, " ").trim();
56
+ if (!text) return "";
57
+ if (text.length <= maxChars) return text;
58
+ return `${text.slice(0, maxChars)}...[truncated]`;
59
+ }
60
+
61
+ function resolveHistoryAgentId(rawTarget, activeIdSet, nicknames) {
62
+ const target = String(rawTarget || "").trim();
63
+ if (!target) return "";
64
+ if (target === "*" || target === "broadcast") return "";
65
+ if (activeIdSet.has(target)) return target;
66
+ if (nicknames[target]) return nicknames[target];
67
+
68
+ const targetAlias = normalizeAgentTypeAlias(target);
69
+ if (!targetAlias) return "";
70
+
71
+ const matches = [];
72
+ for (const id of activeIdSet) {
73
+ const prefix = String(id).split(":")[0] || "";
74
+ const alias = normalizeAgentTypeAlias(prefix);
75
+ if (alias === targetAlias) matches.push(id);
76
+ }
77
+ return matches.length === 1 ? matches[0] : "";
78
+ }
79
+
80
+ function buildAgentPromptHistory(projectRoot, agents = [], nicknames = {}, options = {}) {
81
+ const perAgentLimit = Number.isFinite(options.perAgentLimit) && options.perAgentLimit > 0
82
+ ? Math.floor(options.perAgentLimit)
83
+ : 6;
84
+ const maxFiles = Number.isFinite(options.maxFiles) && options.maxFiles > 0
85
+ ? Math.floor(options.maxFiles)
86
+ : 3;
87
+ const eventsDir = getUfooPaths(projectRoot).busEventsDir;
88
+ const activeIds = new Set((Array.isArray(agents) ? agents : []).map((item) => String(item.id || "")).filter(Boolean));
89
+ if (activeIds.size === 0) {
90
+ return { per_agent: [], scanned_files: 0, matched_events: 0 };
91
+ }
92
+
93
+ const entries = new Map();
94
+ for (const item of agents) {
95
+ if (!item || !item.id) continue;
96
+ entries.set(item.id, {
97
+ agent_id: String(item.id),
98
+ nickname: String(item.nickname || ""),
99
+ samples: [],
100
+ sample_count: 0,
101
+ total_count: 0,
102
+ first_ts: "",
103
+ last_ts: "",
104
+ });
105
+ }
106
+
107
+ let files = [];
108
+ try {
109
+ files = fs
110
+ .readdirSync(eventsDir)
111
+ .filter((name) => name.endsWith(".jsonl"))
112
+ .sort()
113
+ .slice(-maxFiles)
114
+ .reverse();
115
+ } catch {
116
+ return { per_agent: [], scanned_files: 0, matched_events: 0 };
117
+ }
118
+
119
+ let matchedEvents = 0;
120
+ for (const file of files) {
121
+ let lines = [];
122
+ try {
123
+ const raw = fs.readFileSync(path.join(eventsDir, file), "utf8");
124
+ lines = raw.split(/\r?\n/).filter(Boolean).reverse();
125
+ } catch {
126
+ continue;
127
+ }
128
+
129
+ for (const line of lines) {
130
+ let evt = null;
131
+ try {
132
+ evt = JSON.parse(line);
133
+ } catch {
134
+ continue;
135
+ }
136
+ if (!evt || evt.event !== "message") continue;
137
+ const targetAgentId = resolveHistoryAgentId(evt.target, activeIds, nicknames);
138
+ if (!targetAgentId) continue;
139
+ const prompt = evt.data && typeof evt.data.message === "string"
140
+ ? clipPromptText(evt.data.message)
141
+ : "";
142
+ if (!prompt) continue;
143
+
144
+ const row = entries.get(targetAgentId);
145
+ if (!row) continue;
146
+ matchedEvents += 1;
147
+ row.total_count += 1;
148
+ const ts = String(evt.timestamp || evt.ts || "");
149
+ if (!row.last_ts) row.last_ts = ts;
150
+ row.first_ts = ts || row.first_ts;
151
+ if (row.samples.length < perAgentLimit) {
152
+ row.samples.push({
153
+ ts,
154
+ publisher: String(evt.publisher || ""),
155
+ prompt,
156
+ });
157
+ row.sample_count = row.samples.length;
158
+ }
159
+ }
160
+ }
161
+
162
+ const perAgent = Array.from(entries.values())
163
+ .filter((row) => row.total_count > 0)
164
+ .sort((a, b) => {
165
+ const left = String(a.last_ts || "");
166
+ const right = String(b.last_ts || "");
167
+ return right.localeCompare(left);
168
+ });
169
+
170
+ return {
171
+ per_agent: perAgent,
172
+ scanned_files: files.length,
173
+ matched_events: matchedEvents,
174
+ };
175
+ }
176
+
31
177
  function loadBusSummary(projectRoot, maxLines = 20) {
32
- // Use daemon's buildStatus as the single source of truth
178
+ // Use daemon's buildStatus as the single source of truth.
33
179
  let agents = [];
34
180
  let nicknames = {};
181
+ let reports = { pending_total: 0, agents: [] };
182
+ let promptHistory = { per_agent: [], scanned_files: 0, matched_events: 0 };
183
+ let summary = {
184
+ active_count: 0,
185
+ busy_count: 0,
186
+ ready_count: 0,
187
+ pending_total: 0,
188
+ };
35
189
  try {
36
190
  const status = buildStatus(projectRoot);
37
- const activeMeta = status.active_meta || [];
191
+ const activeMeta = Array.isArray(status && status.active_meta) ? status.active_meta : [];
38
192
  agents = activeMeta.map((item) => {
39
193
  const nickname = item.nickname || "";
40
194
  if (nickname) {
@@ -42,16 +196,45 @@ function loadBusSummary(projectRoot, maxLines = 20) {
42
196
  }
43
197
  return {
44
198
  id: item.id,
199
+ nickname,
45
200
  status: "active",
46
201
  online: true,
47
- agent_type: "", // Not included in active_meta, but not needed
48
- nickname,
49
- last_heartbeat: "",
202
+ launch_mode: String(item.launch_mode || ""),
203
+ activity_state: String(item.activity_state || ""),
204
+ activity_since: String(item.activity_since || ""),
50
205
  };
51
206
  });
207
+
208
+ const reportState = status && status.reports && typeof status.reports === "object"
209
+ ? status.reports
210
+ : {};
211
+ const reportAgents = Array.isArray(reportState.agents)
212
+ ? reportState.agents.slice(0, 50).map((item) => toReportAgentSnapshot(item))
213
+ : [];
214
+ reports = {
215
+ pending_total: Number(reportState.pending_total || 0) || 0,
216
+ agents: reportAgents,
217
+ };
218
+
219
+ const busyCount = agents.filter((item) => isBusyActivityState(item.activity_state)).length;
220
+ summary = {
221
+ active_count: agents.length,
222
+ busy_count: busyCount,
223
+ ready_count: Math.max(agents.length - busyCount, 0),
224
+ pending_total: reports.pending_total,
225
+ };
226
+ promptHistory = buildAgentPromptHistory(projectRoot, agents, nicknames);
52
227
  } catch {
53
228
  agents = [];
54
229
  nicknames = {};
230
+ reports = { pending_total: 0, agents: [] };
231
+ promptHistory = { per_agent: [], scanned_files: 0, matched_events: 0 };
232
+ summary = {
233
+ active_count: 0,
234
+ busy_count: 0,
235
+ ready_count: 0,
236
+ pending_total: 0,
237
+ };
55
238
  }
56
239
 
57
240
  const eventsDir = getUfooPaths(projectRoot).busEventsDir;
@@ -74,7 +257,7 @@ function loadBusSummary(projectRoot, maxLines = 20) {
74
257
  recent = [];
75
258
  }
76
259
 
77
- return { agents, nicknames, recent };
260
+ return { agents, nicknames, reports, agent_prompt_history: promptHistory, summary, recent };
78
261
  }
79
262
 
80
263
  function buildSystemPrompt(context) {
@@ -106,6 +289,9 @@ function buildSystemPrompt(context) {
106
289
  "- Use top-level assistant_call for project exploration, temporary shell tasks, and quick execution support.",
107
290
  "- assistant_call fields: kind (explore|bash|mixed), task (required), context/expect (optional), provider (codex|claude|ufoo, optional), model/timeout_ms (optional).",
108
291
  "- Prefer assistant_call over launching coding agents when the task is short-lived.",
292
+ "- Primary routing signal is semantic continuity from agent_prompt_history; prefer the agent that already handled similar prompts.",
293
+ "- Launch a new coding agent when the request is a new topic without clear ownership in existing histories.",
294
+ "- If best-matching target agent is busy, keep routing to that same agent (queue semantics) instead of rerouting only by idle status.",
109
295
  "- Legacy compatibility: if model emits ops.assistant_call, daemon will still process it.",
110
296
  "- If no action needed, return reply with empty dispatch/ops.",
111
297
  agentGuidance,
@@ -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({ launchMode: info.launch_mode, agentId });
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
- * 使用 PTY socket 直接注入命令(无需macOS权限)
187
+ * 使用指定路径的 PTY socket 注入命令
188
188
  */
189
- async injectPty(subscriber, command) {
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)) {
@@ -7,6 +7,7 @@ const {
7
7
  appendJSONL,
8
8
  readLastLine,
9
9
  isPidAlive,
10
+ normalizeAgentTypeAlias,
10
11
  } = require("./utils");
11
12
  const NicknameManager = require("./nickname");
12
13
 
@@ -14,15 +15,6 @@ const SEQ_LOCK_TIMEOUT_MS = 5000;
14
15
  const SEQ_LOCK_POLL_MS = 25;
15
16
  const SEQ_LOCK_STALE_MS = 30000;
16
17
 
17
- function normalizeAgentTypeAlias(value = "") {
18
- const text = String(value || "").trim().toLowerCase();
19
- if (!text) return "";
20
- if (text === "codex") return "codex";
21
- if (text === "claude" || text === "claude-code") return "claude-code";
22
- if (text === "ufoo" || text === "ucode" || text === "ufoo-code") return "ufoo-code";
23
- return text;
24
- }
25
-
26
18
  /**
27
19
  * 消息管理器
28
20
  */
@@ -197,11 +197,29 @@ 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,
203
219
  nickname: finalNickname,
204
220
  status: "active",
221
+ activity_state: "starting",
222
+ activity_since: getTimestamp(),
205
223
  joined_at: existingMeta?.joined_at || getTimestamp(),
206
224
  last_seen: getTimestamp(),
207
225
  pid: overridePid || getJoinedPid(),
@@ -211,6 +229,22 @@ class SubscriberManager {
211
229
  launch_mode: launchMode,
212
230
  };
213
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
+
214
248
  const terminalApp = options.terminalApp || detectTerminalAppFromEnv();
215
249
  if (terminalApp) {
216
250
  this.busData.agents[subscriber].terminal_app = terminalApp;
package/src/bus/utils.js CHANGED
@@ -331,7 +331,17 @@ function isMetaActive(meta) {
331
331
  return false;
332
332
  }
333
333
 
334
+ function normalizeAgentTypeAlias(value = "") {
335
+ const text = String(value || "").trim().toLowerCase();
336
+ if (!text) return "";
337
+ if (text === "codex") return "codex";
338
+ if (text === "claude" || text === "claude-code") return "claude-code";
339
+ if (text === "ufoo" || text === "ucode" || text === "ufoo-code") return "ufoo-code";
340
+ return text;
341
+ }
342
+
334
343
  module.exports = {
344
+ normalizeAgentTypeAlias,
335
345
  getTimestamp,
336
346
  getDate,
337
347
  generateInstanceId,
@@ -1,5 +1,16 @@
1
1
  const { stripAnsi, truncateAnsi } = require("./text");
2
2
 
3
+ const ACTIVITY_INDICATORS = {
4
+ working: "*",
5
+ waiting_input: "?",
6
+ blocked: "!",
7
+ };
8
+
9
+ const ACTIVITY_COLORS = {
10
+ waiting_input: "\x1b[33m", // yellow
11
+ blocked: "\x1b[31m", // red
12
+ };
13
+
3
14
  function computeAgentBar(options = {}) {
4
15
  const {
5
16
  cols = 80,
@@ -11,6 +22,7 @@ function computeAgentBar(options = {}) {
11
22
  agentListWindowStart = 0,
12
23
  maxAgentWindow = 4,
13
24
  getAgentLabel = (id) => id,
25
+ agentStates = {},
14
26
  } = options;
15
27
 
16
28
  const hintAnsi = `\x1b[90m│ ${hintText}\x1b[0m`;
@@ -60,12 +72,18 @@ function computeAgentBar(options = {}) {
60
72
  agentParts = visible.map((agent, i) => {
61
73
  const rawLabel = getAgentLabel(agent);
62
74
  const label = maxLabelLen ? truncateLabel(rawLabel, maxLabelLen) : rawLabel;
75
+ const actState = agentStates[agent] || "";
76
+ const indicator = ACTIVITY_INDICATORS[actState] || "";
77
+ const indicatorColor = ACTIVITY_COLORS[actState] || "";
78
+ const prefix = indicator
79
+ ? `${indicatorColor}${indicator}\x1b[0m`
80
+ : "";
63
81
  const idx = s + i + 1; // +1 for ucode at index 0
64
82
  if (focusMode === "dashboard" && idx === selectedAgentIndex) {
65
- return `\x1b[90;7m${label}\x1b[0m`;
83
+ return `${prefix}\x1b[90;7m${label}\x1b[0m`;
66
84
  }
67
- if (agent === viewingAgent) return `\x1b[1;36m${label}\x1b[0m`;
68
- return `\x1b[36m${label}\x1b[0m`;
85
+ if (agent === viewingAgent) return `${prefix}\x1b[1;36m${label}\x1b[0m`;
86
+ return `${prefix}\x1b[36m${label}\x1b[0m`;
69
87
  });
70
88
  }
71
89
  const agentsText = activeAgents.length > 0
@@ -16,6 +16,7 @@ function createAgentViewController(options = {}) {
16
16
  getAgentListWindowStart = () => 0,
17
17
  setAgentListWindowStart = () => {},
18
18
  getAgentLabel = (id) => id,
19
+ getAgentStates = () => ({}),
19
20
  setDashboardView = () => {},
20
21
  setScreenGrabKeys = (value) => {
21
22
  if (screen) screen.grabKeys = Boolean(value);
@@ -79,6 +80,7 @@ function createAgentViewController(options = {}) {
79
80
  agentListWindowStart: getAgentListWindowStart(),
80
81
  maxAgentWindow,
81
82
  getAgentLabel,
83
+ agentStates: getAgentStates(),
82
84
  });
83
85
  setAgentListWindowStart(computed.windowStart);
84
86
  processStdout.write(`\x1b7\x1b[${rows};1H${computed.bar}\x1b8`);
@@ -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
  };
@@ -7,6 +7,7 @@ function createDaemonConnection(options = {}) {
7
7
  queueStatusLine,
8
8
  resolveStatusLine,
9
9
  logMessage,
10
+ switchConnectionTimeoutMs = 18000,
10
11
  } = options;
11
12
 
12
13
  let connectClient = connectClientOption;
@@ -16,6 +17,35 @@ function createDaemonConnection(options = {}) {
16
17
  let connectionLostNotified = false;
17
18
  const pendingRequests = [];
18
19
  const MAX_PENDING_REQUESTS = 50;
20
+ const STATUS_KEY_RECONNECT = "daemon-reconnect";
21
+ const STATUS_KEY_SWITCH = "daemon-switch";
22
+ const DEFAULT_SWITCH_TIMEOUT_MS = Number.isFinite(switchConnectionTimeoutMs)
23
+ && switchConnectionTimeoutMs > 0
24
+ ? Math.trunc(switchConnectionTimeoutMs)
25
+ : 18000;
26
+
27
+ function withTimeout(promiseLike, timeoutMs, timeoutMessage) {
28
+ const ms = Number.isFinite(timeoutMs) && timeoutMs > 0
29
+ ? Math.trunc(timeoutMs)
30
+ : DEFAULT_SWITCH_TIMEOUT_MS;
31
+ return new Promise((resolve, reject) => {
32
+ const timer = setTimeout(() => {
33
+ const err = new Error(timeoutMessage || `operation timed out after ${ms}ms`);
34
+ err.code = "UFOO_TIMEOUT";
35
+ reject(err);
36
+ }, ms);
37
+ if (typeof timer.unref === "function") {
38
+ timer.unref();
39
+ }
40
+ Promise.resolve(promiseLike).then((value) => {
41
+ clearTimeout(timer);
42
+ resolve(value);
43
+ }, (err) => {
44
+ clearTimeout(timer);
45
+ reject(err);
46
+ });
47
+ });
48
+ }
19
49
 
20
50
  function enqueueRequest(req) {
21
51
  if (!req || req.type === IPC_REQUEST_TYPES.STATUS) return;
@@ -91,18 +121,18 @@ function createDaemonConnection(options = {}) {
91
121
  if (client && !client.destroyed) return true;
92
122
  if (exitRequested) return false;
93
123
  if (reconnectPromise) return reconnectPromise;
94
- queueStatusLine("Reconnecting to daemon");
124
+ queueStatusLine("Reconnecting to daemon", { key: STATUS_KEY_RECONNECT });
95
125
  logMessage("status", "{white-fg}⚙{/white-fg} Reconnecting to daemon...");
96
126
  reconnectPromise = (async () => {
97
127
  const newClient = await connectClient();
98
128
  if (!newClient) {
99
- resolveStatusLine("{gray-fg}✗{/gray-fg} Daemon offline");
129
+ resolveStatusLine("{gray-fg}✗{/gray-fg} Daemon offline", { key: STATUS_KEY_RECONNECT });
100
130
  logMessage("error", "{white-fg}✗{/white-fg} Failed to reconnect to daemon");
101
131
  return false;
102
132
  }
103
133
  attachClient(newClient);
104
134
  connectionLostNotified = false;
105
- resolveStatusLine("{gray-fg}✓{/gray-fg} Daemon reconnected");
135
+ resolveStatusLine("{gray-fg}✓{/gray-fg} Daemon reconnected", { key: STATUS_KEY_RECONNECT });
106
136
  requestStatus();
107
137
  return true;
108
138
  })();
@@ -130,9 +160,17 @@ function createDaemonConnection(options = {}) {
130
160
  }
131
161
  const previousClient = client;
132
162
  try {
133
- queueStatusLine("Switching daemon connection");
134
- const nextClient = await nextConnectClient();
163
+ queueStatusLine("Switching daemon connection", { key: STATUS_KEY_SWITCH });
164
+ const timeoutMs = Number.isFinite(next.timeoutMs) && next.timeoutMs > 0
165
+ ? Math.trunc(next.timeoutMs)
166
+ : DEFAULT_SWITCH_TIMEOUT_MS;
167
+ const nextClient = await withTimeout(
168
+ nextConnectClient(),
169
+ timeoutMs,
170
+ `Switch connection timed out after ${timeoutMs}ms`
171
+ );
135
172
  if (!nextClient) {
173
+ resolveStatusLine("{gray-fg}✗{/gray-fg} Switch failed", { key: STATUS_KEY_SWITCH });
136
174
  return { ok: false, error: "Failed to connect target daemon" };
137
175
  }
138
176
  connectClient = nextConnectClient;
@@ -140,7 +178,7 @@ function createDaemonConnection(options = {}) {
140
178
  if (next.callRequestStatus !== false) {
141
179
  requestStatus();
142
180
  }
143
- resolveStatusLine("{gray-fg}✓{/gray-fg} Daemon switched");
181
+ resolveStatusLine("{gray-fg}✓{/gray-fg} Daemon switched", { key: STATUS_KEY_SWITCH });
144
182
  return { ok: true };
145
183
  } catch (err) {
146
184
  // Keep existing connection alive on switch failures.
@@ -148,7 +186,7 @@ function createDaemonConnection(options = {}) {
148
186
  client = previousClient;
149
187
  }
150
188
  const message = err && err.message ? err.message : String(err || "switch failed");
151
- resolveStatusLine("{gray-fg}✗{/gray-fg} Switch failed");
189
+ resolveStatusLine("{gray-fg}✗{/gray-fg} Switch failed", { key: STATUS_KEY_SWITCH });
152
190
  logMessage("error", `{white-fg}✗{/white-fg} ${message}`);
153
191
  return { ok: false, error: message };
154
192
  }