u-foo 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -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
  */
@@ -202,6 +202,8 @@ class SubscriberManager {
202
202
  agent_type: agentType,
203
203
  nickname: finalNickname,
204
204
  status: "active",
205
+ activity_state: "starting",
206
+ activity_since: getTimestamp(),
205
207
  joined_at: existingMeta?.joined_at || getTimestamp(),
206
208
  last_seen: getTimestamp(),
207
209
  pid: overridePid || getJoinedPid(),
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`);
@@ -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
  }
@@ -25,8 +25,17 @@ function createDaemonMessageRouter(options = {}) {
25
25
  appendStreamDelta = () => {},
26
26
  finalizeStream = () => {},
27
27
  hasStream = () => false,
28
+ setTransientAgentState = () => {},
29
+ clearTransientAgentState = () => {},
30
+ refreshDashboard = () => {},
28
31
  } = options;
29
32
 
33
+ function isLikelySubscriberId(value) {
34
+ const text = String(value || "");
35
+ if (!text) return false;
36
+ return text.includes(":") && !text.includes(" ");
37
+ }
38
+
30
39
  function normalizeDisplayMessage(raw) {
31
40
  let displayMessage = raw || "";
32
41
  let streamPayload = null;
@@ -53,6 +62,14 @@ function createDaemonMessageRouter(options = {}) {
53
62
  if (typeof data.phase === "string") {
54
63
  const text = data.text || "";
55
64
  const item = { key: data.key, text };
65
+ const key = typeof data.key === "string" ? data.key : "";
66
+ if (isLikelySubscriberId(key)) {
67
+ if (data.phase === BUS_STATUS_PHASES.START) {
68
+ setTransientAgentState(key, "working");
69
+ } else if (data.phase === BUS_STATUS_PHASES.DONE || data.phase === BUS_STATUS_PHASES.ERROR) {
70
+ clearTransientAgentState(key);
71
+ }
72
+ }
56
73
  if (data.phase === BUS_STATUS_PHASES.START) {
57
74
  enqueueBusStatus(item);
58
75
  } else if (data.phase === BUS_STATUS_PHASES.DONE || data.phase === BUS_STATUS_PHASES.ERROR) {
@@ -66,6 +83,7 @@ function createDaemonMessageRouter(options = {}) {
66
83
  } else {
67
84
  enqueueBusStatus(item);
68
85
  }
86
+ refreshDashboard();
69
87
  renderScreen();
70
88
  return false;
71
89
  }
@@ -301,6 +319,10 @@ function createDaemonMessageRouter(options = {}) {
301
319
 
302
320
  function handleBusMessage(msg) {
303
321
  const data = msg.data || {};
322
+ if (data.event === "activity_state_changed") {
323
+ requestStatus();
324
+ return true;
325
+ }
304
326
  const prefix = data.event === "broadcast" ? "{gray-fg}⇢{/gray-fg}" : "{gray-fg}↔{/gray-fg}";
305
327
  const publisher = data.publisher && data.publisher !== "unknown"
306
328
  ? data.publisher
@@ -11,6 +11,7 @@ function createDaemonTransport(options = {}) {
11
11
  secondaryRetries = DAEMON_TRANSPORT_DEFAULTS.secondaryRetries,
12
12
  retryDelayMs = DAEMON_TRANSPORT_DEFAULTS.retryDelayMs,
13
13
  restartDelayMs = DAEMON_TRANSPORT_DEFAULTS.restartDelayMs,
14
+ connectTimeoutMs = DAEMON_TRANSPORT_DEFAULTS.connectTimeoutMs,
14
15
  } = options;
15
16
 
16
17
  let activeProjectRoot = projectRoot;
@@ -25,14 +26,24 @@ function createDaemonTransport(options = {}) {
25
26
 
26
27
  async function connectClientForTarget(override = {}) {
27
28
  const target = resolveTarget(override);
28
- let client = await connectWithRetry(target.sockPath, primaryRetries, retryDelayMs);
29
+ let client = await connectWithRetry(
30
+ target.sockPath,
31
+ primaryRetries,
32
+ retryDelayMs,
33
+ { timeoutMs: connectTimeoutMs }
34
+ );
29
35
  if (!client) {
30
36
  // Retry once with a fresh daemon start and longer wait.
31
37
  if (!isRunning(target.projectRoot)) {
32
38
  startDaemon(target.projectRoot);
33
39
  await new Promise((resolve) => setTimeout(resolve, restartDelayMs));
34
40
  }
35
- client = await connectWithRetry(target.sockPath, secondaryRetries, retryDelayMs);
41
+ client = await connectWithRetry(
42
+ target.sockPath,
43
+ secondaryRetries,
44
+ retryDelayMs,
45
+ { timeoutMs: connectTimeoutMs }
46
+ );
36
47
  }
37
48
  return client;
38
49
  }
@@ -3,6 +3,7 @@ const DAEMON_TRANSPORT_DEFAULTS = {
3
3
  secondaryRetries: 50,
4
4
  retryDelayMs: 200,
5
5
  restartDelayMs: 1000,
6
+ connectTimeoutMs: 2000,
6
7
  };
7
8
 
8
9
  module.exports = {
@@ -23,6 +23,7 @@ function createDashboardKeyController(options = {}) {
23
23
  clampAgentWindow = () => {},
24
24
  clampAgentWindowWithSelection = () => {},
25
25
  requestProjectSwitch = () => {},
26
+ requestCloseProject = () => {},
26
27
  renderDashboard = () => {},
27
28
  renderAgentDashboard = () => {},
28
29
  renderScreen = () => {},
@@ -385,6 +386,14 @@ function createDashboardKeyController(options = {}) {
385
386
  return true;
386
387
  }
387
388
 
389
+ if (key.name === "x" && key.ctrl) {
390
+ const current = Number.isFinite(state.selectedProjectIndex) ? state.selectedProjectIndex : 0;
391
+ if (current >= 0 && current < projects.length) {
392
+ requestCloseProject(current);
393
+ }
394
+ return true;
395
+ }
396
+
388
397
  if (key.name === "down") {
389
398
  state.dashboardView = "agents";
390
399
  if (!Array.isArray(state.activeAgents) || state.activeAgents.length === 0) {