u-foo 1.4.1 → 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.
Files changed (47) hide show
  1. package/README.md +21 -0
  2. package/README.zh-CN.md +21 -0
  3. package/bin/ufoo.js +15 -7
  4. package/modules/AGENTS.template.md +4 -102
  5. package/package.json +3 -2
  6. package/scripts/global-chat-switch-benchmark.js +406 -0
  7. package/src/agent/activityDetector.js +328 -0
  8. package/src/agent/activityStatePublisher.js +67 -0
  9. package/src/agent/activityStateWriter.js +40 -0
  10. package/src/agent/internalRunner.js +13 -0
  11. package/src/agent/launcher.js +47 -7
  12. package/src/agent/notifier.js +73 -4
  13. package/src/agent/ptyRunner.js +81 -34
  14. package/src/agent/ufooAgent.js +192 -6
  15. package/src/bus/message.js +1 -9
  16. package/src/bus/subscriber.js +2 -0
  17. package/src/bus/utils.js +10 -0
  18. package/src/chat/agentBar.js +21 -3
  19. package/src/chat/agentViewController.js +2 -0
  20. package/src/chat/chatLogController.js +28 -5
  21. package/src/chat/commandExecutor.js +127 -3
  22. package/src/chat/commands.js +8 -0
  23. package/src/chat/daemonConnection.js +77 -4
  24. package/src/chat/daemonCoordinator.js +36 -0
  25. package/src/chat/daemonMessageRouter.js +22 -0
  26. package/src/chat/daemonTransport.js +47 -5
  27. package/src/chat/daemonTransportDefaults.js +1 -0
  28. package/src/chat/dashboardKeyController.js +89 -1
  29. package/src/chat/dashboardView.js +312 -93
  30. package/src/chat/index.js +683 -41
  31. package/src/chat/inputHistoryController.js +33 -3
  32. package/src/chat/inputListenerController.js +22 -12
  33. package/src/chat/layout.js +12 -7
  34. package/src/chat/projectCloseController.js +119 -0
  35. package/src/chat/projectRuntimes.js +55 -0
  36. package/src/chat/statusLineController.js +52 -6
  37. package/src/chat/streamTracker.js +6 -0
  38. package/src/chat/transport.js +41 -5
  39. package/src/cli.js +167 -4
  40. package/src/daemon/index.js +54 -5
  41. package/src/daemon/ipcServer.js +6 -1
  42. package/src/daemon/ops.js +245 -35
  43. package/src/daemon/status.js +3 -1
  44. package/src/init/index.js +32 -3
  45. package/src/projects/projectId.js +29 -0
  46. package/src/projects/registry.js +279 -0
  47. package/src/ufoo/agentsStore.js +44 -0
@@ -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`);
@@ -2,8 +2,8 @@ function createChatLogController(options = {}) {
2
2
  const {
3
3
  logBox,
4
4
  fsModule,
5
- historyDir,
6
- historyFile,
5
+ historyDir: historyDirOption,
6
+ historyFile: historyFileOption,
7
7
  now = () => new Date().toISOString(),
8
8
  } = options;
9
9
 
@@ -13,9 +13,11 @@ function createChatLogController(options = {}) {
13
13
  if (!fsModule) {
14
14
  throw new Error("createChatLogController requires fsModule");
15
15
  }
16
- if (!historyDir || !historyFile) {
16
+ if (!historyDirOption || !historyFileOption) {
17
17
  throw new Error("createChatLogController requires historyDir/historyFile");
18
18
  }
19
+ let historyDir = historyDirOption;
20
+ let historyFile = historyFileOption;
19
21
 
20
22
  const SPACED_TYPES = new Set(["user", "reply", "bus", "dispatch", "error"]);
21
23
  let lastLogWasSpacer = false;
@@ -95,9 +97,28 @@ function createChatLogController(options = {}) {
95
97
  recordLog(item.type || "unknown", item.text, item.meta || {}, false);
96
98
  }
97
99
  }
98
- } catch {
99
- // Ignore missing/invalid history.
100
+ } catch (err) {
101
+ if (err && err.code === "ENOENT") {
102
+ return;
103
+ }
104
+ if (err && typeof console !== "undefined" && typeof console.warn === "function") {
105
+ console.warn(`chat history load failed (${historyFile}): ${err.message || err}`);
106
+ }
107
+ }
108
+ }
109
+
110
+ function setHistoryTarget(next = {}) {
111
+ if (!next.historyDir || !next.historyFile) {
112
+ throw new Error("setHistoryTarget requires historyDir/historyFile");
100
113
  }
114
+ historyDir = next.historyDir;
115
+ historyFile = next.historyFile;
116
+ }
117
+
118
+ function resetViewState() {
119
+ // Callers are expected to clear logBox separately; this only resets spacing trackers.
120
+ lastLogWasSpacer = false;
121
+ hasLoggedAny = false;
101
122
  }
102
123
 
103
124
  return {
@@ -107,6 +128,8 @@ function createChatLogController(options = {}) {
107
128
  logMessage,
108
129
  markStreamStart,
109
130
  loadHistory,
131
+ setHistoryTarget,
132
+ resetViewState,
110
133
  };
111
134
  }
112
135
 
@@ -22,6 +22,13 @@ function defaultCreateSkills(projectRoot) {
22
22
  return new UfooSkills(projectRoot);
23
23
  }
24
24
 
25
+ function defaultResolveTerminalApp() {
26
+ const program = String(process.env.TERM_PROGRAM || "").trim();
27
+ if (program === "Apple_Terminal") return "terminal";
28
+ if (program === "iTerm.app" || process.env.ITERM_SESSION_ID) return "iterm2";
29
+ return "";
30
+ }
31
+
25
32
  async function withCapturedConsole(capture, fn) {
26
33
  const originalLog = console.log;
27
34
  const originalError = console.error;
@@ -72,6 +79,10 @@ function createCommandExecutor(options = {}) {
72
79
  stopCronTask = () => false,
73
80
  runGroupCore = runGroupCoreCommand,
74
81
  requestCron = null,
82
+ listProjects = () => [],
83
+ getCurrentProject = () => ({ projectRoot }),
84
+ switchProject = async () => ({ ok: false, error: "project switching unavailable" }),
85
+ resolveTerminalApp = defaultResolveTerminalApp,
75
86
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
76
87
  schedule = (fn, ms) => setTimeout(fn, ms),
77
88
  } = options;
@@ -355,7 +366,10 @@ function createCommandExecutor(options = {}) {
355
366
 
356
367
  async function handleLaunchCommand(args = []) {
357
368
  if (args.length === 0) {
358
- logMessage("error", "{white-fg}✗{/white-fg} Usage: /launch <claude|codex|ucode> [nickname=<name>] [count=<n>]");
369
+ logMessage(
370
+ "error",
371
+ "{white-fg}✗{/white-fg} Usage: /launch <claude|codex|ucode> [nickname=<name>] [count=<n>] [scope=inplace|window]"
372
+ );
359
373
  return;
360
374
  }
361
375
 
@@ -375,20 +389,68 @@ function createCommandExecutor(options = {}) {
375
389
  }
376
390
  }
377
391
 
392
+ function normalizeLaunchScopeOption(value, fallback = "inplace") {
393
+ const raw = String(value || "").trim().toLowerCase();
394
+ if (!raw) return fallback;
395
+ if (raw === "inplace" || raw === "same" || raw === "current" || raw === "tab" || raw === "pane") {
396
+ return "inplace";
397
+ }
398
+ if (
399
+ raw === "window"
400
+ || raw === "separate"
401
+ || raw === "new"
402
+ || raw === "new-window"
403
+ || raw === "external"
404
+ || raw === "1"
405
+ || raw === "true"
406
+ || raw === "yes"
407
+ || raw === "y"
408
+ || raw === "on"
409
+ ) {
410
+ return "window";
411
+ }
412
+ if (raw === "0" || raw === "false" || raw === "no" || raw === "n" || raw === "off") {
413
+ return "inplace";
414
+ }
415
+ return "";
416
+ }
417
+
378
418
  const nickname = parsedOptions.nickname || "";
379
419
  const count = parseInt(parsedOptions.count || "1", 10);
420
+ const scopeRaw = parsedOptions.scope || parsedOptions.launch_scope || parsedOptions.window || "";
421
+ let launchScope = normalizeLaunchScopeOption(scopeRaw, "inplace");
422
+ if (scopeRaw && !launchScope) {
423
+ logMessage("error", "{white-fg}✗{/white-fg} scope must be inplace|window");
424
+ return;
425
+ }
426
+ const rawFlags = args
427
+ .slice(1)
428
+ .filter((arg) => !String(arg || "").includes("="))
429
+ .map((arg) => String(arg || "").trim().toLowerCase())
430
+ .filter(Boolean);
431
+ for (const flag of rawFlags) {
432
+ const normalized = normalizeLaunchScopeOption(flag, "");
433
+ if (normalized) launchScope = normalized;
434
+ }
435
+ if (!launchScope) launchScope = "inplace";
380
436
  if (nickname && count > 1) {
381
437
  logMessage("error", "{white-fg}✗{/white-fg} nickname requires count=1");
382
438
  return;
383
439
  }
384
440
 
385
441
  try {
386
- send({
442
+ const request = {
387
443
  type: IPC_REQUEST_TYPES.LAUNCH_AGENT,
388
444
  agent: normalizedAgent,
389
445
  count: Number.isFinite(count) ? count : 1,
390
446
  nickname,
391
- });
447
+ launch_scope: launchScope,
448
+ };
449
+ const terminalApp = String(resolveTerminalApp() || "").trim().toLowerCase();
450
+ if (terminalApp === "terminal" || terminalApp === "iterm2") {
451
+ request.terminal_app = terminalApp;
452
+ }
453
+ send(request);
392
454
  schedule(requestStatus, 1000);
393
455
  } catch (err) {
394
456
  logMessage("error", `{white-fg}✗{/white-fg} Launch failed: ${escapeBlessed(err.message)}`);
@@ -413,6 +475,64 @@ function createCommandExecutor(options = {}) {
413
475
  schedule(requestStatus, 1000);
414
476
  }
415
477
 
478
+ async function handleProjectCommand(args = []) {
479
+ const subcommand = String(args[0] || "list").trim().toLowerCase();
480
+
481
+ if (subcommand === "list") {
482
+ const rowsRaw = await Promise.resolve(listProjects());
483
+ const rows = Array.isArray(rowsRaw) ? rowsRaw : [];
484
+ const current = await Promise.resolve(getCurrentProject());
485
+ const currentRoot = current && current.project_root ? String(current.project_root) : "";
486
+ if (rows.length === 0) {
487
+ logMessage("system", "{white-fg}No projects found{/white-fg}");
488
+ return;
489
+ }
490
+ logMessage("system", `{cyan-fg}Projects:{/cyan-fg} ${rows.length}`);
491
+ rows.forEach((item, idx) => {
492
+ const row = item || {};
493
+ const root = String(row.project_root || "");
494
+ const name = String(row.project_name || root || "-");
495
+ const status = String(row.status || "unknown");
496
+ const marker = root && root === currentRoot ? "*" : " ";
497
+ logMessage(
498
+ "system",
499
+ `${marker}${idx + 1}. {cyan-fg}${escapeBlessed(name)}{/cyan-fg} [{white-fg}${escapeBlessed(status)}{/white-fg}] ${escapeBlessed(root)}`
500
+ );
501
+ });
502
+ return;
503
+ }
504
+
505
+ if (subcommand === "current") {
506
+ const current = await Promise.resolve(getCurrentProject());
507
+ if (!current || !current.project_root) {
508
+ logMessage("error", "{white-fg}✗{/white-fg} Current project unavailable");
509
+ return;
510
+ }
511
+ logMessage("system", `{cyan-fg}Current:{/cyan-fg} ${escapeBlessed(current.project_root)}`);
512
+ return;
513
+ }
514
+
515
+ if (subcommand === "switch") {
516
+ const target = String(args[1] || "").trim();
517
+ if (!target) {
518
+ logMessage("error", "{white-fg}✗{/white-fg} Usage: /project switch <index|path>");
519
+ return;
520
+ }
521
+ logMessage("system", `{white-fg}⚙{/white-fg} Switching project: ${escapeBlessed(target)}`);
522
+ const result = await Promise.resolve(switchProject({ target }));
523
+ if (!result || result.ok !== true) {
524
+ const reason = result && result.error ? String(result.error) : "switch failed";
525
+ logMessage("error", `{white-fg}✗{/white-fg} Switch failed: ${escapeBlessed(reason)}`);
526
+ return;
527
+ }
528
+ const nextRoot = result.project_root || result.projectRoot || "";
529
+ logMessage("system", `{white-fg}✓{/white-fg} Switched project: ${escapeBlessed(nextRoot)}`);
530
+ return;
531
+ }
532
+
533
+ logMessage("error", "{white-fg}✗{/white-fg} Unknown project command. Use: list, current, switch");
534
+ }
535
+
416
536
  function parseKeyValueArgs(args = []) {
417
537
  const parsed = {};
418
538
  for (const raw of args) {
@@ -963,6 +1083,9 @@ function createCommandExecutor(options = {}) {
963
1083
  case "resume":
964
1084
  await handleResumeCommand(args);
965
1085
  return true;
1086
+ case "project":
1087
+ await handleProjectCommand(args);
1088
+ return true;
966
1089
  case "cron":
967
1090
  await handleCronCommand(args);
968
1091
  return true;
@@ -992,6 +1115,7 @@ function createCommandExecutor(options = {}) {
992
1115
  handleSkillsCommand,
993
1116
  handleLaunchCommand,
994
1117
  handleResumeCommand,
1118
+ handleProjectCommand,
995
1119
  handleCronCommand,
996
1120
  handleGroupCommand,
997
1121
  handleSettingsCommand,
@@ -55,6 +55,14 @@ const COMMAND_TREE = {
55
55
  ucode: { desc: "Launch ucode core agent" },
56
56
  },
57
57
  },
58
+ "/project": {
59
+ desc: "Project switch operations (spike)",
60
+ children: {
61
+ current: { desc: "Show current chat project" },
62
+ list: { desc: "List running projects from registry" },
63
+ switch: { desc: "Switch daemon connection to project index/path" },
64
+ },
65
+ },
58
66
  "/resume": {
59
67
  desc: "Resume agents (optional nickname) or list recoverable targets",
60
68
  children: {
@@ -2,19 +2,50 @@ const { IPC_REQUEST_TYPES } = require("../shared/eventContract");
2
2
 
3
3
  function createDaemonConnection(options = {}) {
4
4
  const {
5
- connectClient,
5
+ connectClient: connectClientOption,
6
6
  handleMessage,
7
7
  queueStatusLine,
8
8
  resolveStatusLine,
9
9
  logMessage,
10
+ switchConnectionTimeoutMs = 18000,
10
11
  } = options;
11
12
 
13
+ let connectClient = connectClientOption;
12
14
  let client = null;
13
15
  let reconnectPromise = null;
14
16
  let exitRequested = false;
15
17
  let connectionLostNotified = false;
16
18
  const pendingRequests = [];
17
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
+ }
18
49
 
19
50
  function enqueueRequest(req) {
20
51
  if (!req || req.type === IPC_REQUEST_TYPES.STATUS) return;
@@ -90,18 +121,18 @@ function createDaemonConnection(options = {}) {
90
121
  if (client && !client.destroyed) return true;
91
122
  if (exitRequested) return false;
92
123
  if (reconnectPromise) return reconnectPromise;
93
- queueStatusLine("Reconnecting to daemon");
124
+ queueStatusLine("Reconnecting to daemon", { key: STATUS_KEY_RECONNECT });
94
125
  logMessage("status", "{white-fg}⚙{/white-fg} Reconnecting to daemon...");
95
126
  reconnectPromise = (async () => {
96
127
  const newClient = await connectClient();
97
128
  if (!newClient) {
98
- resolveStatusLine("{gray-fg}✗{/gray-fg} Daemon offline");
129
+ resolveStatusLine("{gray-fg}✗{/gray-fg} Daemon offline", { key: STATUS_KEY_RECONNECT });
99
130
  logMessage("error", "{white-fg}✗{/white-fg} Failed to reconnect to daemon");
100
131
  return false;
101
132
  }
102
133
  attachClient(newClient);
103
134
  connectionLostNotified = false;
104
- resolveStatusLine("{gray-fg}✓{/gray-fg} Daemon reconnected");
135
+ resolveStatusLine("{gray-fg}✓{/gray-fg} Daemon reconnected", { key: STATUS_KEY_RECONNECT });
105
136
  requestStatus();
106
137
  return true;
107
138
  })();
@@ -120,6 +151,47 @@ function createDaemonConnection(options = {}) {
120
151
  return true;
121
152
  }
122
153
 
154
+ async function switchConnection(next = {}) {
155
+ const nextConnectClient = typeof next.connectClient === "function"
156
+ ? next.connectClient
157
+ : null;
158
+ if (!nextConnectClient) {
159
+ return { ok: false, error: "switchConnection requires connectClient" };
160
+ }
161
+ const previousClient = client;
162
+ try {
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
+ );
172
+ if (!nextClient) {
173
+ resolveStatusLine("{gray-fg}✗{/gray-fg} Switch failed", { key: STATUS_KEY_SWITCH });
174
+ return { ok: false, error: "Failed to connect target daemon" };
175
+ }
176
+ connectClient = nextConnectClient;
177
+ attachClient(nextClient);
178
+ if (next.callRequestStatus !== false) {
179
+ requestStatus();
180
+ }
181
+ resolveStatusLine("{gray-fg}✓{/gray-fg} Daemon switched", { key: STATUS_KEY_SWITCH });
182
+ return { ok: true };
183
+ } catch (err) {
184
+ // Keep existing connection alive on switch failures.
185
+ if (previousClient && (!client || client.destroyed)) {
186
+ client = previousClient;
187
+ }
188
+ const message = err && err.message ? err.message : String(err || "switch failed");
189
+ resolveStatusLine("{gray-fg}✗{/gray-fg} Switch failed", { key: STATUS_KEY_SWITCH });
190
+ logMessage("error", `{white-fg}✗{/white-fg} ${message}`);
191
+ return { ok: false, error: message };
192
+ }
193
+ }
194
+
123
195
  function send(req) {
124
196
  if (!client || client.destroyed) {
125
197
  enqueueRequest(req);
@@ -155,6 +227,7 @@ function createDaemonConnection(options = {}) {
155
227
  connect,
156
228
  send,
157
229
  requestStatus,
230
+ switchConnection,
158
231
  close,
159
232
  markExit,
160
233
  getState,
@@ -40,6 +40,41 @@ function createDaemonCoordinator(options = {}) {
40
40
  daemonConnection: connection,
41
41
  logMessage,
42
42
  });
43
+ let switchProjectChain = Promise.resolve();
44
+
45
+ function switchProject(target = {}) {
46
+ const runSwitch = async () => {
47
+ if (!daemonTransport || typeof daemonTransport.connectClientForTarget !== "function") {
48
+ return { ok: false, error: "project switching requires daemonTransport.connectClientForTarget" };
49
+ }
50
+ if (!target || !target.projectRoot || !target.sockPath) {
51
+ return { ok: false, error: "switchProject requires projectRoot and sockPath" };
52
+ }
53
+ if (!connection || typeof connection.switchConnection !== "function") {
54
+ return { ok: false, error: "daemon connection does not support switching" };
55
+ }
56
+
57
+ const result = await connection.switchConnection({
58
+ connectClient: () => daemonTransport.connectClientForTarget(target),
59
+ callRequestStatus: false,
60
+ });
61
+ if (!result || result.ok !== true) {
62
+ return {
63
+ ok: false,
64
+ error: (result && result.error) || "switch failed",
65
+ };
66
+ }
67
+ if (typeof daemonTransport.setTarget === "function") {
68
+ daemonTransport.setTarget(target);
69
+ }
70
+ connection.requestStatus();
71
+ return { ok: true, target };
72
+ };
73
+
74
+ const scheduled = switchProjectChain.then(runSwitch, runSwitch);
75
+ switchProjectChain = scheduled.catch(() => {});
76
+ return scheduled;
77
+ }
43
78
 
44
79
  function isConnected() {
45
80
  if (!connection || typeof connection.getState !== "function") return false;
@@ -54,6 +89,7 @@ function createDaemonCoordinator(options = {}) {
54
89
  restart: () => restart(),
55
90
  close: () => connection.close(),
56
91
  markExit: () => connection.markExit(),
92
+ switchProject,
57
93
  isConnected,
58
94
  getState: () => (typeof connection.getState === "function" ? connection.getState() : null),
59
95
  };
@@ -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,23 +11,65 @@ 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
- async function connectClient() {
17
- let client = await connectWithRetry(sockPath, primaryRetries, retryDelayMs);
17
+ let activeProjectRoot = projectRoot;
18
+ let activeSockPath = sockPath;
19
+
20
+ function resolveTarget(override = {}) {
21
+ return {
22
+ projectRoot: override.projectRoot || activeProjectRoot,
23
+ sockPath: override.sockPath || activeSockPath,
24
+ };
25
+ }
26
+
27
+ async function connectClientForTarget(override = {}) {
28
+ const target = resolveTarget(override);
29
+ let client = await connectWithRetry(
30
+ target.sockPath,
31
+ primaryRetries,
32
+ retryDelayMs,
33
+ { timeoutMs: connectTimeoutMs }
34
+ );
18
35
  if (!client) {
19
36
  // Retry once with a fresh daemon start and longer wait.
20
- if (!isRunning(projectRoot)) {
21
- startDaemon(projectRoot);
37
+ if (!isRunning(target.projectRoot)) {
38
+ startDaemon(target.projectRoot);
22
39
  await new Promise((resolve) => setTimeout(resolve, restartDelayMs));
23
40
  }
24
- client = await connectWithRetry(sockPath, secondaryRetries, retryDelayMs);
41
+ client = await connectWithRetry(
42
+ target.sockPath,
43
+ secondaryRetries,
44
+ retryDelayMs,
45
+ { timeoutMs: connectTimeoutMs }
46
+ );
25
47
  }
26
48
  return client;
27
49
  }
28
50
 
51
+ async function connectClient() {
52
+ return connectClientForTarget();
53
+ }
54
+
55
+ function setTarget(next = {}) {
56
+ if (next.projectRoot) activeProjectRoot = next.projectRoot;
57
+ if (next.sockPath) activeSockPath = next.sockPath;
58
+ return getTarget();
59
+ }
60
+
61
+ function getTarget() {
62
+ return {
63
+ projectRoot: activeProjectRoot,
64
+ sockPath: activeSockPath,
65
+ };
66
+ }
67
+
29
68
  return {
30
69
  connectClient,
70
+ connectClientForTarget,
71
+ setTarget,
72
+ getTarget,
31
73
  };
32
74
  }
33
75
 
@@ -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 = {