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
package/README.md CHANGED
@@ -111,6 +111,26 @@ ucode-core run-once --json
111
111
  ucode-core list --json
112
112
  ```
113
113
 
114
+ ## Global Chat (`ufoo -g`)
115
+
116
+ Use `ufoo -g` (or `ufoo --global`) to launch a cross-project chat dashboard. Instead of being scoped to a single project, global mode connects to all running ufoo daemons and lets you switch between projects on the fly.
117
+
118
+ ```bash
119
+ $ ufoo -g
120
+
121
+ > /project list # List all running project daemons
122
+ > /project switch 2 # Switch to project #2
123
+ > /launch claude scope=inplace # Launch agent in current context
124
+ > @claude-1 Start reviewing the auth module
125
+ ```
126
+
127
+ | Command | Description |
128
+ |---------|-------------|
129
+ | `/project list` | List running projects from global runtime registry |
130
+ | `/project switch <index\|path>` | Switch active project daemon connection |
131
+ | `/launch <agent> scope=inplace` | Launch agent in current workspace |
132
+ | `/launch <agent> scope=window` | Launch agent in separate terminal window |
133
+
114
134
  ## Agent Configuration
115
135
 
116
136
  Configure AI providers in `.ufoo/config.json`:
@@ -187,6 +207,7 @@ Bus state lives in `.ufoo/agent/all-agents.json` (metadata), `.ufoo/bus/*` (queu
187
207
  |---------|-------------|
188
208
  | `ufoo` | Launch chat interface (default) |
189
209
  | `ufoo chat` | Launch interactive multi-agent chat UI |
210
+ | `ufoo -g` | Launch global chat mode (cross-project dashboard) |
190
211
  | `ufoo init` | Initialize .ufoo in current project |
191
212
  | `ufoo status` | Show banner, unread bus messages, open decisions |
192
213
  | `ufoo doctor` | Check installation health |
package/README.zh-CN.md CHANGED
@@ -111,6 +111,26 @@ ucode-core run-once --json
111
111
  ucode-core list --json
112
112
  ```
113
113
 
114
+ ## 全局聊天(`ufoo -g`)
115
+
116
+ 使用 `ufoo -g`(或 `ufoo --global`)启动跨项目聊天仪表盘。全局模式会连接所有正在运行的 ufoo 守护进程,支持在不同项目之间快速切换。
117
+
118
+ ```bash
119
+ $ ufoo -g
120
+
121
+ > /project list # 列出所有运行中的项目守护进程
122
+ > /project switch 2 # 切换到第 2 个项目
123
+ > /launch claude scope=inplace # 在当前上下文启动 Agent
124
+ > @claude-1 开始审查 auth 模块
125
+ ```
126
+
127
+ | 命令 | 说明 |
128
+ |------|------|
129
+ | `/project list` | 列出全局运行时注册的项目 |
130
+ | `/project switch <序号\|路径>` | 切换活动项目的 daemon 连接 |
131
+ | `/launch <agent> scope=inplace` | 在当前工作区启动 Agent |
132
+ | `/launch <agent> scope=window` | 在独立终端窗口启动 Agent |
133
+
114
134
  ## Agent 配置
115
135
 
116
136
  在 `.ufoo/config.json` 中配置 AI 提供商:
@@ -187,6 +207,7 @@ Bus 状态存放于 `.ufoo/agent/all-agents.json`(元数据)、`.ufoo/bus/*`
187
207
  |------|------|
188
208
  | `ufoo` | 启动聊天界面(默认) |
189
209
  | `ufoo chat` | 启动交互式多 Agent 聊天 UI |
210
+ | `ufoo -g` | 启动全局聊天模式(跨项目仪表盘) |
190
211
  | `ufoo init` | 在当前项目初始化 .ufoo |
191
212
  | `ufoo status` | 显示 banner、未读消息和未处理决策 |
192
213
  | `ufoo doctor` | 检查安装状态 |
package/bin/ufoo.js CHANGED
@@ -6,24 +6,32 @@ const { runChat } = require("../src/chat");
6
6
  const { runInternalRunner } = require("../src/agent/internalRunner");
7
7
  const { runPtyRunner } = require("../src/agent/ptyRunner");
8
8
 
9
- const cmd = process.argv[2];
9
+ const rawArgv = process.argv.slice(2);
10
+
11
+ function hasGlobalModeFlag(args = []) {
12
+ return args.includes("-g") || args.includes("--global");
13
+ }
10
14
 
11
15
  async function main() {
16
+ const globalMode = hasGlobalModeFlag(rawArgv);
17
+ const argv = rawArgv.filter((arg) => arg !== "-g" && arg !== "--global");
18
+ const cmd = argv[0];
19
+
12
20
  if (!cmd) {
13
- await runChat(process.cwd());
21
+ await runChat(process.cwd(), { globalMode });
14
22
  return;
15
23
  }
16
24
  if (cmd === "daemon") {
17
- runDaemonCli(process.argv.slice(2));
25
+ runDaemonCli(["daemon", ...argv.slice(1)]);
18
26
  return;
19
27
  }
20
28
  if (cmd === "agent-runner") {
21
- const agentType = process.argv[3] || "codex";
29
+ const agentType = argv[1] || "codex";
22
30
  await runInternalRunner({ projectRoot: process.cwd(), agentType });
23
31
  return;
24
32
  }
25
33
  if (cmd === "agent-pty-runner") {
26
- const agentType = process.argv[3] || "codex";
34
+ const agentType = argv[1] || "codex";
27
35
  try {
28
36
  await runPtyRunner({ projectRoot: process.cwd(), agentType });
29
37
  } catch (err) {
@@ -39,13 +47,13 @@ async function main() {
39
47
  return;
40
48
  }
41
49
  if (cmd === "chat") {
42
- await runChat(process.cwd());
50
+ await runChat(process.cwd(), { globalMode });
43
51
  return;
44
52
  }
45
53
 
46
54
  // Handle resume command to resume/launch agent sessions
47
55
  if (cmd === "resume") {
48
- const target = process.argv[3];
56
+ const target = argv[1];
49
57
  if (!target) {
50
58
  console.error("Error: resume requires an agent type or nickname");
51
59
  console.error("Usage: ufoo resume <ucode|uclaude|ucodex|nickname>");
@@ -1,105 +1,7 @@
1
1
  <!-- ufoo -->
2
- ## ufoo Protocol
2
+ ## ufoo Agent Protocol
3
3
 
4
- This project uses **ufoo** for agent coordination. Read the full documentation at `.ufoo/docs/` (symlinked from ufoo installation).
5
-
6
- ### Core Principles
7
-
8
- 1. **Agents are autonomous** - Execute tasks without asking for permission
9
- 2. **Communication via bus** - Use `ufoo bus` for inter-agent messaging
10
- 3. **Decisions are recorded** - Use `ufoo ctx` for decision tracking
11
- 4. **Context is shared** - All agents read from `.ufoo/context/`
12
-
13
- ### Available Commands
14
-
15
- | Command | Description |
16
- |---------|-------------|
17
- | `uinit` | Initialize/repair .ufoo directory |
18
- | `uctx` | Check context status and decisions |
19
- | `ustatus` | Unified status view (banner, unread bus, open decisions) |
20
- | `ubus` | Check bus messages and **auto-execute** them |
21
-
22
- ### Quick Reference
23
-
24
- ```bash
25
- # Context
26
- ufoo ctx decisions -l # List all decisions
27
- ufoo ctx decisions -n 1 # Show latest decision
28
-
29
- # Bus
30
- SUBSCRIBER="${UFOO_SUBSCRIBER_ID:-$(ufoo bus whoami 2>/dev/null || true)}"
31
- [ -n "$SUBSCRIBER" ] || SUBSCRIBER=$(ufoo bus join | tail -1)
32
- ufoo bus check $SUBSCRIBER # Check pending messages
33
- ufoo bus send "<id>" "<msg>" # Send message
34
- ufoo bus status # Show bus status
35
-
36
- # Runtime report (shared contract for assistant/ucodex/uclaude)
37
- ufoo report start "<task>" --task <id> --agent "$SUBSCRIBER" --scope public
38
- ufoo report progress "<detail>" --task <id> --agent "$SUBSCRIBER" --scope public
39
- ufoo report done "<summary>" --task <id> --agent "$SUBSCRIBER" --scope public
40
- ufoo report error "<reason>" --task <id> --agent "$SUBSCRIBER" --scope public
41
- ```
42
-
43
- ---
44
-
45
- ## ufoo context Protocol
46
-
47
- On session start, check context status:
48
- ```bash
49
- ufoo ctx decisions -l
50
- ufoo ctx decisions -n 1
51
- ```
52
-
53
- Key files in `.ufoo/context/`:
54
- - `decisions/` - Decision log (append-only)
55
-
56
- **Decision recording policy — "If it has information value, write it down":**
57
-
58
- Record a decision whenever your work produces knowledge that would be useful to your future self, other agents, or the user. The threshold is LOW — when in doubt, record it.
59
-
60
- - **Always record**: architectural choices, trade-off analysis, research findings, non-obvious gotchas, naming/convention changes, external API behavior discovered, performance observations, bug root causes
61
- - **Also record**: open questions you couldn't resolve, assumptions you made, approaches you considered and rejected (with reasons), edge cases noticed but not handled
62
- - **Write the decision BEFORE acting on it** — if your session dies, the knowledge survives
63
- - **Granularity**: A decision can be one sentence ("X doesn't support Y, use Z instead") or a multi-page analysis. Match the depth to the information value.
64
-
65
- ```bash
66
- ufoo ctx decisions new "Short descriptive title"
67
- # Then edit the created file with Context/Decision/Implications
68
- ```
69
-
70
- ---
71
-
72
- ## ufoo bus Protocol
73
-
74
- ### CRITICAL: `ubus` Command Behavior
75
-
76
- **When you receive `ubus`, you MUST:**
77
- 1. Resolve subscriber ID first (reuse existing ID, join only as fallback):
78
- `SUBSCRIBER="${UFOO_SUBSCRIBER_ID:-$(ufoo bus whoami 2>/dev/null || true)}"; [ -n "$SUBSCRIBER" ] || SUBSCRIBER=$(ufoo bus join | tail -1)`
79
- 2. Check pending messages: `ufoo bus check $SUBSCRIBER`
80
- 3. **EXECUTE each task immediately** - Do NOT ask the user
81
- 4. Reply to sender: `ufoo bus send "<publisher>" "<result>"`
82
- 5. **CRITICAL: Acknowledge messages after handling**: `ufoo bus ack $SUBSCRIBER`
83
-
84
- **Rules:**
85
- - Execute tasks immediately without asking
86
- - Always reply to the sender
87
- - Do NOT ask "Want me to...?" or "Should I...?"
88
- - Do NOT wait for user confirmation
89
-
90
- ### Message Format
91
-
92
- ```
93
- @you from claude-code:abc123
94
- Type: message/targeted
95
- Content: {"message":"Please analyze the project structure"}
96
- ```
97
-
98
- Extract sender ID from "from" field, use it to reply.
99
-
100
- ### Example
101
-
102
- 1. Receive: `@you from claude-code:bd36dda0 Content: {"message":"Please analyze the project structure"}`
103
- 2. Execute: Analyze the project structure
104
- 3. Reply: `ufoo bus send "claude-code:bd36dda0" "Project contains src/, modules/, bin/"`
4
+ > **Record decisions before acting.** Any knowledge with information value `ufoo ctx decisions new "Title"` BEFORE you act on it.
5
+ > **Auto-execute bus messages.** On `ubus`: execute tasks immediately, reply to sender, then `ufoo bus ack`. Never ask the user.
6
+ > **Full protocol**: `/ufoo` skill (auto-loaded on session start). Docs: `.ufoo/docs/`
105
7
  <!-- /ufoo -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -43,7 +43,8 @@
43
43
  "postinstall": "node scripts/postinstall.js",
44
44
  "test": "jest",
45
45
  "test:watch": "jest --watch",
46
- "test:coverage": "jest --coverage"
46
+ "test:coverage": "jest --coverage",
47
+ "bench:global-switch": "node scripts/global-chat-switch-benchmark.js"
47
48
  },
48
49
  "dependencies": {
49
50
  "blessed": "^0.1.81",
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { spawn, spawnSync } = require("child_process");
6
+ const UfooInit = require("../src/init");
7
+ const { socketPath, isRunning } = require("../src/daemon");
8
+ const { connectWithRetry } = require("../src/chat/transport");
9
+ const { createDaemonTransport } = require("../src/chat/daemonTransport");
10
+ const { createDaemonCoordinator } = require("../src/chat/daemonCoordinator");
11
+
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+
16
+ function parseIntArg(argv, flag, fallback) {
17
+ const idx = argv.indexOf(flag);
18
+ if (idx < 0 || idx + 1 >= argv.length) return fallback;
19
+ const parsed = Number.parseInt(String(argv[idx + 1] || ""), 10);
20
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
21
+ }
22
+
23
+ function parseStringArg(argv, flag, fallback) {
24
+ const idx = argv.indexOf(flag);
25
+ if (idx < 0 || idx + 1 >= argv.length) return fallback;
26
+ const value = String(argv[idx + 1] || "").trim();
27
+ return value || fallback;
28
+ }
29
+
30
+ function hasFlag(argv, flag) {
31
+ return argv.includes(flag);
32
+ }
33
+
34
+ function percentile(sortedValues, p) {
35
+ if (!Array.isArray(sortedValues) || sortedValues.length === 0) return 0;
36
+ const clamped = Math.max(0, Math.min(1, p));
37
+ const idx = Math.ceil(clamped * sortedValues.length) - 1;
38
+ const safeIdx = Math.max(0, Math.min(sortedValues.length - 1, idx));
39
+ return sortedValues[safeIdx];
40
+ }
41
+
42
+ function summarizeDurations(values) {
43
+ if (!Array.isArray(values) || values.length === 0) {
44
+ return {
45
+ count: 0,
46
+ minMs: 0,
47
+ maxMs: 0,
48
+ avgMs: 0,
49
+ p50Ms: 0,
50
+ p95Ms: 0,
51
+ };
52
+ }
53
+ const sorted = [...values].sort((a, b) => a - b);
54
+ const total = values.reduce((sum, n) => sum + n, 0);
55
+ return {
56
+ count: values.length,
57
+ minMs: sorted[0],
58
+ maxMs: sorted[sorted.length - 1],
59
+ avgMs: total / values.length,
60
+ p50Ms: percentile(sorted, 0.5),
61
+ p95Ms: percentile(sorted, 0.95),
62
+ };
63
+ }
64
+
65
+ function normalizeProjectRootForCompare(projectRoot) {
66
+ const raw = String(projectRoot || "").trim();
67
+ if (!raw) return "";
68
+ try {
69
+ return fs.realpathSync.native(raw);
70
+ } catch {
71
+ return path.resolve(raw);
72
+ }
73
+ }
74
+
75
+ async function waitForDaemonReady(projectRoot, timeoutMs = 20000) {
76
+ const startedAt = Date.now();
77
+ while (Date.now() - startedAt < timeoutMs) {
78
+ if (isRunning(projectRoot)) {
79
+ const client = await connectWithRetry(socketPath(projectRoot), 1, 0);
80
+ if (client) {
81
+ try {
82
+ client.end();
83
+ client.destroy();
84
+ } catch {
85
+ // ignore
86
+ }
87
+ return true;
88
+ }
89
+ }
90
+ // eslint-disable-next-line no-await-in-loop
91
+ await sleep(150);
92
+ }
93
+ return false;
94
+ }
95
+
96
+ async function waitForDaemonStopped(projectRoot, timeoutMs = 10000) {
97
+ const startedAt = Date.now();
98
+ while (Date.now() - startedAt < timeoutMs) {
99
+ if (!isRunning(projectRoot)) return true;
100
+ // eslint-disable-next-line no-await-in-loop
101
+ await sleep(120);
102
+ }
103
+ return !isRunning(projectRoot);
104
+ }
105
+
106
+ function createStatusWaiter() {
107
+ let latestProjectRoot = "";
108
+ const waiting = new Set();
109
+ const seen = [];
110
+
111
+ function settle(targetRoot, ok, error) {
112
+ for (const waiter of Array.from(waiting)) {
113
+ if (waiter.targetRoot !== targetRoot) continue;
114
+ waiting.delete(waiter);
115
+ if (ok) waiter.resolve(targetRoot);
116
+ else waiter.reject(error || new Error(`status wait failed: ${targetRoot}`));
117
+ }
118
+ }
119
+
120
+ function handleMessage(msg) {
121
+ const type = msg && msg.type ? String(msg.type) : "";
122
+ seen.push({
123
+ ts: Date.now(),
124
+ type: type || "unknown",
125
+ projectRoot: msg && msg.data && msg.data.projectRoot ? String(msg.data.projectRoot) : "",
126
+ });
127
+ if (seen.length > 50) {
128
+ seen.shift();
129
+ }
130
+ if (!msg || msg.type !== "status") return false;
131
+ const rootRaw = msg.data && msg.data.projectRoot ? String(msg.data.projectRoot) : "";
132
+ const root = normalizeProjectRootForCompare(rootRaw);
133
+ if (!root) return false;
134
+ latestProjectRoot = root;
135
+ settle(root, true);
136
+ return false;
137
+ }
138
+
139
+ function waitForProject(targetRoot, timeoutMs = 5000) {
140
+ const normalizedTarget = normalizeProjectRootForCompare(targetRoot);
141
+ if (!normalizedTarget) {
142
+ return Promise.reject(new Error("invalid target root for status wait"));
143
+ }
144
+ if (latestProjectRoot === normalizedTarget) return Promise.resolve(normalizedTarget);
145
+ return new Promise((resolve, reject) => {
146
+ const waiter = { targetRoot: normalizedTarget, resolve, reject, timer: null };
147
+ waiter.timer = setTimeout(() => {
148
+ waiting.delete(waiter);
149
+ const seenTail = seen.slice(-5)
150
+ .map((entry) => `${entry.type}:${entry.projectRoot || "-"}`)
151
+ .join(", ");
152
+ reject(new Error(`timeout waiting status for ${normalizedTarget}; seen=[${seenTail}]`));
153
+ }, timeoutMs);
154
+ const wrappedResolve = (value) => {
155
+ clearTimeout(waiter.timer);
156
+ resolve(value);
157
+ };
158
+ const wrappedReject = (err) => {
159
+ clearTimeout(waiter.timer);
160
+ reject(err);
161
+ };
162
+ waiting.add({
163
+ targetRoot: normalizedTarget,
164
+ resolve: wrappedResolve,
165
+ reject: wrappedReject,
166
+ });
167
+ });
168
+ }
169
+
170
+ function clearAll(err) {
171
+ for (const waiter of Array.from(waiting)) {
172
+ waiting.delete(waiter);
173
+ waiter.reject(err || new Error("status waiter cleared"));
174
+ }
175
+ }
176
+
177
+ return {
178
+ handleMessage,
179
+ waitForProject,
180
+ clearAll,
181
+ getSeen: () => seen.slice(),
182
+ };
183
+ }
184
+
185
+ async function main() {
186
+ const argv = process.argv.slice(2);
187
+ const switches = parseIntArg(argv, "--switches", 50);
188
+ const keepTmp = hasFlag(argv, "--keep-tmp");
189
+ const jsonOnly = hasFlag(argv, "--json");
190
+ const tempParent = parseStringArg(argv, "--tmp-root", "/tmp");
191
+
192
+ const tempRoot = fs.mkdtempSync(path.join(tempParent, "ufoo-global-switch-bench-"));
193
+ const projectA = path.join(tempRoot, "project-a");
194
+ const projectB = path.join(tempRoot, "project-b");
195
+ fs.mkdirSync(projectA, { recursive: true });
196
+ fs.mkdirSync(projectB, { recursive: true });
197
+
198
+ let coordinator = null;
199
+ const statusWaiter = createStatusWaiter();
200
+ const daemonProcesses = new Map();
201
+ let exitCode = 0;
202
+ const errors = [];
203
+ const daemonBin = path.resolve(__dirname, "..", "bin", "ufoo.js");
204
+
205
+ function startManagedDaemon(projectRoot) {
206
+ const existing = daemonProcesses.get(projectRoot);
207
+ if (existing && !existing.child.killed && existing.child.exitCode === null) {
208
+ return existing.child;
209
+ }
210
+ const child = spawn(process.execPath, [daemonBin, "daemon", "start"], {
211
+ cwd: projectRoot,
212
+ env: { ...process.env, UFOO_DAEMON_CHILD: "1" },
213
+ stdio: ["ignore", "pipe", "pipe"],
214
+ });
215
+ const logs = { stdout: "", stderr: "" };
216
+ child.stdout.on("data", (chunk) => {
217
+ logs.stdout += String(chunk || "");
218
+ if (logs.stdout.length > 8000) logs.stdout = logs.stdout.slice(-8000);
219
+ });
220
+ child.stderr.on("data", (chunk) => {
221
+ logs.stderr += String(chunk || "");
222
+ if (logs.stderr.length > 8000) logs.stderr = logs.stderr.slice(-8000);
223
+ });
224
+ daemonProcesses.set(projectRoot, { child, logs });
225
+ return child;
226
+ }
227
+
228
+ function stopManagedDaemon(projectRoot) {
229
+ try {
230
+ spawnSync(process.execPath, [daemonBin, "daemon", "stop"], {
231
+ cwd: projectRoot,
232
+ stdio: "ignore",
233
+ });
234
+ } catch {
235
+ // ignore
236
+ }
237
+ }
238
+
239
+ try {
240
+ const init = new UfooInit(path.resolve(__dirname, ".."));
241
+ await init.init({ modules: "context,bus", project: projectA });
242
+ await init.init({ modules: "context,bus", project: projectB });
243
+
244
+ startManagedDaemon(projectA);
245
+ startManagedDaemon(projectB);
246
+ const readyA = await waitForDaemonReady(projectA);
247
+ const readyB = await waitForDaemonReady(projectB);
248
+ if (!readyA || !readyB) {
249
+ const aMeta = daemonProcesses.get(projectA);
250
+ const bMeta = daemonProcesses.get(projectB);
251
+ const aErr = aMeta ? aMeta.logs.stderr || aMeta.logs.stdout : "";
252
+ const bErr = bMeta ? bMeta.logs.stderr || bMeta.logs.stdout : "";
253
+ if (aErr) errors.push(`daemon A log: ${aErr.trim().slice(-400)}`);
254
+ if (bErr) errors.push(`daemon B log: ${bErr.trim().slice(-400)}`);
255
+ throw new Error(`daemon readiness failed: A=${readyA} B=${readyB}`);
256
+ }
257
+
258
+ const transport = createDaemonTransport({
259
+ projectRoot: projectA,
260
+ sockPath: socketPath(projectA),
261
+ isRunning,
262
+ startDaemon: startManagedDaemon,
263
+ connectWithRetry,
264
+ primaryRetries: 12,
265
+ secondaryRetries: 20,
266
+ retryDelayMs: 80,
267
+ restartDelayMs: 600,
268
+ });
269
+
270
+ coordinator = createDaemonCoordinator({
271
+ projectRoot: projectA,
272
+ daemonTransport: transport,
273
+ handleMessage: statusWaiter.handleMessage,
274
+ queueStatusLine: () => {},
275
+ resolveStatusLine: () => {},
276
+ logMessage: () => {},
277
+ stopDaemon: stopManagedDaemon,
278
+ startDaemon: startManagedDaemon,
279
+ });
280
+
281
+ const connected = await coordinator.connect();
282
+ if (!connected) {
283
+ throw new Error("initial coordinator.connect() failed");
284
+ }
285
+ coordinator.requestStatus();
286
+ await statusWaiter.waitForProject(projectA, 5000);
287
+
288
+ const durations = [];
289
+ let routingChecksPassed = 0;
290
+
291
+ for (let i = 0; i < switches; i += 1) {
292
+ const targetRoot = i % 2 === 0 ? projectB : projectA;
293
+ const startedNs = process.hrtime.bigint();
294
+ // eslint-disable-next-line no-await-in-loop
295
+ const result = await coordinator.switchProject({
296
+ projectRoot: targetRoot,
297
+ sockPath: socketPath(targetRoot),
298
+ });
299
+ const durationMs = Number(process.hrtime.bigint() - startedNs) / 1e6;
300
+ durations.push(durationMs);
301
+ if (!result || result.ok !== true) {
302
+ errors.push(`switch ${i + 1} failed: ${(result && result.error) || "unknown"}`);
303
+ continue;
304
+ }
305
+ try {
306
+ // eslint-disable-next-line no-await-in-loop
307
+ await statusWaiter.waitForProject(targetRoot, 5000);
308
+ routingChecksPassed += 1;
309
+ } catch (err) {
310
+ errors.push(`switch ${i + 1} status mismatch: ${err.message || err}`);
311
+ }
312
+ }
313
+
314
+ const summary = summarizeDurations(durations);
315
+ const thresholds = {
316
+ p50MsLt500: summary.p50Ms < 500,
317
+ p95MsLt1200: summary.p95Ms < 1200,
318
+ };
319
+ const routeOk = routingChecksPassed === switches;
320
+ const pass = routeOk && thresholds.p50MsLt500 && thresholds.p95MsLt1200 && errors.length === 0;
321
+ exitCode = pass ? 0 : 2;
322
+
323
+ const report = {
324
+ switches,
325
+ routingChecksPassed,
326
+ routeOk,
327
+ summary,
328
+ thresholds,
329
+ pass,
330
+ tempRoot,
331
+ errors,
332
+ };
333
+
334
+ if (jsonOnly) {
335
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
336
+ } else {
337
+ process.stdout.write("=== Global Chat Switch Benchmark ===\n");
338
+ process.stdout.write(`tempRoot: ${tempRoot}\n`);
339
+ process.stdout.write(`switches: ${switches}\n`);
340
+ process.stdout.write(`routing checks: ${routingChecksPassed}/${switches} (${routeOk ? "PASS" : "FAIL"})\n`);
341
+ process.stdout.write(
342
+ `latency ms: min=${summary.minMs.toFixed(2)} avg=${summary.avgMs.toFixed(2)} ` +
343
+ `p50=${summary.p50Ms.toFixed(2)} p95=${summary.p95Ms.toFixed(2)} max=${summary.maxMs.toFixed(2)}\n`
344
+ );
345
+ process.stdout.write(
346
+ `thresholds: p50<500=${thresholds.p50MsLt500 ? "PASS" : "FAIL"} ` +
347
+ `p95<1200=${thresholds.p95MsLt1200 ? "PASS" : "FAIL"}\n`
348
+ );
349
+ if (errors.length > 0) {
350
+ process.stdout.write("errors:\n");
351
+ errors.forEach((line) => process.stdout.write(`- ${line}\n`));
352
+ }
353
+ process.stdout.write(`overall: ${pass ? "PASS" : "FAIL"}\n`);
354
+ }
355
+ } finally {
356
+ statusWaiter.clearAll(new Error("benchmark teardown"));
357
+ if (coordinator) {
358
+ try {
359
+ coordinator.close();
360
+ } catch {
361
+ // ignore
362
+ }
363
+ }
364
+ try {
365
+ stopManagedDaemon(projectA);
366
+ } catch {
367
+ // ignore
368
+ }
369
+ try {
370
+ stopManagedDaemon(projectB);
371
+ } catch {
372
+ // ignore
373
+ }
374
+ for (const [projectRoot, meta] of daemonProcesses.entries()) {
375
+ const child = meta && meta.child;
376
+ if (!child || child.exitCode !== null) continue;
377
+ try {
378
+ child.kill("SIGTERM");
379
+ } catch {
380
+ // ignore
381
+ }
382
+ // Ensure child cannot leak if SIGTERM is ignored.
383
+ await sleep(80);
384
+ if (child.exitCode === null) {
385
+ try {
386
+ child.kill("SIGKILL");
387
+ } catch {
388
+ // ignore
389
+ }
390
+ }
391
+ daemonProcesses.delete(projectRoot);
392
+ }
393
+ await waitForDaemonStopped(projectA, 8000);
394
+ await waitForDaemonStopped(projectB, 8000);
395
+ if (!keepTmp) {
396
+ fs.rmSync(tempRoot, { recursive: true, force: true });
397
+ }
398
+ }
399
+
400
+ process.exit(exitCode);
401
+ }
402
+
403
+ main().catch((err) => {
404
+ process.stderr.write(`${err && err.stack ? err.stack : err}\n`);
405
+ process.exit(1);
406
+ });