u-foo 1.0.3 → 1.0.6

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 (91) hide show
  1. package/README.md +67 -8
  2. package/README.zh-CN.md +9 -7
  3. package/SKILLS/ufoo/SKILL.md +117 -0
  4. package/SKILLS/uinit/SKILL.md +73 -0
  5. package/SKILLS/ustatus/SKILL.md +36 -0
  6. package/bin/uclaude.js +13 -0
  7. package/bin/ucodex.js +13 -0
  8. package/bin/ufoo +9 -31
  9. package/bin/ufoo.js +13 -0
  10. package/modules/AGENTS.template.md +15 -7
  11. package/modules/bus/README.md +28 -23
  12. package/modules/bus/SKILLS/ubus/SKILL.md +18 -8
  13. package/modules/context/README.md +18 -40
  14. package/modules/context/SKILLS/uctx/SKILL.md +61 -1
  15. package/package.json +16 -4
  16. package/scripts/.archived/bash-to-js-migration/README.md +46 -0
  17. package/scripts/.archived/bash-to-js-migration/banner.sh +89 -0
  18. package/scripts/{bus-inject.sh → .archived/bash-to-js-migration/bus-inject.sh} +35 -3
  19. package/scripts/{bus.sh → .archived/bash-to-js-migration/bus.sh} +3 -1
  20. package/scripts/banner.sh +2 -89
  21. package/scripts/postinstall.js +59 -0
  22. package/src/agent/cliRunner.js +33 -5
  23. package/src/agent/internalRunner.js +78 -51
  24. package/src/agent/launcher.js +702 -0
  25. package/src/agent/notifier.js +200 -0
  26. package/src/agent/ptyRunner.js +377 -0
  27. package/src/agent/ptyWrapper.js +354 -0
  28. package/src/agent/readyDetector.js +159 -0
  29. package/src/agent/ufooAgent.js +37 -42
  30. package/src/bus/API_DESIGN.md +204 -0
  31. package/src/bus/activate.js +156 -0
  32. package/src/bus/daemon.js +308 -0
  33. package/src/bus/index.js +785 -0
  34. package/src/bus/inject.js +285 -0
  35. package/src/bus/message.js +302 -0
  36. package/src/bus/nickname.js +86 -0
  37. package/src/bus/queue.js +131 -0
  38. package/src/bus/shake.js +26 -0
  39. package/src/bus/subscriber.js +296 -0
  40. package/src/bus/utils.js +357 -0
  41. package/src/chat/index.js +1842 -249
  42. package/src/cli.js +658 -95
  43. package/src/config.js +9 -2
  44. package/src/context/decisions.js +314 -0
  45. package/src/context/doctor.js +183 -0
  46. package/src/context/index.js +38 -0
  47. package/src/daemon/index.js +749 -94
  48. package/src/daemon/ops.js +395 -51
  49. package/src/daemon/providerSessions.js +291 -0
  50. package/src/daemon/run.js +34 -1
  51. package/src/daemon/status.js +24 -7
  52. package/src/doctor/index.js +50 -0
  53. package/src/init/index.js +264 -0
  54. package/src/skills/index.js +159 -0
  55. package/src/status/index.js +252 -0
  56. package/src/terminal/detect.js +64 -0
  57. package/src/terminal/index.js +8 -0
  58. package/src/terminal/iterm2.js +126 -0
  59. package/src/ufoo/agentsStore.js +41 -0
  60. package/src/ufoo/paths.js +46 -0
  61. package/src/utils/banner.js +73 -0
  62. package/bin/uclaude +0 -65
  63. package/bin/ucodex +0 -65
  64. package/modules/bus/scripts/bus-alert.sh +0 -185
  65. package/modules/bus/scripts/bus-listen.sh +0 -117
  66. package/modules/context/ASSUMPTIONS.md +0 -7
  67. package/modules/context/CONSTRAINTS.md +0 -7
  68. package/modules/context/CONTEXT-STRUCTURE.md +0 -49
  69. package/modules/context/DECISION-PROTOCOL.md +0 -62
  70. package/modules/context/HANDOFF.md +0 -33
  71. package/modules/context/RULES.md +0 -15
  72. package/modules/context/SKILLS/README.md +0 -14
  73. package/modules/context/SYSTEM.md +0 -18
  74. package/modules/context/TEMPLATES/assumptions.md +0 -4
  75. package/modules/context/TEMPLATES/constraints.md +0 -4
  76. package/modules/context/TEMPLATES/decision.md +0 -16
  77. package/modules/context/TEMPLATES/project-context-readme.md +0 -6
  78. package/modules/context/TEMPLATES/system.md +0 -3
  79. package/modules/context/TEMPLATES/terminology.md +0 -4
  80. package/modules/context/TERMINOLOGY.md +0 -10
  81. /package/scripts/{bus-alert.sh → .archived/bash-to-js-migration/bus-alert.sh} +0 -0
  82. /package/scripts/{bus-autotrigger.sh → .archived/bash-to-js-migration/bus-autotrigger.sh} +0 -0
  83. /package/scripts/{bus-daemon.sh → .archived/bash-to-js-migration/bus-daemon.sh} +0 -0
  84. /package/scripts/{bus-listen.sh → .archived/bash-to-js-migration/bus-listen.sh} +0 -0
  85. /package/scripts/{context-decisions.sh → .archived/bash-to-js-migration/context-decisions.sh} +0 -0
  86. /package/scripts/{context-doctor.sh → .archived/bash-to-js-migration/context-doctor.sh} +0 -0
  87. /package/scripts/{context-lint.sh → .archived/bash-to-js-migration/context-lint.sh} +0 -0
  88. /package/scripts/{doctor.sh → .archived/bash-to-js-migration/doctor.sh} +0 -0
  89. /package/scripts/{init.sh → .archived/bash-to-js-migration/init.sh} +0 -0
  90. /package/scripts/{skills.sh → .archived/bash-to-js-migration/skills.sh} +0 -0
  91. /package/scripts/{status.sh → .archived/bash-to-js-migration/status.sh} +0 -0
@@ -0,0 +1,702 @@
1
+ const { spawn, spawnSync } = require("child_process");
2
+ const fs = require("fs");
3
+ const net = require("net");
4
+ const path = require("path");
5
+ const EventBus = require("../bus");
6
+ const { isAgentPidAlive } = require("../bus/utils");
7
+ const { showBanner } = require("../utils/banner");
8
+ const AgentNotifier = require("./notifier");
9
+ const { getUfooPaths } = require("../ufoo/paths");
10
+ const PtyWrapper = require("./ptyWrapper");
11
+ const ReadyDetector = require("./readyDetector");
12
+
13
+ function connectSocket(sockPath) {
14
+ return new Promise((resolve, reject) => {
15
+ const client = net.createConnection(sockPath, () => resolve(client));
16
+ client.on("error", reject);
17
+ });
18
+ }
19
+
20
+ async function connectWithRetry(sockPath, retries, delayMs) {
21
+ for (let i = 0; i < retries; i += 1) {
22
+ try {
23
+ // eslint-disable-next-line no-await-in-loop
24
+ const client = await connectSocket(sockPath);
25
+ return client;
26
+ } catch {
27
+ // eslint-disable-next-line no-await-in-loop
28
+ await new Promise((r) => setTimeout(r, delayMs));
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+
34
+ function normalizeTty(ttyPath) {
35
+ if (!ttyPath) return "";
36
+ const trimmed = String(ttyPath).trim();
37
+ if (!trimmed || trimmed === "not a tty") return "";
38
+ if (trimmed === "/dev/tty") return "";
39
+ return trimmed;
40
+ }
41
+
42
+ function getEnvTtyOverride() {
43
+ const override = normalizeTty(process.env.UFOO_TTY_OVERRIDE || "");
44
+ return override;
45
+ }
46
+
47
+ function detectTtyOnce() {
48
+ try {
49
+ const res = spawnSync("tty", {
50
+ stdio: [0, "pipe", "ignore"],
51
+ encoding: "utf8",
52
+ });
53
+ if (res && res.status === 0) {
54
+ const tty = normalizeTty(res.stdout || "");
55
+ if (tty) return tty;
56
+ }
57
+ } catch {
58
+ // ignore
59
+ }
60
+ return "";
61
+ }
62
+
63
+ async function detectTtyWithRetry(retries = 3, delayMs = 50) {
64
+ for (let i = 0; i < retries; i += 1) {
65
+ const tty = detectTtyOnce();
66
+ if (tty) return tty;
67
+ // eslint-disable-next-line no-await-in-loop
68
+ await new Promise((r) => setTimeout(r, delayMs));
69
+ }
70
+ return "";
71
+ }
72
+
73
+ /**
74
+ * 查找当前 TTY/tmux pane 对应的旧 session
75
+ * 用于在同一终端重启时自动恢复之前的会话
76
+ *
77
+ * 匹配规则:
78
+ * - tmux 模式:优先匹配 tmux_pane(每个 pane 有唯一 ID 如 %0, %1)
79
+ * - terminal 模式:匹配 tty(如 /dev/ttys001)
80
+ */
81
+ function findPreviousSession(cwd, agentType, tty, tmuxPane) {
82
+ if (!tty && !tmuxPane) return null;
83
+
84
+ try {
85
+ const agentsFile = getUfooPaths(cwd).agentsFile;
86
+ if (!fs.existsSync(agentsFile)) return null;
87
+
88
+ const data = JSON.parse(fs.readFileSync(agentsFile, "utf8"));
89
+ const agents = data.agents || {};
90
+
91
+ // 查找匹配的旧 session
92
+ for (const [id, meta] of Object.entries(agents)) {
93
+ if (!meta) continue;
94
+
95
+ // 必须是同类型 agent
96
+ if (meta.agent_type !== agentType) continue;
97
+
98
+ // tmux 模式:必须匹配 tmux_pane(更精确)
99
+ if (tmuxPane) {
100
+ if (meta.tmux_pane !== tmuxPane) continue;
101
+ } else if (tty) {
102
+ // terminal 模式:匹配 tty
103
+ if (meta.tty !== tty) continue;
104
+ } else {
105
+ continue;
106
+ }
107
+
108
+ // 检查旧进程是否已经退出
109
+ if (meta.pid && isAgentPidAlive(meta.pid)) {
110
+ // 旧进程还在运行,不能复用
111
+ continue;
112
+ }
113
+
114
+ // 找到了可以复用的旧 session
115
+ const parts = id.split(":");
116
+ if (parts.length !== 2) continue;
117
+
118
+ return {
119
+ sessionId: parts[1],
120
+ subscriberId: id,
121
+ nickname: meta.nickname || "",
122
+ providerSessionId: meta.provider_session_id || "",
123
+ };
124
+ }
125
+ } catch {
126
+ // ignore errors
127
+ }
128
+
129
+ return null;
130
+ }
131
+
132
+ function resolveLaunchMode() {
133
+ const explicit = process.env.UFOO_LAUNCH_MODE || "";
134
+ if (explicit) return explicit;
135
+ if (process.env.TMUX_PANE) return "tmux";
136
+ return "terminal";
137
+ }
138
+
139
+ /**
140
+ * Agent 启动器
141
+ * 统一处理 agent 启动流程:初始化、daemon 注册、banner、命令执行
142
+ */
143
+ class AgentLauncher {
144
+ constructor(agentType, command) {
145
+ this.agentType = agentType;
146
+ this.command = command;
147
+ this.cwd = process.cwd();
148
+ }
149
+
150
+ /**
151
+ * 确保 .ufoo 目录已初始化
152
+ */
153
+ async ensureInit() {
154
+ const paths = getUfooPaths(this.cwd);
155
+ const busDir = paths.busDir;
156
+
157
+ if (!fs.existsSync(busDir)) {
158
+ // 调用 ufoo init
159
+ spawnSync("ufoo", ["init", "--modules", "context,bus"], {
160
+ cwd: this.cwd,
161
+ stdio: "ignore",
162
+ });
163
+ }
164
+
165
+ // 检查 AGENTS.md 是否有 ufoo template
166
+ const agentsFile = path.join(this.cwd, "AGENTS.md");
167
+ if (fs.existsSync(agentsFile)) {
168
+ const content = fs.readFileSync(agentsFile, "utf8");
169
+ if (!content.includes("<!-- ufoo -->")) {
170
+ spawnSync("ufoo", ["init", "--modules", "context,bus"], {
171
+ cwd: this.cwd,
172
+ stdio: "ignore",
173
+ });
174
+ }
175
+ }
176
+ }
177
+
178
+ /**
179
+ * 解析已预注册的 subscriber(daemon 启动的情况)
180
+ */
181
+ async getPreRegistered() {
182
+ const subscriberId = process.env.UFOO_SUBSCRIBER_ID || "";
183
+ if (!subscriberId) return null;
184
+ const parts = subscriberId.split(":");
185
+ if (parts.length !== 2) return null;
186
+ if (parts[0] !== this.agentType) return null;
187
+ try {
188
+ const bus = new EventBus(this.cwd);
189
+ bus.loadBusData();
190
+ const meta = bus.subscriberManager.getSubscriber(subscriberId);
191
+ if (!meta || meta.status !== "active") return null;
192
+ const pidValue = Number.parseInt(meta.pid, 10);
193
+ if (Number.isFinite(pidValue) && pidValue > 0 && !isAgentPidAlive(pidValue)) {
194
+ return null;
195
+ }
196
+ if (meta.agent_type && meta.agent_type !== this.agentType) return null;
197
+ return {
198
+ subscriberId,
199
+ sessionId: parts[1],
200
+ nickname: meta.nickname || process.env.UFOO_NICKNAME || "",
201
+ preRegistered: true,
202
+ };
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * 确保 daemon 正在运行
210
+ */
211
+ async ensureDaemon() {
212
+ const pidFile = getUfooPaths(this.cwd).ufooDaemonPid;
213
+
214
+ if (fs.existsSync(pidFile)) {
215
+ const pidStr = fs.readFileSync(pidFile, "utf8").trim();
216
+ if (pidStr) {
217
+ const pid = parseInt(pidStr, 10);
218
+ try {
219
+ process.kill(pid, 0); // Check if alive
220
+ return "running";
221
+ } catch {
222
+ // Dead, start new
223
+ }
224
+ }
225
+ }
226
+
227
+ // Start daemon using correct command
228
+ spawnSync("ufoo", ["daemon", "start"], {
229
+ cwd: this.cwd,
230
+ stdio: "ignore",
231
+ detached: true,
232
+ });
233
+
234
+ // Wait for daemon socket to be ready
235
+ const sockFile = getUfooPaths(this.cwd).ufooSock;
236
+ for (let i = 0; i < 30; i++) {
237
+ if (fs.existsSync(sockFile)) {
238
+ try {
239
+ const stat = fs.statSync(sockFile);
240
+ if (stat.isSocket()) {
241
+ break;
242
+ }
243
+ } catch {
244
+ // Continue waiting
245
+ }
246
+ }
247
+ await new Promise((resolve) => setTimeout(resolve, 100));
248
+ }
249
+
250
+ return "started";
251
+ }
252
+
253
+ /**
254
+ * 通过 daemon socket 注册 agent
255
+ */
256
+ async registerWithDaemon(nickname) {
257
+ const sockFile = getUfooPaths(this.cwd).ufooSock;
258
+ const client = await connectWithRetry(sockFile, 25, 200);
259
+ if (!client) {
260
+ throw new Error("Failed to connect to ufoo daemon");
261
+ }
262
+
263
+ const ttyOverride = getEnvTtyOverride();
264
+ const tty = ttyOverride || await detectTtyWithRetry();
265
+ const tmuxPane = process.env.TMUX_PANE || "";
266
+ const launchMode = resolveLaunchMode();
267
+
268
+ // 只在 terminal/tmux 模式下查找旧 session(可见终端才需要恢复)
269
+ // internal 模式由 daemon 管理,不需要自动恢复
270
+ const shouldReuse = launchMode === "terminal" || launchMode === "tmux";
271
+ const previousSession = shouldReuse
272
+ ? findPreviousSession(this.cwd, this.agentType, tty, tmuxPane)
273
+ : null;
274
+
275
+ const req = {
276
+ type: "register_agent",
277
+ agentType: this.agentType,
278
+ nickname: nickname || (previousSession?.nickname) || "",
279
+ parentPid: process.pid,
280
+ launchMode,
281
+ tmuxPane,
282
+ tty,
283
+ skipProbe: process.env.UFOO_SKIP_SESSION_PROBE === "1",
284
+ // 传递旧 session 信息用于复用(仅 terminal/tmux 模式)
285
+ reuseSession: previousSession ? {
286
+ sessionId: previousSession.sessionId,
287
+ subscriberId: previousSession.subscriberId,
288
+ providerSessionId: previousSession.providerSessionId,
289
+ } : null,
290
+ };
291
+
292
+ return new Promise((resolve, reject) => {
293
+ let buffer = "";
294
+ let settled = false;
295
+ const timeout = setTimeout(() => {
296
+ if (settled) return;
297
+ settled = true;
298
+ try {
299
+ client.destroy();
300
+ } catch {
301
+ // ignore
302
+ }
303
+ reject(new Error("register_agent timeout"));
304
+ }, 8000);
305
+
306
+ const cleanup = () => {
307
+ clearTimeout(timeout);
308
+ client.removeAllListeners();
309
+ try {
310
+ client.end();
311
+ } catch {
312
+ // ignore
313
+ }
314
+ };
315
+
316
+ client.on("error", (err) => {
317
+ if (settled) return;
318
+ settled = true;
319
+ cleanup();
320
+ reject(err);
321
+ });
322
+
323
+ client.on("data", (data) => {
324
+ buffer += data.toString("utf8");
325
+ const lines = buffer.split(/\r?\n/);
326
+ buffer = lines.pop() || "";
327
+ for (const line of lines) {
328
+ if (!line.trim()) continue;
329
+ let payload;
330
+ try {
331
+ payload = JSON.parse(line);
332
+ } catch {
333
+ continue;
334
+ }
335
+ if (payload.type === "register_ok") {
336
+ if (settled) return;
337
+ settled = true;
338
+ cleanup();
339
+ resolve({
340
+ subscriberId: payload.subscriberId,
341
+ nickname: payload.nickname || nickname || "",
342
+ sessionId: (payload.subscriberId || "").split(":")[1] || "",
343
+ preRegistered: false,
344
+ });
345
+ return;
346
+ }
347
+ if (payload.type === "error") {
348
+ if (settled) return;
349
+ settled = true;
350
+ cleanup();
351
+ reject(new Error(payload.error || "register_agent failed"));
352
+ return;
353
+ }
354
+ }
355
+ });
356
+
357
+ client.write(`${JSON.stringify(req)}\n`);
358
+ });
359
+ }
360
+
361
+ /**
362
+ * 直接spawn启动(回退逻辑)
363
+ * @private
364
+ */
365
+ _spawnDirect(args, subscriberId) {
366
+ const child = spawn(this.command, args, {
367
+ cwd: this.cwd,
368
+ stdio: "inherit",
369
+ env: process.env,
370
+ });
371
+
372
+ child.on("error", (err) => {
373
+ console.error(`[${this.command}] Failed to start:`, err.message);
374
+ process.exit(1);
375
+ });
376
+
377
+ child.on("exit", async (code, signal) => {
378
+ // 清理 bus 状态
379
+ try {
380
+ const bus = new EventBus(this.cwd);
381
+ bus.loadBusData();
382
+ await bus.subscriberManager.leave(subscriberId);
383
+ bus.saveBusData();
384
+ } catch {
385
+ // ignore cleanup errors
386
+ }
387
+
388
+ if (signal) {
389
+ console.log(`\n[${this.command}] killed by signal ${signal}`);
390
+ }
391
+ process.exit(code || 0);
392
+ });
393
+
394
+ return child;
395
+ }
396
+
397
+ /**
398
+ * 启动 agent
399
+ */
400
+ async launch(args) {
401
+ try {
402
+ // 1. 确保初始化
403
+ await this.ensureInit();
404
+
405
+ // 2. 确保 daemon 运行
406
+ const daemonStatus = await this.ensureDaemon();
407
+
408
+ // 3. 使用 daemon 注册(或复用预注册)
409
+ const preRegistered = await this.getPreRegistered();
410
+ const nickname = process.env.UFOO_NICKNAME || "";
411
+ const result = preRegistered || await this.registerWithDaemon(nickname);
412
+
413
+ const subscriberId = result.subscriberId;
414
+ const sessionId = result.sessionId || (subscriberId.split(":")[1] || "");
415
+ const finalNickname = result.nickname || nickname || "";
416
+
417
+ // 4. 更新环境变量(供子进程/后续使用)
418
+ if (subscriberId) process.env.UFOO_SUBSCRIBER_ID = subscriberId;
419
+ if (finalNickname) process.env.UFOO_NICKNAME = finalNickname;
420
+
421
+ // 5. 显示 banner
422
+ showBanner({
423
+ agentType: this.agentType,
424
+ sessionId,
425
+ nickname: finalNickname,
426
+ daemonStatus,
427
+ });
428
+
429
+ // 6. 启动消息通知监听器
430
+ const notifier = new AgentNotifier(this.cwd, subscriberId);
431
+ notifier.start();
432
+
433
+ // 7. 启动命令(PTY wrapper或直接spawn)
434
+
435
+ // 7.1 PTY启用条件(显式开关 + 自动检测)
436
+ let shouldUsePty = false;
437
+
438
+ // 显式开关(优先级最高)
439
+ if (process.env.UFOO_DISABLE_PTY === "1") {
440
+ shouldUsePty = false; // 强制回退spawn (CI/回滚)
441
+ } else if (process.env.UFOO_FORCE_PTY === "1") {
442
+ shouldUsePty = true; // 强制使用PTY (测试/调试)
443
+ } else {
444
+ // 自动检测:Terminal模式 + 非tmux + 非internal
445
+ shouldUsePty =
446
+ process.stdin.isTTY &&
447
+ process.stdout.isTTY &&
448
+ !process.env.TMUX && // tmux已有PTY,避免套嵌
449
+ !process.env.UFOO_INTERNAL_AGENT; // internal有专用runner(当前阶段)
450
+ }
451
+
452
+ // 7.2 使用PTY wrapper或回退到spawn
453
+ if (shouldUsePty) {
454
+ // 使用PTY wrapper
455
+ try {
456
+ const wrapper = new PtyWrapper(this.command, args, {
457
+ cwd: this.cwd,
458
+ env: process.env,
459
+ // 未来扩展:ioAdapter: new TerminalIOAdapter()
460
+ });
461
+
462
+ // 启用日志记录(JSONL)
463
+ const logFile = path.join(
464
+ getUfooPaths(this.cwd).runDir,
465
+ `${this.agentType}-${sessionId}-io.jsonl`
466
+ );
467
+ wrapper.enableLogging(logFile);
468
+
469
+ // 启用Ready检测(监控agent初始化状态)
470
+ const readyDetector = new ReadyDetector(this.agentType);
471
+ wrapper.enableMonitoring((data) => {
472
+ readyDetector.processOutput(data);
473
+ });
474
+
475
+ // 当检测到agent ready时,通知daemon可以提前inject probe
476
+ const daemonSockPath = getUfooPaths(this.cwd).ufooSock;
477
+ readyDetector.onReady(async () => {
478
+ const startTime = Date.now();
479
+ try {
480
+ const daemonSock = await connectWithRetry(daemonSockPath, 3, 100);
481
+ if (daemonSock) {
482
+ daemonSock.write(`${JSON.stringify({
483
+ type: "agent_ready",
484
+ subscriberId,
485
+ })}\n`);
486
+ daemonSock.end();
487
+
488
+ const notifyTime = Date.now() - startTime;
489
+ if (process.env.UFOO_DEBUG) {
490
+ console.error(`[ready] notified daemon in ${notifyTime}ms`);
491
+ }
492
+ } else {
493
+ if (process.env.UFOO_DEBUG) {
494
+ console.error(`[ready] failed to connect to daemon after retries, will use fallback delay`);
495
+ }
496
+ }
497
+ } catch (err) {
498
+ // 忽略通知失败(probe会通过fallback延迟执行)
499
+ if (process.env.UFOO_DEBUG) {
500
+ console.error(`[ready] daemon notification error: ${err.message}, will use fallback delay`);
501
+ }
502
+ }
503
+ });
504
+
505
+ // Fallback:如果10秒后还没检测到ready,强制标记为ready
506
+ const forceReadyTimer = setTimeout(() => {
507
+ readyDetector.forceReady();
508
+ }, 10000);
509
+
510
+ // 设置退出回调(复用清理逻辑)
511
+ wrapper.onExit = async ({ exitCode, signal }) => {
512
+ // 清理forceReady timer
513
+ clearTimeout(forceReadyTimer);
514
+
515
+ // 清理 bus 状态
516
+ try {
517
+ const bus = new EventBus(this.cwd);
518
+ bus.loadBusData();
519
+ await bus.subscriberManager.leave(subscriberId);
520
+ bus.saveBusData();
521
+ } catch {
522
+ // ignore cleanup errors
523
+ }
524
+
525
+ if (signal) {
526
+ console.log(`\n[${this.command}] killed by signal ${signal}`);
527
+ }
528
+ process.exit(exitCode || 0);
529
+ };
530
+
531
+ // 启动PTY
532
+ wrapper.spawn();
533
+ wrapper.attachStreams(process.stdin, process.stdout, process.stderr);
534
+
535
+ // 启动inject监听socket(用于外部注入命令到PTY)
536
+ const injectSockPath = path.join(
537
+ getUfooPaths(this.cwd).busQueuesDir,
538
+ subscriberId.replace(/:/g, "_"),
539
+ "inject.sock"
540
+ );
541
+ // 确保目录存在
542
+ const injectSockDir = path.dirname(injectSockPath);
543
+ if (!fs.existsSync(injectSockDir)) {
544
+ fs.mkdirSync(injectSockDir, { recursive: true });
545
+ }
546
+ // 清理旧socket
547
+ if (fs.existsSync(injectSockPath)) {
548
+ fs.unlinkSync(injectSockPath);
549
+ }
550
+
551
+ // Output subscribers for TTY view streaming
552
+ const outputSubscribers = new Set();
553
+
554
+ // In-memory ring buffer of recent PTY output for replay on subscribe
555
+ const OUTPUT_BUFFER_MAX = 256 * 1024; // 256KB
556
+ let outputRingBuffer = "";
557
+
558
+ // Chain monitor callback to forward output to subscribers
559
+ const originalMonitor = wrapper.monitor;
560
+ wrapper.monitor = {
561
+ onOutput: (data) => {
562
+ // Call original monitor (ReadyDetector)
563
+ if (originalMonitor && originalMonitor.onOutput) {
564
+ originalMonitor.onOutput(data);
565
+ }
566
+ // Accumulate in ring buffer
567
+ const text = Buffer.from(data).toString("utf8");
568
+ outputRingBuffer += text;
569
+ if (outputRingBuffer.length > OUTPUT_BUFFER_MAX) {
570
+ outputRingBuffer = outputRingBuffer.slice(-OUTPUT_BUFFER_MAX);
571
+ }
572
+ // Forward to all output subscribers
573
+ if (outputSubscribers.size > 0) {
574
+ const msg = JSON.stringify({ type: "output", data: text, encoding: "utf8" }) + "\n";
575
+ for (const sub of outputSubscribers) {
576
+ try {
577
+ sub.write(msg);
578
+ } catch {
579
+ outputSubscribers.delete(sub);
580
+ }
581
+ }
582
+ }
583
+ },
584
+ };
585
+
586
+ const injectServer = net.createServer((client) => {
587
+ let buffer = "";
588
+ client.on("data", (data) => {
589
+ buffer += data.toString("utf8");
590
+ const lines = buffer.split("\n");
591
+ buffer = lines.pop() || "";
592
+
593
+ for (const line of lines) {
594
+ if (!line.trim()) continue;
595
+ try {
596
+ const req = JSON.parse(line);
597
+ if (req.type === "inject" && req.command) {
598
+ // 注入命令到PTY(带延迟确保输入完成)
599
+ wrapper.write(req.command);
600
+ setTimeout(() => {
601
+ wrapper.write("\x1b");
602
+ setTimeout(() => {
603
+ wrapper.write("\r");
604
+ }, 100);
605
+ }, 200);
606
+ client.write(JSON.stringify({ ok: true }) + "\n");
607
+ if (wrapper.logger) {
608
+ const logEntry = {
609
+ ts: Date.now(),
610
+ dir: "in",
611
+ data: { text: req.command, encoding: "utf8", size: req.command.length },
612
+ source: "inject",
613
+ };
614
+ wrapper.logger.write(JSON.stringify(logEntry) + "\n");
615
+ }
616
+ } else if (req.type === "raw" && req.data) {
617
+ // Raw PTY write (no Enter appended) - for TTY view passthrough
618
+ wrapper.write(req.data);
619
+ client.write(JSON.stringify({ ok: true }) + "\n");
620
+ } else if (req.type === "resize" && req.cols && req.rows) {
621
+ // Resize PTY - for TTY view viewport adjustment
622
+ if (wrapper.pty && !wrapper.pty._closed) {
623
+ wrapper.pty.resize(req.cols, req.rows);
624
+ }
625
+ client.write(JSON.stringify({ ok: true }) + "\n");
626
+ } else if (req.type === "subscribe") {
627
+ // Subscribe to PTY output stream for TTY view
628
+ outputSubscribers.add(client);
629
+ client.write(JSON.stringify({ type: "subscribed", ok: true }) + "\n");
630
+ // Replay from in-memory ring buffer
631
+ if (outputRingBuffer.length > 0) {
632
+ client.write(JSON.stringify({ type: "replay", data: outputRingBuffer, encoding: "utf8" }) + "\n");
633
+ }
634
+ } else {
635
+ client.write(JSON.stringify({ ok: false, error: "invalid request" }) + "\n");
636
+ }
637
+ } catch (err) {
638
+ client.write(JSON.stringify({ ok: false, error: err.message }) + "\n");
639
+ }
640
+ }
641
+ });
642
+ client.on("error", () => {
643
+ outputSubscribers.delete(client);
644
+ });
645
+ client.on("close", () => {
646
+ outputSubscribers.delete(client);
647
+ });
648
+ });
649
+
650
+ injectServer.listen(injectSockPath, () => {
651
+ if (process.env.UFOO_DEBUG) {
652
+ console.error(`[inject] listening on ${injectSockPath}`);
653
+ }
654
+ });
655
+
656
+ injectServer.on("error", (err) => {
657
+ if (process.env.UFOO_DEBUG) {
658
+ console.error(`[inject] server error: ${err.message}`);
659
+ }
660
+ });
661
+
662
+ // 记录inject socket路径到cleanup
663
+ const cleanupInjectServer = () => {
664
+ // Close all output subscribers
665
+ for (const sub of outputSubscribers) {
666
+ try { sub.destroy(); } catch { /* ignore */ }
667
+ }
668
+ outputSubscribers.clear();
669
+ try {
670
+ injectServer.close();
671
+ if (fs.existsSync(injectSockPath)) {
672
+ fs.unlinkSync(injectSockPath);
673
+ }
674
+ } catch {
675
+ // ignore
676
+ }
677
+ };
678
+
679
+ // 更新onExit以清理inject server
680
+ const originalOnExit = wrapper.onExit;
681
+ wrapper.onExit = async (exitInfo) => {
682
+ cleanupInjectServer();
683
+ if (originalOnExit) {
684
+ await originalOnExit(exitInfo);
685
+ }
686
+ };
687
+ } catch (err) {
688
+ console.error(`[PTY] Failed to start, falling back to spawn:`, err.message);
689
+ this._spawnDirect(args, subscriberId);
690
+ }
691
+ } else {
692
+ // 非PTY环境:tmux、internal、管道、显式禁用等
693
+ this._spawnDirect(args, subscriberId);
694
+ }
695
+ } catch (err) {
696
+ console.error(`[${this.command}] Error:`, err.message);
697
+ process.exit(1);
698
+ }
699
+ }
700
+ }
701
+
702
+ module.exports = AgentLauncher;