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
@@ -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) {
@@ -22,17 +22,35 @@ function ensureAtPrefix(value) {
22
22
  return text.startsWith("@") ? text : `@${text}`;
23
23
  }
24
24
 
25
+ function activityMarker(state = "") {
26
+ const normalized = String(state || "").trim().toLowerCase();
27
+ if (normalized === "working") return "*";
28
+ if (normalized === "waiting_input") return "?";
29
+ if (normalized === "blocked") return "!";
30
+ return "";
31
+ }
32
+
33
+ function withActivityMarker(label = "", state = "") {
34
+ const marker = activityMarker(state);
35
+ if (!marker) return label;
36
+ return `${marker}${label}`;
37
+ }
38
+
25
39
  function buildSummaryLine(options = {}) {
26
40
  const {
27
41
  activeAgents = [],
28
42
  getAgentLabel = (id) => id,
43
+ getAgentState = () => "",
29
44
  launchMode = "terminal",
30
45
  agentProvider = "codex-cli",
31
46
  assistantEngine = "auto",
32
47
  cronTasks = [],
33
48
  } = options;
34
49
  const agents = activeAgents.length > 0
35
- ? activeAgents.slice(0, 3).map((id) => ensureAtPrefix(getAgentLabel(id))).join(", ") + (activeAgents.length > 3 ? ` +${activeAgents.length - 3}` : "")
50
+ ? activeAgents.slice(0, 3)
51
+ .map((id) => withActivityMarker(ensureAtPrefix(getAgentLabel(id)), getAgentState(id)))
52
+ .join(", ")
53
+ + (activeAgents.length > 3 ? ` +${activeAgents.length - 3}` : "")
36
54
  : "none";
37
55
  return `{gray-fg}Agents:{/gray-fg} {cyan-fg}${agents}{/cyan-fg}`
38
56
  + ` {gray-fg}Mode:{/gray-fg} {cyan-fg}${launchMode}{/cyan-fg}`
@@ -86,17 +104,13 @@ function buildProjectRailLine(options = {}) {
86
104
  const rowRoot = String((row && row.project_root) || "");
87
105
  const isActiveProject = Boolean(activeRoot && rowRoot === activeRoot);
88
106
  const isSelected = absoluteIndex === safeSelectedIndex;
89
- const displayName = isActiveProject ? `[${name}]` : name;
90
107
  if (projectsFocused && isSelected) {
91
- return `{inverse}${displayName}{/inverse}`;
108
+ return `{inverse}${name}{/inverse}`;
92
109
  }
93
110
  if (isActiveProject) {
94
- return `{cyan-fg}${displayName}{/cyan-fg}`;
95
- }
96
- if (isSelected) {
97
- return `{inverse}${displayName}{/inverse}`;
111
+ return `{bold}{cyan-fg}${name}{/cyan-fg}{/bold}`;
98
112
  }
99
- return `{cyan-fg}${displayName}{/cyan-fg}`;
113
+ return `{cyan-fg}${name}{/cyan-fg}`;
100
114
  });
101
115
 
102
116
  const leftMore = start > 0 ? "{gray-fg}<{/gray-fg} " : "";
@@ -117,6 +131,7 @@ function buildDashboardDetailLine(options = {}) {
117
131
  agentListWindowStart = 0,
118
132
  maxAgentWindow = 4,
119
133
  getAgentLabel = (id) => id,
134
+ getAgentState = () => "",
120
135
  selectedModeIndex = 0,
121
136
  selectedProviderIndex = 0,
122
137
  selectedAssistantIndex = 0,
@@ -203,7 +218,10 @@ function buildDashboardDetailLine(options = {}) {
203
218
  const visibleAgents = activeAgents.slice(start, end);
204
219
  const agentParts = visibleAgents.map((agent, i) => {
205
220
  const absoluteIndex = start + i;
206
- const label = ensureAtPrefix(getAgentLabel(agent));
221
+ const label = withActivityMarker(
222
+ ensureAtPrefix(getAgentLabel(agent)),
223
+ getAgentState(agent)
224
+ );
207
225
  if (absoluteIndex === selectedAgentIndex) {
208
226
  return `{inverse}${label}{/inverse}`;
209
227
  }
@@ -238,6 +256,7 @@ function computeDashboardContent(options = {}) {
238
256
  agentListWindowStart = 0,
239
257
  maxAgentWindow = 4,
240
258
  getAgentLabel = (id) => id,
259
+ getAgentState = () => "",
241
260
  launchMode = "terminal",
242
261
  agentProvider = "codex-cli",
243
262
  assistantEngine = "auto",
@@ -275,6 +294,7 @@ function computeDashboardContent(options = {}) {
275
294
  const line2 = buildSummaryLine({
276
295
  activeAgents,
277
296
  getAgentLabel,
297
+ getAgentState,
278
298
  launchMode,
279
299
  agentProvider,
280
300
  assistantEngine,
@@ -294,6 +314,7 @@ function computeDashboardContent(options = {}) {
294
314
  agentListWindowStart,
295
315
  maxAgentWindow,
296
316
  getAgentLabel,
317
+ getAgentState,
297
318
  selectedModeIndex,
298
319
  selectedProviderIndex,
299
320
  selectedAssistantIndex,
@@ -320,6 +341,7 @@ function computeDashboardContent(options = {}) {
320
341
  agentListWindowStart,
321
342
  maxAgentWindow,
322
343
  getAgentLabel,
344
+ getAgentState,
323
345
  selectedModeIndex,
324
346
  selectedProviderIndex,
325
347
  selectedAssistantIndex,
@@ -337,6 +359,7 @@ function computeDashboardContent(options = {}) {
337
359
  content += buildSummaryLine({
338
360
  activeAgents,
339
361
  getAgentLabel,
362
+ getAgentState,
340
363
  launchMode,
341
364
  agentProvider,
342
365
  assistantEngine,
package/src/chat/index.js CHANGED
@@ -38,13 +38,19 @@ const { createChatLogController } = require("./chatLogController");
38
38
  const { createPasteController } = require("./pasteController");
39
39
  const { createAgentViewController } = require("./agentViewController");
40
40
  const { createSettingsController } = require("./settingsController");
41
+ const { createProjectCloseController } = require("./projectCloseController");
41
42
  const { createChatLayout } = require("./layout");
42
43
  const { createDaemonCoordinator } = require("./daemonCoordinator");
43
44
  const { IPC_REQUEST_TYPES } = require("../shared/eventContract");
44
45
  const { createTerminalAdapterRouter } = require("../terminal/adapterRouter");
45
46
  const { createDaemonTransport } = require("./daemonTransport");
46
- const { listProjectRuntimes } = require("../projects/registry");
47
+ const { listProjectRuntimes, resolveRuntimeDir } = require("../projects/registry");
47
48
  const { canonicalProjectRoot, buildProjectId } = require("../projects/projectId");
49
+ const {
50
+ sortProjectRuntimes,
51
+ parseTimestampMs,
52
+ filterVisibleProjectRuntimes,
53
+ } = require("./projectRuntimes");
48
54
 
49
55
  async function runChat(projectRoot, options = {}) {
50
56
  const globalMode = options && options.globalMode === true;
@@ -655,6 +661,7 @@ async function runChat(projectRoot, options = {}) {
655
661
  let activeAgents = [];
656
662
  let activeAgentLabelMap = new Map();
657
663
  let activeAgentMetaMap = new Map(); // Store full meta including launch_mode
664
+ const transientAgentStateMap = new Map();
658
665
  let agentListWindowStart = 0;
659
666
  const MAX_AGENT_WINDOW = 4;
660
667
  let projectRuntimes = [];
@@ -698,7 +705,7 @@ async function runChat(projectRoot, options = {}) {
698
705
  cron: "Ctrl+X close · ↑ back",
699
706
  resume: "",
700
707
  projects: "Use /project switch <index|path>",
701
- projectsFocus: "←/→ switch · ↓ second row · Enter confirm · ↑ back",
708
+ projectsFocus: "←/→ switch · Ctrl+X close · ↓ second row · Enter confirm · ↑ back",
702
709
  projectsEmpty: "Run ufoo chat or ufoo daemon start in project directories",
703
710
  };
704
711
  const AGENT_BAR_HINTS = {
@@ -718,7 +725,7 @@ async function runChat(projectRoot, options = {}) {
718
725
  if (!terminalAdapterRouter) return null;
719
726
  const meta = activeAgentMetaMap ? activeAgentMetaMap.get(agentId) : null;
720
727
  const agentLaunchMode = (meta && meta.launch_mode) || launchMode || "";
721
- return terminalAdapterRouter.getAdapter({ launchMode: agentLaunchMode, agentId });
728
+ return terminalAdapterRouter.getAdapter({ launchMode: agentLaunchMode, agentId, meta });
722
729
  }
723
730
 
724
731
  function getViewingAgentAdapter() {
@@ -927,6 +934,7 @@ async function runChat(projectRoot, options = {}) {
927
934
  } catch {
928
935
  rows = [];
929
936
  }
937
+ rows = filterVisibleProjectRuntimes(rows);
930
938
  const normalizedActive = String(activeProjectRoot || "");
931
939
  if (
932
940
  normalizedActive
@@ -939,7 +947,27 @@ async function runChat(projectRoot, options = {}) {
939
947
  last_seen: null,
940
948
  });
941
949
  }
942
- projectRuntimes = rows;
950
+ projectRuntimes = sortProjectRuntimes({
951
+ rows,
952
+ activeProjectRoot: normalizedActive,
953
+ resolveProjectRoot: resolveRuntimeProjectRoot,
954
+ getInteractionMs: (row) => {
955
+ const rowRoot = resolveRuntimeProjectRoot(row);
956
+ if (!rowRoot) return 0;
957
+ try {
958
+ const historyContext = resolveHistoryContext(rowRoot);
959
+ if (historyContext && historyContext.historyFile && fs.existsSync(historyContext.historyFile)) {
960
+ const stat = fs.statSync(historyContext.historyFile);
961
+ if (Number.isFinite(stat.mtimeMs) && stat.mtimeMs > 0) {
962
+ return stat.mtimeMs;
963
+ }
964
+ }
965
+ } catch {
966
+ // fall through
967
+ }
968
+ return parseTimestampMs(row && row.last_seen);
969
+ },
970
+ });
943
971
 
944
972
  if (projectRuntimes.length === 0) {
945
973
  selectedProjectIndex = -1;
@@ -1040,8 +1068,6 @@ async function runChat(projectRoot, options = {}) {
1040
1068
  logMessage("error", "{white-fg}✗{/white-fg} No agent selected");
1041
1069
  return;
1042
1070
  }
1043
- const label = getAgentLabel(agentId);
1044
- logMessage("status", `{white-fg}⚙{/white-fg} Closing ${label}...`);
1045
1071
  send({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
1046
1072
  }
1047
1073
 
@@ -1135,6 +1161,18 @@ async function runChat(projectRoot, options = {}) {
1135
1161
  agentListWindowStart,
1136
1162
  maxAgentWindow: MAX_AGENT_WINDOW,
1137
1163
  getAgentLabel,
1164
+ getAgentState: (agentId) => {
1165
+ let metaState = "";
1166
+ if (activeAgentMetaMap) {
1167
+ const meta = activeAgentMetaMap.get(agentId);
1168
+ metaState = meta && typeof meta.activity_state === "string"
1169
+ ? String(meta.activity_state).trim()
1170
+ : "";
1171
+ }
1172
+ if (metaState) return metaState;
1173
+ const transientState = transientAgentStateMap.get(agentId);
1174
+ return typeof transientState === "string" ? transientState : "";
1175
+ },
1138
1176
  launchMode,
1139
1177
  agentProvider,
1140
1178
  assistantEngine,
@@ -1162,8 +1200,36 @@ async function runChat(projectRoot, options = {}) {
1162
1200
  dashboard.setContent(dashboardContent);
1163
1201
  }
1164
1202
 
1203
+ function readDiskMetaForActiveAgents(activeList = []) {
1204
+ const map = new Map();
1205
+ const ids = Array.isArray(activeList) ? activeList : [];
1206
+ if (ids.length === 0) return map;
1207
+ try {
1208
+ const busPath = getUfooPaths(activeProjectRoot).agentsFile;
1209
+ if (!fs.existsSync(busPath)) return map;
1210
+ const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
1211
+ const agents = bus && bus.agents && typeof bus.agents === "object" ? bus.agents : {};
1212
+ for (const id of ids) {
1213
+ const meta = agents[id];
1214
+ if (!meta || typeof meta !== "object") continue;
1215
+ map.set(id, meta);
1216
+ }
1217
+ } catch {
1218
+ // ignore disk fallback errors
1219
+ }
1220
+ return map;
1221
+ }
1222
+
1165
1223
  function updateDashboard(status) {
1166
1224
  activeAgents = status.active || [];
1225
+ if (transientAgentStateMap.size > 0) {
1226
+ const activeSet = new Set(activeAgents);
1227
+ for (const id of transientAgentStateMap.keys()) {
1228
+ if (!activeSet.has(id)) {
1229
+ transientAgentStateMap.delete(id);
1230
+ }
1231
+ }
1232
+ }
1167
1233
  if (globalMode) {
1168
1234
  refreshProjectRuntimes();
1169
1235
  }
@@ -1187,7 +1253,35 @@ async function runChat(projectRoot, options = {}) {
1187
1253
  }
1188
1254
  const maps = agentDirectory.buildAgentMaps(activeAgents, metaList, fallbackMap);
1189
1255
  activeAgentLabelMap = maps.labelMap;
1190
- activeAgentMetaMap = maps.metaMap;
1256
+ const diskMetaMap = readDiskMetaForActiveAgents(activeAgents);
1257
+ if (diskMetaMap.size > 0) {
1258
+ const mergedMetaMap = new Map(maps.metaMap);
1259
+ for (const id of activeAgents) {
1260
+ const currentMeta = mergedMetaMap.get(id);
1261
+ const diskMeta = diskMetaMap.get(id);
1262
+ if (!currentMeta && diskMeta) {
1263
+ mergedMetaMap.set(id, { id, ...diskMeta });
1264
+ continue;
1265
+ }
1266
+ if (!currentMeta || !diskMeta) continue;
1267
+ const currentState = typeof currentMeta.activity_state === "string"
1268
+ ? String(currentMeta.activity_state).trim()
1269
+ : "";
1270
+ const diskState = typeof diskMeta.activity_state === "string"
1271
+ ? String(diskMeta.activity_state).trim()
1272
+ : "";
1273
+ if (!currentState && diskState) {
1274
+ mergedMetaMap.set(id, {
1275
+ ...currentMeta,
1276
+ activity_state: diskState,
1277
+ activity_since: currentMeta.activity_since || diskMeta.activity_since || "",
1278
+ });
1279
+ }
1280
+ }
1281
+ activeAgentMetaMap = mergedMetaMap;
1282
+ } else {
1283
+ activeAgentMetaMap = maps.metaMap;
1284
+ }
1191
1285
  clampAgentWindow();
1192
1286
  // If viewing agent went offline, exit view
1193
1287
  const currentView = getCurrentView();
@@ -1320,6 +1414,7 @@ async function runChat(projectRoot, options = {}) {
1320
1414
  clampAgentWindow,
1321
1415
  clampAgentWindowWithSelection,
1322
1416
  requestProjectSwitch: requestProjectSwitchByIndex,
1417
+ requestCloseProject: requestCloseProjectByIndex,
1323
1418
  renderDashboard,
1324
1419
  renderAgentDashboard,
1325
1420
  renderScreen: () => screen.render(),
@@ -1379,6 +1474,15 @@ async function runChat(projectRoot, options = {}) {
1379
1474
  agentListWindowStart = value;
1380
1475
  },
1381
1476
  getAgentLabel,
1477
+ getAgentStates: () => {
1478
+ const states = {};
1479
+ if (activeAgentMetaMap) {
1480
+ for (const [id, meta] of activeAgentMetaMap) {
1481
+ if (meta && meta.activity_state) states[id] = meta.activity_state;
1482
+ }
1483
+ }
1484
+ return states;
1485
+ },
1382
1486
  setDashboardView: (value) => {
1383
1487
  dashboardView = value;
1384
1488
  },
@@ -1444,6 +1548,21 @@ async function runChat(projectRoot, options = {}) {
1444
1548
  appendStreamDelta,
1445
1549
  finalizeStream,
1446
1550
  hasStream: (publisher) => streamTracker.hasStream(publisher),
1551
+ setTransientAgentState: (agentId, state) => {
1552
+ if (!agentId || !state) return;
1553
+ transientAgentStateMap.set(agentId, state);
1554
+ },
1555
+ clearTransientAgentState: (agentId) => {
1556
+ if (!agentId) return;
1557
+ transientAgentStateMap.delete(agentId);
1558
+ },
1559
+ refreshDashboard: () => {
1560
+ if (getCurrentView() === "agent") {
1561
+ renderAgentDashboard();
1562
+ return;
1563
+ }
1564
+ renderDashboard();
1565
+ },
1447
1566
  });
1448
1567
 
1449
1568
  daemonCoordinator = createDaemonCoordinator({
@@ -1677,6 +1796,29 @@ async function runChat(projectRoot, options = {}) {
1677
1796
  return { ok: false, error: "switch did not complete" };
1678
1797
  }
1679
1798
 
1799
+ const projectCloseController = createProjectCloseController({
1800
+ getProjects: () => projectRuntimes,
1801
+ getActiveProjectRoot: () => activeProjectRoot,
1802
+ resolveProjectRoot: resolveRuntimeProjectRoot,
1803
+ isRunning,
1804
+ stopDaemon,
1805
+ switchProject: (targetProjectRoot) => requestProjectSwitchByTarget(targetProjectRoot),
1806
+ refreshProjects: () => {
1807
+ if (!globalMode) return;
1808
+ refreshProjectRuntimes();
1809
+ syncSelectedProjectToActive();
1810
+ },
1811
+ renderDashboard,
1812
+ renderScreen: () => screen.render(),
1813
+ logMessage,
1814
+ escapeBlessed,
1815
+ });
1816
+
1817
+ function requestCloseProjectByIndex(index) {
1818
+ if (!globalMode) return;
1819
+ void projectCloseController.requestCloseProject(index);
1820
+ }
1821
+
1680
1822
  const commandExecutor = createCommandExecutor({
1681
1823
  projectRoot,
1682
1824
  parseCommand,
@@ -1856,7 +1998,33 @@ async function runChat(projectRoot, options = {}) {
1856
1998
  if (daemonCoordinator && daemonCoordinator.isConnected()) {
1857
1999
  requestStatus();
1858
2000
  }
1859
- }, 30000);
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
+ }
1860
2028
  screen.on("resize", () => {
1861
2029
  if (handleResizeInAgentView()) {
1862
2030
  return;
@@ -0,0 +1,119 @@
1
+ function normalizeIndex(value, length) {
2
+ const parsed = Number(value);
3
+ const nextIndex = Number.isFinite(parsed) ? Math.trunc(parsed) : Number.NaN;
4
+ if (!Number.isFinite(nextIndex) || nextIndex < 0 || nextIndex >= length) {
5
+ return -1;
6
+ }
7
+ return nextIndex;
8
+ }
9
+
10
+ function defaultResolveProjectRoot(row = {}) {
11
+ return String((row && row.project_root) || "");
12
+ }
13
+
14
+ function createProjectCloseController(options = {}) {
15
+ const {
16
+ getProjects = () => [],
17
+ getActiveProjectRoot = () => "",
18
+ resolveProjectRoot = defaultResolveProjectRoot,
19
+ isRunning = () => false,
20
+ stopDaemon = () => false,
21
+ switchProject = async () => ({ ok: false, error: "project switching unavailable" }),
22
+ refreshProjects = () => {},
23
+ renderDashboard = () => {},
24
+ renderScreen = () => {},
25
+ logMessage = () => {},
26
+ escapeBlessed = (value) => String(value || ""),
27
+ } = options;
28
+
29
+ let closingProject = false;
30
+
31
+ function pickFallbackProjectRoot(targetProjectRoot) {
32
+ const rows = Array.isArray(getProjects()) ? getProjects() : [];
33
+ for (const row of rows) {
34
+ const root = resolveProjectRoot(row);
35
+ if (!root || root === targetProjectRoot) continue;
36
+ return root;
37
+ }
38
+ return "";
39
+ }
40
+
41
+ async function requestCloseProject(index) {
42
+ if (closingProject) {
43
+ return { ok: false, error: "project close already in progress" };
44
+ }
45
+
46
+ const rows = Array.isArray(getProjects()) ? getProjects() : [];
47
+ const nextIndex = normalizeIndex(index, rows.length);
48
+ if (nextIndex < 0) {
49
+ return { ok: false, error: "project index out of range" };
50
+ }
51
+
52
+ const target = rows[nextIndex] || {};
53
+ const projectRoot = resolveProjectRoot(target);
54
+ if (!projectRoot) {
55
+ return { ok: false, error: "project root unavailable" };
56
+ }
57
+
58
+ const projectName = String(target.project_name || projectRoot);
59
+ const escapedName = escapeBlessed(projectName);
60
+ const activeProjectRoot = String(getActiveProjectRoot() || "");
61
+
62
+ closingProject = true;
63
+ try {
64
+ logMessage("status", `{white-fg}⚙{/white-fg} Closing project ${escapedName} daemon and agents...`);
65
+
66
+ let switchedTo = "";
67
+ if (activeProjectRoot === projectRoot) {
68
+ const fallbackRoot = pickFallbackProjectRoot(projectRoot);
69
+ if (!fallbackRoot) {
70
+ const error = "Cannot close current project; switch to another project first";
71
+ logMessage("error", `{white-fg}✗{/white-fg} ${escapeBlessed(error)}`);
72
+ return { ok: false, error };
73
+ }
74
+
75
+ const switched = await Promise.resolve(switchProject(fallbackRoot));
76
+ if (!switched || switched.ok !== true) {
77
+ const reason = String((switched && switched.error) || "switch failed");
78
+ logMessage("error", `{white-fg}✗{/white-fg} Failed to switch project before close: ${escapeBlessed(reason)}`);
79
+ return { ok: false, error: reason };
80
+ }
81
+ switchedTo = fallbackRoot;
82
+ }
83
+
84
+ const wasRunning = Boolean(isRunning(projectRoot));
85
+ stopDaemon(projectRoot);
86
+
87
+ refreshProjects();
88
+ renderDashboard();
89
+ renderScreen();
90
+
91
+ if (wasRunning) {
92
+ logMessage("status", `{white-fg}✓{/white-fg} Closed project ${escapedName} daemon and agents`);
93
+ } else {
94
+ logMessage("status", `{white-fg}✓{/white-fg} Project ${escapedName} daemon already stopped`);
95
+ }
96
+
97
+ return {
98
+ ok: true,
99
+ project_root: projectRoot,
100
+ switched_to: switchedTo || undefined,
101
+ };
102
+ } catch (err) {
103
+ const message = err && err.message ? err.message : String(err || "project close failed");
104
+ logMessage("error", `{white-fg}✗{/white-fg} Failed to close project: ${escapeBlessed(message)}`);
105
+ return { ok: false, error: message };
106
+ } finally {
107
+ closingProject = false;
108
+ }
109
+ }
110
+
111
+ return {
112
+ requestCloseProject,
113
+ pickFallbackProjectRoot,
114
+ };
115
+ }
116
+
117
+ module.exports = {
118
+ createProjectCloseController,
119
+ };