u-foo 1.0.2 → 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 +1995 -263
  42. package/src/cli.js +658 -95
  43. package/src/config.js +23 -4
  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 +38 -3
  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
package/src/chat/index.js CHANGED
@@ -1,10 +1,15 @@
1
1
  const net = require("net");
2
2
  const path = require("path");
3
3
  const blessed = require("blessed");
4
- const { spawn, spawnSync } = require("child_process");
4
+ const { spawn, spawnSync, execSync } = require("child_process");
5
5
  const fs = require("fs");
6
- const { loadConfig, saveConfig, normalizeLaunchMode } = require("../config");
6
+ const { loadConfig, saveConfig, normalizeLaunchMode, normalizeAgentProvider } = require("../config");
7
7
  const { socketPath, isRunning } = require("../daemon");
8
+ const UfooInit = require("../init");
9
+ const EventBus = require("../bus");
10
+ const AgentActivator = require("../bus/activate");
11
+ const { getUfooPaths } = require("../ufoo/paths");
12
+ const { subscriberToSafeName } = require("../bus/utils");
8
13
 
9
14
  function connectSocket(sockPath) {
10
15
  return new Promise((resolve, reject) => {
@@ -19,16 +24,28 @@ function resolveProjectFile(projectRoot, relativePath, fallbackRelativePath) {
19
24
  return path.join(__dirname, "..", "..", fallbackRelativePath);
20
25
  }
21
26
 
22
- function startDaemon(projectRoot) {
27
+ function startDaemon(projectRoot, options = {}) {
23
28
  const daemonBin = resolveProjectFile(projectRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
29
+ const env = options.forceResume
30
+ ? { ...process.env, UFOO_FORCE_RESUME: "1" }
31
+ : process.env;
24
32
  const child = spawn(process.execPath, [daemonBin, "daemon", "--start"], {
25
33
  detached: true,
26
34
  stdio: "ignore",
27
35
  cwd: projectRoot,
36
+ env,
28
37
  });
29
38
  child.unref();
30
39
  }
31
40
 
41
+ function stopDaemon(projectRoot) {
42
+ const daemonBin = resolveProjectFile(projectRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
43
+ spawnSync(process.execPath, [daemonBin, "daemon", "--stop"], {
44
+ stdio: "ignore",
45
+ cwd: projectRoot,
46
+ });
47
+ }
48
+
32
49
  async function connectWithRetry(sockPath, retries, delayMs) {
33
50
  for (let i = 0; i < retries; i += 1) {
34
51
  try {
@@ -44,29 +61,104 @@ async function connectWithRetry(sockPath, retries, delayMs) {
44
61
  }
45
62
 
46
63
  async function runChat(projectRoot) {
47
- if (!fs.existsSync(path.join(projectRoot, ".ufoo"))) {
48
- const initScript = resolveProjectFile(projectRoot, path.join("scripts", "init.sh"), path.join("scripts", "init.sh"));
49
- spawnSync("bash", [initScript, "--modules", "context,bus", "--project", projectRoot], {
50
- stdio: "inherit",
51
- });
64
+ if (!fs.existsSync(getUfooPaths(projectRoot).ufooDir)) {
65
+ const repoRoot = path.join(__dirname, "..", "..");
66
+ const init = new UfooInit(repoRoot);
67
+ await init.init({ modules: "context,bus", project: projectRoot });
68
+ }
69
+
70
+ // Ensure subscriber ID exists for chat (persistent across restarts)
71
+ if (!process.env.UFOO_SUBSCRIBER_ID) {
72
+ const crypto = require("crypto");
73
+ const sessionFile = path.join(getUfooPaths(projectRoot).ufooDir, "chat", "session-id.txt");
74
+ const sessionDir = path.dirname(sessionFile);
75
+ fs.mkdirSync(sessionDir, { recursive: true });
76
+
77
+ let sessionId;
78
+ if (fs.existsSync(sessionFile)) {
79
+ sessionId = fs.readFileSync(sessionFile, "utf8").trim();
80
+ } else {
81
+ sessionId = crypto.randomBytes(4).toString("hex");
82
+ fs.writeFileSync(sessionFile, sessionId, "utf8");
83
+ }
84
+ // Chat 模式默认使用 claude-code 类型
85
+ process.env.UFOO_SUBSCRIBER_ID = `claude-code:${sessionId}`;
52
86
  }
87
+
53
88
  if (!isRunning(projectRoot)) {
54
89
  startDaemon(projectRoot);
55
90
  }
56
91
 
92
+ const daemonBin = resolveProjectFile(projectRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
57
93
  const sock = socketPath(projectRoot);
58
- let client = await connectWithRetry(sock, 25, 200);
59
- if (!client) {
60
- // Retry once with a fresh daemon start and longer wait.
61
- if (!isRunning(projectRoot)) {
62
- startDaemon(projectRoot);
94
+ let client = null;
95
+ let reconnectPromise = null;
96
+ let exitRequested = false;
97
+ let connectionLostNotified = false;
98
+ const pendingRequests = [];
99
+ const MAX_PENDING_REQUESTS = 50;
100
+
101
+ const connectClient = async () => {
102
+ let newClient = await connectWithRetry(sock, 25, 200);
103
+ if (!newClient) {
104
+ // Retry once with a fresh daemon start and longer wait.
105
+ if (!isRunning(projectRoot)) {
106
+ startDaemon(projectRoot);
107
+ // Wait for daemon to write PID file and create socket
108
+ await new Promise(r => setTimeout(r, 1000));
109
+ }
110
+ newClient = await connectWithRetry(sock, 50, 200);
111
+ }
112
+ return newClient;
113
+ };
114
+
115
+ function enqueueRequest(req) {
116
+ if (!req || req.type === "status") return;
117
+ pendingRequests.push(req);
118
+ if (pendingRequests.length > MAX_PENDING_REQUESTS) {
119
+ pendingRequests.shift();
120
+ }
121
+ }
122
+
123
+ function flushPendingRequests() {
124
+ if (!client || client.destroyed) return;
125
+ while (pendingRequests.length > 0) {
126
+ const req = pendingRequests.shift();
127
+ client.write(`${JSON.stringify(req)}\n`);
128
+ }
129
+ }
130
+
131
+ async function ensureConnected() {
132
+ if (client && !client.destroyed) return true;
133
+ if (exitRequested) return false;
134
+ if (reconnectPromise) return reconnectPromise;
135
+ queueStatusLine("Reconnecting to daemon");
136
+ logMessage("status", "{magenta-fg}⚙{/magenta-fg} Reconnecting to daemon...");
137
+ reconnectPromise = (async () => {
138
+ const newClient = await connectClient();
139
+ if (!newClient) {
140
+ resolveStatusLine("{red-fg}✗{/red-fg} Daemon offline");
141
+ logMessage("error", "{red-fg}✗{/red-fg} Failed to reconnect to daemon");
142
+ return false;
143
+ }
144
+ attachClient(newClient);
145
+ connectionLostNotified = false;
146
+ resolveStatusLine("{green-fg}✓{/green-fg} Daemon reconnected");
147
+ requestStatus();
148
+ return true;
149
+ })();
150
+ try {
151
+ return await reconnectPromise;
152
+ } finally {
153
+ reconnectPromise = null;
63
154
  }
64
- client = await connectWithRetry(sock, 50, 200);
65
155
  }
156
+
157
+ client = await connectClient();
66
158
  if (!client) {
67
159
  // Check if daemon failed to start
68
160
  if (!isRunning(projectRoot)) {
69
- const logFile = path.join(projectRoot, ".ufoo", "run", "ufoo-daemon.log");
161
+ const logFile = getUfooPaths(projectRoot).ufooDaemonLog;
70
162
  // eslint-disable-next-line no-console
71
163
  console.error("Failed to start ufoo daemon. Check logs at:", logFile);
72
164
  throw new Error("Daemon failed to start. Check the daemon log for details.");
@@ -78,16 +170,28 @@ async function runChat(projectRoot) {
78
170
  smartCSR: true,
79
171
  title: "ufoo chat",
80
172
  fullUnicode: true,
81
- // Allow terminal native copy by not fully grabbing mouse
82
- // Hold Option/Alt to use native selection in most terminals
173
+ // Toggle mouse at runtime to balance copy vs scroll
83
174
  sendFocus: true,
84
175
  mouse: false,
85
176
  // Allow Ctrl+C to exit even when input grabs keys
86
177
  ignoreLocked: ["C-c"],
87
178
  });
179
+ // Prefer normal buffer for reliable terminal selection/copy
180
+ if (screen.program && typeof screen.program.normalBuffer === "function") {
181
+ screen.program.normalBuffer();
182
+ if (screen.program.put && typeof screen.program.put.keypad_local === "function") {
183
+ screen.program.put.keypad_local();
184
+ }
185
+ if (typeof screen.program.clear === "function") {
186
+ screen.program.clear();
187
+ screen.program.cup(0, 0);
188
+ }
189
+ }
88
190
 
89
191
  const config = loadConfig(projectRoot);
90
192
  let launchMode = config.launchMode;
193
+ let agentProvider = config.agentProvider;
194
+ let autoResume = config.autoResume !== false;
91
195
 
92
196
  // Dynamic input height settings
93
197
  // Layout: topLine(1) + content + bottomLine(1) + dashboard(1)
@@ -106,11 +210,11 @@ async function runChat(projectRoot) {
106
210
  scrollable: true,
107
211
  alwaysScroll: true,
108
212
  scrollback: 10000,
109
- scrollbar: { ch: "│", style: { fg: "cyan" } },
213
+ scrollbar: null,
110
214
  keys: true,
111
215
  vi: true,
112
- // Enable mouse wheel scrolling in log area (use Option/Alt for native selection)
113
- mouse: true,
216
+ // Mouse handled globally (toggleable) to keep copy working
217
+ mouse: false,
114
218
  });
115
219
 
116
220
  // Status line just above input
@@ -128,7 +232,7 @@ async function runChat(projectRoot) {
128
232
  const bannerText = `{bold}UFOO{/bold} · Multi-Agent Manager{|}v${pkg.version}`;
129
233
  statusLine.setContent(bannerText);
130
234
 
131
- const historyDir = path.join(projectRoot, ".ufoo", "chat");
235
+ const historyDir = path.join(getUfooPaths(projectRoot).ufooDir, "chat");
132
236
  const historyFile = path.join(historyDir, "history.jsonl");
133
237
  const inputHistoryFile = path.join(historyDir, "input-history.jsonl");
134
238
 
@@ -142,13 +246,19 @@ async function runChat(projectRoot) {
142
246
  let lastLogType = null;
143
247
  let hasLoggedAny = false;
144
248
 
145
- function shouldSpace(type) {
146
- return SPACED_TYPES.has(type);
249
+ function shouldSpace(type, text) {
250
+ if (SPACED_TYPES.has(type)) return true;
251
+ if (text && /daemon/i.test(text)) return true;
252
+ return false;
147
253
  }
148
254
 
149
255
  function writeSpacer(writeHistory) {
150
256
  if (lastLogWasSpacer || !hasLoggedAny) return;
151
- logBox.log(" ");
257
+ try {
258
+ logBox.log(" ");
259
+ } catch {
260
+ // ignore rendering errors
261
+ }
152
262
  if (writeHistory) {
153
263
  appendHistory({
154
264
  ts: new Date().toISOString(),
@@ -163,15 +273,16 @@ async function runChat(projectRoot) {
163
273
  }
164
274
 
165
275
  function recordLog(type, text, meta = {}, writeHistory = true) {
166
- if (type !== "spacer" && shouldSpace(type)) {
276
+ const lineText = text == null ? "" : String(text);
277
+ if (type !== "spacer" && shouldSpace(type, text)) {
167
278
  writeSpacer(writeHistory);
168
279
  }
169
- logBox.log(text);
280
+ appendToLogBox(lineText);
170
281
  if (writeHistory) {
171
282
  appendHistory({
172
283
  ts: new Date().toISOString(),
173
284
  type,
174
- text,
285
+ text: lineText,
175
286
  meta,
176
287
  });
177
288
  }
@@ -184,6 +295,39 @@ async function runChat(projectRoot) {
184
295
  recordLog(type, text, meta, true);
185
296
  }
186
297
 
298
+ // Prevent blessed tag parsing crashes from untrusted text.
299
+ // blessed parses `{...}` as style tags; certain inputs like `{foo,bar}` can
300
+ // trigger a blessed bug (Program._attr on unknown comma/semicolon parts).
301
+ //
302
+ // Workaround: blessed@0.1.81 has a bug where tags containing comma/semicolon
303
+ // (e.g. `{foo,bar}`) can crash when the log widget reparses cached lines.
304
+ // We proactively neutralize any such tag-like sequences so they don't match
305
+ // blessed's tag regex on subsequent reparses.
306
+ function neutralizeBlessedCommaTags(text) {
307
+ if (text == null) return "";
308
+ const raw = String(text);
309
+ if (!raw.includes("{")) return raw;
310
+ return raw.replace(/\{\/?[\w\-,;!#]*[;,][\w\-,;!#]*\}/g, (m) => {
311
+ // Insert a space after separators so `{foo,bar}` becomes `{foo, bar}`.
312
+ // This stops blessed from treating it as a tag on future reparses.
313
+ const inner = m.slice(1, -1).replace(/[,;]/g, (ch) => `${ch} `);
314
+ return `{${inner}}`;
315
+ });
316
+ }
317
+
318
+ function escapeBlessed(text) {
319
+ if (text == null) return "{escape}{/escape}";
320
+ const raw = neutralizeBlessedCommaTags(text);
321
+ // Avoid allowing payload to terminate escape mode.
322
+ const safe = raw.replace(/\{\/escape\}/g, "{open}/escape{close}");
323
+ return `{escape}${safe}{/escape}`;
324
+ }
325
+
326
+ function appendToLogBox(text) {
327
+ // Avoid a blessed render-time crash for `{foo,bar}`-like tag sequences.
328
+ logBox.log(neutralizeBlessedCommaTags(text));
329
+ }
330
+
187
331
  function loadHistory(limit = 2000) {
188
332
  try {
189
333
  const lines = fs.readFileSync(historyFile, "utf8").trim().split(/\r?\n/).filter(Boolean);
@@ -197,7 +341,7 @@ async function runChat(projectRoot) {
197
341
  }
198
342
  if (!item.text) continue;
199
343
  if (hasSpacer) {
200
- logBox.log(item.text);
344
+ appendToLogBox(item.text);
201
345
  lastLogWasSpacer = false;
202
346
  lastLogType = item.type || null;
203
347
  hasLoggedAny = true;
@@ -238,16 +382,69 @@ async function runChat(projectRoot) {
238
382
  const pendingStatusLines = [];
239
383
  const busStatusQueue = [];
240
384
  let primaryStatusText = bannerText;
385
+ let primaryStatusPending = false;
386
+ const shimmerStart = Date.now();
387
+ let statusAnimationTimer = null;
388
+ const STATUS_ANIM_FRAME_MS = 50;
389
+ const SHIMMER_PADDING = 10;
390
+ const SHIMMER_BAND_HALF_WIDTH = 5;
391
+ const SHIMMER_SWEEP_MS = 2000;
392
+ const SPINNER_PERIOD_MS = 600;
241
393
 
242
394
  function formatProcessingText(text) {
243
395
  if (!text) return text;
244
396
  if (text.includes("{")) return text;
245
397
  if (!/processing/i.test(text)) return text;
246
- return `{yellow-fg}⏳{/yellow-fg} ${text}`;
398
+ return text;
399
+ }
400
+
401
+ function shimmerText(text, nowMs) {
402
+ if (!text) return "";
403
+ if (text.includes("{")) return text;
404
+ const chars = Array.from(text);
405
+ const period = chars.length + SHIMMER_PADDING * 2;
406
+ const pos =
407
+ Math.floor(((nowMs - shimmerStart) % SHIMMER_SWEEP_MS) / SHIMMER_SWEEP_MS * period);
408
+ let out = "";
409
+ for (let i = 0; i < chars.length; i += 1) {
410
+ const iPos = i + SHIMMER_PADDING;
411
+ const dist = Math.abs(iPos - pos);
412
+ let intensity = 0;
413
+ if (dist <= SHIMMER_BAND_HALF_WIDTH) {
414
+ const x = Math.PI * (dist / SHIMMER_BAND_HALF_WIDTH);
415
+ intensity = 0.5 * (1 + Math.cos(x));
416
+ }
417
+ const ch = chars[i];
418
+ if (intensity < 0.2) {
419
+ out += `{gray-fg}${ch}{/gray-fg}`;
420
+ } else if (intensity < 0.6) {
421
+ out += ch;
422
+ } else {
423
+ out += `{bold}{white-fg}${ch}{/white-fg}{/bold}`;
424
+ }
425
+ }
426
+ return out;
427
+ }
428
+
429
+ function spinnerFrame(nowMs) {
430
+ const on = Math.floor((nowMs - shimmerStart) / SPINNER_PERIOD_MS) % 2 === 0;
431
+ return on
432
+ ? "{white-fg}•{/white-fg}"
433
+ : "{gray-fg}◦{/gray-fg}";
247
434
  }
248
435
 
249
- function renderStatusLine() {
436
+ function renderPendingStatus(text, nowMs) {
437
+ const spinner = spinnerFrame(nowMs);
438
+ const shimmer = shimmerText(text, nowMs);
439
+ if (!shimmer) return spinner;
440
+ return `${spinner} ${shimmer}`;
441
+ }
442
+
443
+ function renderStatusLine(nowMs = Date.now()) {
250
444
  let content = primaryStatusText || "";
445
+ if (primaryStatusPending) {
446
+ content = renderPendingStatus(primaryStatusText, nowMs);
447
+ }
251
448
  if (busStatusQueue.length > 0) {
252
449
  const extra = busStatusQueue.length > 1
253
450
  ? ` {gray-fg}(+${busStatusQueue.length - 1}){/gray-fg}`
@@ -260,16 +457,31 @@ async function runChat(projectRoot) {
260
457
  statusLine.setContent(content);
261
458
  }
262
459
 
263
- function setPrimaryStatus(text) {
460
+ function updateStatusAnimation() {
461
+ if (primaryStatusPending && !statusAnimationTimer) {
462
+ statusAnimationTimer = setInterval(() => {
463
+ if (!primaryStatusPending) return;
464
+ renderStatusLine(Date.now());
465
+ screen.render();
466
+ }, STATUS_ANIM_FRAME_MS);
467
+ } else if (!primaryStatusPending && statusAnimationTimer) {
468
+ clearInterval(statusAnimationTimer);
469
+ statusAnimationTimer = null;
470
+ }
471
+ }
472
+
473
+ function setPrimaryStatus(text, options = {}) {
264
474
  primaryStatusText = text || "";
475
+ primaryStatusPending = Boolean(options.pending);
476
+ updateStatusAnimation();
265
477
  renderStatusLine();
266
478
  }
267
479
 
268
480
  function queueStatusLine(text) {
269
- const formatted = formatProcessingText(text);
270
- pendingStatusLines.push(formatted);
481
+ let raw = text || "";
482
+ pendingStatusLines.push(raw);
271
483
  if (pendingStatusLines.length === 1) {
272
- setPrimaryStatus(formatted);
484
+ setPrimaryStatus(raw, { pending: true });
273
485
  screen.render();
274
486
  }
275
487
  }
@@ -279,17 +491,18 @@ async function runChat(projectRoot) {
279
491
  pendingStatusLines.shift();
280
492
  }
281
493
  if (pendingStatusLines.length > 0) {
282
- setPrimaryStatus(pendingStatusLines[0]);
494
+ setPrimaryStatus(pendingStatusLines[0], { pending: true });
283
495
  } else {
284
- setPrimaryStatus(text || "");
496
+ setPrimaryStatus(text || "", { pending: false });
285
497
  }
286
498
  screen.render();
287
499
  }
288
500
 
289
501
  function enqueueBusStatus(item) {
290
502
  if (!item || !item.text) return;
291
- const key = item.key || item.text;
292
- const formatted = formatProcessingText(item.text);
503
+ const rawText = item.text == null ? "" : String(item.text);
504
+ const key = item.key || rawText;
505
+ const formatted = escapeBlessed(formatProcessingText(rawText));
293
506
  const existing = busStatusQueue.find((entry) => entry.key === key);
294
507
  if (existing) {
295
508
  existing.text = formatted;
@@ -301,7 +514,8 @@ async function runChat(projectRoot) {
301
514
 
302
515
  function resolveBusStatus(item) {
303
516
  if (!item) return;
304
- const key = item.key || item.text;
517
+ const rawText = item.text == null ? "" : String(item.text);
518
+ const key = item.key || rawText;
305
519
  let index = -1;
306
520
  if (key) {
307
521
  index = busStatusQueue.findIndex((entry) => entry.key === key);
@@ -322,6 +536,7 @@ async function runChat(projectRoot) {
322
536
  width: "100%",
323
537
  height: 0,
324
538
  hidden: true,
539
+ wrap: false,
325
540
  border: {
326
541
  type: "line",
327
542
  top: true,
@@ -354,6 +569,15 @@ async function runChat(projectRoot) {
354
569
  tags: true,
355
570
  });
356
571
 
572
+ // Agent TTY view state
573
+ let currentView = "main"; // "main" | "agent"
574
+ let viewingAgent = null; // subscriber ID of agent being viewed
575
+ let agentOutputClient = null; // net.Socket connected to inject.sock
576
+ let agentOutputBuffer = ""; // partial line buffer for output parsing
577
+ let agentInputClient = null; // net.Socket for sending raw input
578
+ let _detachedChildren = null; // Screen children saved during agent view
579
+ let agentInputSuppressUntil = 0; // Suppress input forwarding until this timestamp
580
+
357
581
  // Bottom border line for input area (above dashboard)
358
582
  const inputBottomLine = blessed.line({
359
583
  parent: screen,
@@ -401,6 +625,8 @@ async function runChat(projectRoot) {
401
625
  // Add cursor position tracking
402
626
  let cursorPos = 0;
403
627
  let preferredCol = null;
628
+ const unicode = blessed.unicode;
629
+ const wideRegex = new RegExp(unicode.chars.all.source);
404
630
 
405
631
  // Get inner width
406
632
  function getInnerWidth() {
@@ -421,13 +647,86 @@ async function runChat(projectRoot) {
421
647
  return 1;
422
648
  }
423
649
 
424
- // Count lines considering both wrapping and newlines
650
+ function getWrapWidth() {
651
+ if (input._clines && typeof input._clines.width === "number") {
652
+ return Math.max(1, input._clines.width);
653
+ }
654
+ return getInnerWidth();
655
+ }
656
+
657
+ function isWideChar(ch) {
658
+ return wideRegex.test(ch);
659
+ }
660
+
661
+ function transformChar(ch) {
662
+ if (ch === "\n") return "\n";
663
+ if (ch === "\r") return "";
664
+ if (ch === "\t") return screen.tabc;
665
+
666
+ const code = ch.codePointAt(0);
667
+ if (
668
+ code <= 0x08
669
+ || code === 0x0b
670
+ || code === 0x0c
671
+ || (code >= 0x0e && code <= 0x1a)
672
+ || (code >= 0x1c && code <= 0x1f)
673
+ || code === 0x7f
674
+ ) {
675
+ return "";
676
+ }
677
+
678
+ if (ch === "\x1b") return "";
679
+
680
+ const isWide = isWideChar(ch);
681
+
682
+ if (screen.fullUnicode) {
683
+ if (screen.program && screen.program.isiTerm2 && unicode.isCombining(ch, 0)) {
684
+ return "";
685
+ }
686
+ if (isWide) return `${ch}\x03`;
687
+ return ch;
688
+ }
689
+
690
+ if (unicode.isCombining(ch, 0)) return "";
691
+ if (unicode.isSurrogate(ch, 0)) return "?";
692
+ if (isWide) return "??";
693
+ return ch;
694
+ }
695
+
696
+ function transformText(text) {
697
+ if (!text) return "";
698
+ const out = [];
699
+ for (const ch of text) {
700
+ out.push(transformChar(ch));
701
+ }
702
+ return out.join("");
703
+ }
704
+
705
+ function visualLength(text) {
706
+ return transformText(text).length;
707
+ }
708
+
709
+ function originalIndexForVisual(line, visualIndex) {
710
+ if (visualIndex <= 0) return 0;
711
+ let visual = 0;
712
+ let offset = 0;
713
+ for (const ch of line) {
714
+ const rep = transformChar(ch);
715
+ const repLen = rep.length;
716
+ if (visual + repLen > visualIndex) return offset;
717
+ visual += repLen;
718
+ offset += ch.length;
719
+ }
720
+ return line.length;
721
+ }
722
+
723
+ // Count lines considering both wrapping and newlines (matches blessed wrap)
425
724
  function countLines(text, width) {
426
725
  if (width <= 0) return 1;
427
- const lines = text.split("\n");
726
+ const lines = (text || "").split("\n");
428
727
  let total = 0;
429
728
  for (const line of lines) {
430
- const lineWidth = input.strWidth(line);
729
+ const lineWidth = visualLength(line);
431
730
  total += Math.max(1, Math.ceil(lineWidth / width));
432
731
  }
433
732
  return total;
@@ -435,45 +734,33 @@ async function runChat(projectRoot) {
435
734
 
436
735
  function getCursorRowCol(text, pos, width) {
437
736
  if (width <= 0) return { row: 0, col: 0 };
438
- const before = text.slice(0, pos);
439
- const lines = before.split("\n");
737
+ const before = (text || "").slice(0, pos);
738
+ const transformed = transformText(before);
739
+ const lines = transformed.split("\n");
440
740
  let row = 0;
441
741
  for (let i = 0; i < lines.length - 1; i++) {
442
- const lineWidth = input.strWidth(lines[i]);
742
+ const lineWidth = lines[i].length;
443
743
  row += Math.max(1, Math.ceil(lineWidth / width));
444
744
  }
445
745
  const lastLine = lines[lines.length - 1] || "";
446
- const lastWidth = input.strWidth(lastLine);
746
+ const lastWidth = lastLine.length;
447
747
  row += Math.floor(lastWidth / width);
448
748
  const col = lastWidth % width;
449
749
  return { row, col };
450
750
  }
451
751
 
452
- function getLinePosForCol(line, targetCol) {
453
- if (targetCol <= 0) return 0;
454
- let col = 0;
455
- let offset = 0;
456
- for (const ch of Array.from(line)) {
457
- const w = input.strWidth(ch);
458
- if (col + w > targetCol) return offset;
459
- col += w;
460
- offset += ch.length;
461
- }
462
- return offset;
463
- }
464
-
465
752
  function getCursorPosForRowCol(text, targetRow, targetCol, width) {
466
753
  if (width <= 0) return 0;
467
- const lines = text.split("\n");
754
+ const lines = (text || "").split("\n");
468
755
  let row = 0;
469
756
  let pos = 0;
470
757
  for (const line of lines) {
471
- const lineWidth = input.strWidth(line);
758
+ const lineWidth = visualLength(line);
472
759
  const wrappedRows = Math.max(1, Math.ceil(lineWidth / width));
473
760
  if (targetRow < row + wrappedRows) {
474
761
  const rowInLine = targetRow - row;
475
762
  const visualCol = rowInLine * width + Math.max(0, targetCol);
476
- return pos + getLinePosForCol(line, visualCol);
763
+ return pos + originalIndexForVisual(line, Math.min(visualCol, lineWidth));
477
764
  }
478
765
  pos += line.length + 1;
479
766
  row += wrappedRows;
@@ -481,6 +768,34 @@ async function runChat(projectRoot) {
481
768
  return text.length;
482
769
  }
483
770
 
771
+ function ensureInputCursorVisible() {
772
+ const innerWidth = getWrapWidth();
773
+ if (innerWidth <= 0) return;
774
+ const totalRows = countLines(input.value, innerWidth);
775
+ const visibleRows = Math.max(1, input.height || 1);
776
+ const { row } = getCursorRowCol(input.value, cursorPos, innerWidth);
777
+ let base = input.childBase || 0;
778
+ const maxBase = Math.max(0, totalRows - visibleRows);
779
+ const bottomMargin = visibleRows > 1 ? 1 : 0;
780
+ const upperLimit = base;
781
+ const lowerLimit = base + visibleRows - bottomMargin - 1;
782
+
783
+ if (row < upperLimit) {
784
+ base = row;
785
+ } else if (row > lowerLimit) {
786
+ base = row - (visibleRows - bottomMargin - 1);
787
+ }
788
+
789
+ if (base > maxBase) base = maxBase;
790
+ if (base < 0) base = 0;
791
+ if (base !== input.childBase) {
792
+ input.childBase = base;
793
+ if (typeof input.scrollTo === "function") {
794
+ input.scrollTo(base);
795
+ }
796
+ }
797
+ }
798
+
484
799
  function resetPreferredCol() {
485
800
  preferredCol = null;
486
801
  }
@@ -530,6 +845,7 @@ async function runChat(projectRoot) {
530
845
  normalizeCommandPrefix();
531
846
  resetPreferredCol();
532
847
  resizeInput();
848
+ ensureInputCursorVisible();
533
849
  input._updateCursor();
534
850
  screen.render();
535
851
  updateDraftFromInput();
@@ -540,6 +856,7 @@ async function runChat(projectRoot) {
540
856
  cursorPos = input.value.length;
541
857
  resetPreferredCol();
542
858
  resizeInput();
859
+ ensureInputCursorVisible();
543
860
  input._updateCursor();
544
861
  screen.render();
545
862
  }
@@ -573,9 +890,17 @@ async function runChat(projectRoot) {
573
890
  }
574
891
 
575
892
  function exitHandler() {
893
+ exitRequested = true;
894
+ // Clean up agent view connections
895
+ disconnectAgentOutput();
896
+ disconnectAgentInput();
576
897
  if (screen && screen.program && typeof screen.program.decrst === "function") {
577
898
  screen.program.decrst(2004);
578
899
  }
900
+ if (statusAnimationTimer) {
901
+ clearInterval(statusAnimationTimer);
902
+ statusAnimationTimer = null;
903
+ }
579
904
  if (client) {
580
905
  client.end();
581
906
  }
@@ -613,9 +938,12 @@ async function runChat(projectRoot) {
613
938
  const parts = filterText.split(/\s+/);
614
939
  let commands = [];
615
940
 
616
- if ((parts.length > 1 || (endsWithSpace && parts.length === 1)) && parts[0].startsWith("/")) {
941
+ const mainCmd = parts[0];
942
+ const isLaunch = mainCmd && mainCmd.toLowerCase() === "/launch";
943
+ const wantsSubcommands = (parts.length > 1 || (endsWithSpace && parts.length === 1));
944
+
945
+ if ((wantsSubcommands || isLaunch) && mainCmd && mainCmd.startsWith("/")) {
617
946
  // Subcommand mode: "/bus rename"
618
- const mainCmd = parts[0];
619
947
  const subFilter = parts[1] || "";
620
948
 
621
949
  // Find the main command
@@ -623,31 +951,41 @@ async function runChat(projectRoot) {
623
951
  item.cmd.toLowerCase() === mainCmd.toLowerCase()
624
952
  );
625
953
 
626
- if (mainCmdObj && mainCmdObj.subcommands) {
627
- // Filter subcommands
628
- commands = mainCmdObj.subcommands
629
- .filter(sub => sub.cmd.toLowerCase().startsWith(subFilter.toLowerCase()))
630
- .map(sub => ({ ...sub, isSubcommand: true, parentCmd: mainCmd }));
954
+ const fallbackLaunchSubs = [
955
+ { cmd: "claude", desc: "Launch Claude agent" },
956
+ { cmd: "codex", desc: "Launch Codex agent" },
957
+ ];
958
+
959
+ if ((mainCmdObj && mainCmdObj.subcommands) || isLaunch) {
960
+ const baseSubs = mainCmdObj && mainCmdObj.subcommands ? mainCmdObj.subcommands : [];
961
+ let subs = baseSubs;
962
+ if (isLaunch) {
963
+ const merged = new Map();
964
+ for (const sub of [...baseSubs, ...fallbackLaunchSubs]) {
965
+ if (!sub || !sub.cmd) continue;
966
+ merged.set(sub.cmd, sub);
967
+ }
968
+ subs = Array.from(merged.values());
969
+ }
970
+ if (isLaunch) {
971
+ // Always show both launch targets for clarity
972
+ commands = subs
973
+ .map(sub => ({ ...sub, isSubcommand: true, parentCmd: mainCmd }))
974
+ .sort((a, b) => a.cmd.localeCompare(b.cmd));
975
+ } else {
976
+ // Filter subcommands
977
+ commands = subs
978
+ .filter(sub => sub.cmd.toLowerCase().startsWith(subFilter.toLowerCase()))
979
+ .map(sub => ({ ...sub, isSubcommand: true, parentCmd: mainCmd }))
980
+ .sort((a, b) => a.cmd.localeCompare(b.cmd));
981
+ }
631
982
  }
632
983
  } else {
633
984
  // Main command mode: "/bus"
634
- const prefixMatches = COMMAND_REGISTRY.filter(item =>
635
- item.cmd.toLowerCase().startsWith(filterText.toLowerCase())
636
- );
637
- // Also allow fuzzy matches on the command body (e.g. "/b" -> /bus + /ubus)
638
- let fuzzyMatches = [];
639
- if (filterText.startsWith("/") && parts.length === 1) {
640
- const needle = filterText.slice(1).toLowerCase();
641
- if (needle) {
642
- fuzzyMatches = COMMAND_REGISTRY.filter(item =>
643
- item.cmd.toLowerCase().includes(needle)
644
- );
645
- }
646
- }
647
- const merged = new Map();
648
- for (const item of prefixMatches) merged.set(item.cmd, item);
649
- for (const item of fuzzyMatches) merged.set(item.cmd, item);
650
- commands = Array.from(merged.values());
985
+ const filterLower = filterText.toLowerCase();
986
+ commands = COMMAND_REGISTRY
987
+ .filter(item => item.cmd.toLowerCase().startsWith(filterLower))
988
+ .sort((a, b) => a.cmd.localeCompare(b.cmd, "en", { sensitivity: "base" }));
651
989
  }
652
990
 
653
991
  if (commands.length === 0) {
@@ -660,9 +998,12 @@ async function runChat(projectRoot) {
660
998
  completionIndex = 0;
661
999
  completionScrollOffset = 0;
662
1000
 
663
- // Calculate panel height (max 8 visible + 1 for top border)
664
- const visibleItems = Math.min(8, completionCommands.length);
665
- completionPanel.height = visibleItems + 1;
1001
+ // Calculate panel height (visible items + 2 for blessed border overhead)
1002
+ // blessed reserves 2 rows for border (iheight) even when only border.top is set
1003
+ const availableHeight = screen.height - currentInputHeight - 1;
1004
+ completionVisibleCount = Math.min(7, completionCommands.length);
1005
+ completionVisibleCount = Math.min(completionVisibleCount, Math.max(1, availableHeight - 2));
1006
+ completionPanel.height = completionVisibleCount + 2;
666
1007
  completionPanel.bottom = currentInputHeight - 1;
667
1008
  completionPanel.hidden = false;
668
1009
 
@@ -674,6 +1015,7 @@ async function runChat(projectRoot) {
674
1015
  completionCommands = [];
675
1016
  completionIndex = 0;
676
1017
  completionScrollOffset = 0;
1018
+ completionVisibleCount = 0;
677
1019
  completionPanel.hidden = true;
678
1020
  screen.render();
679
1021
  }
@@ -681,7 +1023,11 @@ async function runChat(projectRoot) {
681
1023
  function renderCompletionPanel() {
682
1024
  if (!completionActive || completionCommands.length === 0) return;
683
1025
 
684
- const maxVisible = 8;
1026
+ // blessed reserves 2 rows for border (iheight=2) even with only border.top
1027
+ const panelVisible = Math.max(1, (completionPanel.height || 2) - 2);
1028
+ const maxVisible = completionVisibleCount
1029
+ ? Math.max(1, Math.min(completionVisibleCount, panelVisible))
1030
+ : panelVisible;
685
1031
 
686
1032
  // Adjust scroll offset to keep selected item visible
687
1033
  if (completionIndex < completionScrollOffset) {
@@ -695,21 +1041,37 @@ async function runChat(projectRoot) {
695
1041
  const visibleEnd = Math.min(completionScrollOffset + maxVisible, completionCommands.length);
696
1042
  const visibleCommands = completionCommands.slice(visibleStart, visibleEnd);
697
1043
 
1044
+ const panelWidth = typeof completionPanel.width === "number"
1045
+ ? completionPanel.width
1046
+ : screen.width;
698
1047
  const lines = visibleCommands.map((item, i) => {
699
1048
  const actualIndex = visibleStart + i;
1049
+ const cmdText = item.cmd;
1050
+ const descText = item.desc || "";
700
1051
  const cmdPart = actualIndex === completionIndex
701
- ? `{inverse}${item.cmd}{/inverse}`
702
- : `{cyan-fg}${item.cmd}{/cyan-fg}`;
703
- const descPart = `{gray-fg}${item.desc}{/gray-fg}`;
704
- // Use promptBox width (2) to align with input position
1052
+ ? `{inverse}${cmdText}{/inverse}`
1053
+ : `{cyan-fg}${cmdText}{/cyan-fg}`;
705
1054
  const indent = " ".repeat(promptBox.width || 2);
706
- return `${indent}${cmdPart} ${descPart}`;
1055
+ const maxDescWidth = Math.max(0, panelWidth - indent.length - cmdText.length - 2);
1056
+ const trimmedDesc = truncateText(descText, maxDescWidth);
1057
+ const descPart = trimmedDesc ? `{gray-fg}${trimmedDesc}{/gray-fg}` : "";
1058
+ // Use promptBox width (2) to align with input position
1059
+ return descPart
1060
+ ? `${indent}${cmdPart} ${descPart}`
1061
+ : `${indent}${cmdPart}`;
707
1062
  });
708
1063
 
709
1064
  completionPanel.setContent(lines.join("\n"));
710
1065
  screen.render();
711
1066
  }
712
1067
 
1068
+ function completionPageSize() {
1069
+ const panelVisible = Math.max(1, (completionPanel.height || 2) - 2);
1070
+ return completionVisibleCount
1071
+ ? Math.max(1, Math.min(completionVisibleCount, panelVisible))
1072
+ : panelVisible;
1073
+ }
1074
+
713
1075
  function completionUp() {
714
1076
  if (completionCommands.length === 0) return;
715
1077
  completionIndex = completionIndex <= 0
@@ -726,6 +1088,55 @@ async function runChat(projectRoot) {
726
1088
  renderCompletionPanel();
727
1089
  }
728
1090
 
1091
+ function completionPageUp() {
1092
+ if (completionCommands.length === 0) return;
1093
+ const step = completionPageSize();
1094
+ completionIndex = Math.max(0, completionIndex - step);
1095
+ renderCompletionPanel();
1096
+ }
1097
+
1098
+ function completionPageDown() {
1099
+ if (completionCommands.length === 0) return;
1100
+ const step = completionPageSize();
1101
+ completionIndex = Math.min(completionCommands.length - 1, completionIndex + step);
1102
+ renderCompletionPanel();
1103
+ }
1104
+
1105
+ function completionPreview(selected) {
1106
+ const current = input.value || "";
1107
+ const trimmed = current.trim();
1108
+ const endsWithSpace = /\s$/.test(current);
1109
+ if (selected.isSubcommand) {
1110
+ const parts = trimmed.split(/\s+/);
1111
+ const base = parts[0] || "";
1112
+ const completedCore = base ? `${base} ${selected.cmd}` : selected.cmd;
1113
+ const isComplete = trimmed === completedCore || trimmed.startsWith(`${completedCore} `);
1114
+ return { text: `${completedCore} `, isComplete };
1115
+ }
1116
+ const completedCore = selected.cmd;
1117
+ const hasChildren = selected.subcommands && selected.subcommands.length > 0;
1118
+ const isComplete =
1119
+ (trimmed === completedCore && (!hasChildren || endsWithSpace)) ||
1120
+ trimmed.startsWith(`${completedCore} `);
1121
+ return { text: `${completedCore} `, isComplete };
1122
+ }
1123
+
1124
+ function applyCompletionPreview(preview) {
1125
+ input.value = preview.text;
1126
+ cursorPos = input.value.length;
1127
+ resetPreferredCol();
1128
+ input._updateCursor();
1129
+ updateDraftFromInput();
1130
+ screen.render();
1131
+ }
1132
+
1133
+ function truncateText(text, maxWidth) {
1134
+ if (maxWidth <= 0) return "";
1135
+ if (text.length <= maxWidth) return text;
1136
+ if (maxWidth <= 3) return text.slice(0, maxWidth);
1137
+ return `${text.slice(0, maxWidth - 3)}...`;
1138
+ }
1139
+
729
1140
  function confirmCompletion() {
730
1141
  if (!completionActive || completionCommands.length === 0) return;
731
1142
 
@@ -773,9 +1184,43 @@ async function runChat(projectRoot) {
773
1184
  confirmCompletion();
774
1185
  return true;
775
1186
  }
1187
+ if (key.name === "pageup") {
1188
+ completionPageUp();
1189
+ return true;
1190
+ }
1191
+ if (key.name === "pagedown") {
1192
+ completionPageDown();
1193
+ return true;
1194
+ }
776
1195
  if (key.name === "enter" || key.name === "return") {
777
- // Enter submits input, doesn't confirm completion
1196
+ if (completionEnterSuppressed) {
1197
+ return true;
1198
+ }
1199
+ const selected = completionCommands[completionIndex];
1200
+ if (selected) {
1201
+ const preview = completionPreview(selected);
1202
+ if (!preview.isComplete) {
1203
+ applyCompletionPreview(preview);
1204
+ if (!selected.isSubcommand && selected.subcommands && selected.subcommands.length > 0) {
1205
+ showCompletion(input.value);
1206
+ } else {
1207
+ hideCompletion();
1208
+ }
1209
+ completionEnterSuppressed = true;
1210
+ if (completionEnterReset) clearImmediate(completionEnterReset);
1211
+ completionEnterReset = setImmediate(() => {
1212
+ completionEnterSuppressed = false;
1213
+ });
1214
+ return true;
1215
+ }
1216
+ }
1217
+ // Already complete; allow normal submit
778
1218
  hideCompletion();
1219
+ completionEnterSuppressed = true;
1220
+ if (completionEnterReset) clearImmediate(completionEnterReset);
1221
+ completionEnterReset = setImmediate(() => {
1222
+ completionEnterSuppressed = false;
1223
+ });
779
1224
  return false;
780
1225
  }
781
1226
  if (key.name === "escape") {
@@ -799,7 +1244,7 @@ async function runChat(projectRoot) {
799
1244
 
800
1245
  // Resize input box based on content
801
1246
  function resizeInput() {
802
- const innerWidth = getInnerWidth();
1247
+ const innerWidth = getWrapWidth();
803
1248
  if (innerWidth <= 0) return;
804
1249
 
805
1250
  const numLines = countLines(input.value, innerWidth);
@@ -816,13 +1261,21 @@ async function runChat(projectRoot) {
816
1261
  // Reposition completion panel if active
817
1262
  if (completionActive) {
818
1263
  completionPanel.bottom = currentInputHeight - 1;
1264
+ // Re-clamp visible count for new available space
1265
+ const availableHeight = screen.height - currentInputHeight - 1;
1266
+ const maxVisible = Math.min(7, completionCommands.length);
1267
+ completionVisibleCount = Math.min(maxVisible, Math.max(1, availableHeight - 2));
1268
+ completionPanel.height = completionVisibleCount + 2;
1269
+ renderCompletionPanel();
819
1270
  }
820
1271
  // dashboard and inputBottomLine stay fixed at bottom 0 and 1
821
1272
  logBox.height = Math.max(1, screen.height - currentInputHeight - 1);
1273
+ ensureInputCursorVisible();
822
1274
  }
823
1275
 
824
1276
  // Override the internal listener to support cursor movement
825
1277
  input._listener = function(ch, key) {
1278
+ if (currentView === "agent") return; // Agent view handles keys at screen level
826
1279
  if (key && key.ctrl && key.name === "c") {
827
1280
  exitHandler();
828
1281
  return;
@@ -831,20 +1284,27 @@ async function runChat(projectRoot) {
831
1284
  return;
832
1285
  }
833
1286
  normalizeCommandPrefix();
834
- if (key && (key.name === "pageup" || key.name === "pagedown")) {
835
- const delta = Math.max(1, Math.floor(logBox.height / 2));
836
- scrollLog(key.name === "pageup" ? -delta : delta);
837
- return;
838
- }
839
1287
  if (focusMode === "dashboard") {
840
1288
  if (handleDashboardKey(key)) return;
841
- return;
1289
+ // On agents view, printable char auto-exits dashboard keeping @target
1290
+ if (dashboardView === "agents" && ch && ch.length === 1 && !key.ctrl && !key.meta
1291
+ && !/^[\x00-\x1f\x7f]$/.test(ch)) {
1292
+ exitDashboardMode(true);
1293
+ // Fall through to normal input handling so the char is inserted
1294
+ } else {
1295
+ return;
1296
+ }
842
1297
  }
843
1298
 
844
1299
  // Command completion mode
845
1300
  if (completionActive) {
846
1301
  if (handleCompletionKey(ch, key)) return;
847
1302
  }
1303
+ if (key && (key.name === "pageup" || key.name === "pagedown")) {
1304
+ const delta = Math.max(1, Math.floor(logBox.height / 2));
1305
+ scrollLog(key.name === "pageup" ? -delta : delta);
1306
+ return;
1307
+ }
848
1308
 
849
1309
  // Treat multi-char input (paste) as insertion, including newlines.
850
1310
  if (ch && ch.length > 1 && (!key || !key.name || key.name.length !== 1)) {
@@ -871,6 +1331,7 @@ async function runChat(projectRoot) {
871
1331
  if (key.name === "left") {
872
1332
  if (cursorPos > 0) cursorPos--;
873
1333
  resetPreferredCol();
1334
+ ensureInputCursorVisible();
874
1335
  this._updateCursor();
875
1336
  this.screen.render();
876
1337
  return;
@@ -879,6 +1340,7 @@ async function runChat(projectRoot) {
879
1340
  if (key.name === "right") {
880
1341
  if (cursorPos < this.value.length) cursorPos++;
881
1342
  resetPreferredCol();
1343
+ ensureInputCursorVisible();
882
1344
  this._updateCursor();
883
1345
  this.screen.render();
884
1346
  return;
@@ -887,6 +1349,7 @@ async function runChat(projectRoot) {
887
1349
  if (key.name === "home") {
888
1350
  cursorPos = 0;
889
1351
  resetPreferredCol();
1352
+ ensureInputCursorVisible();
890
1353
  this._updateCursor();
891
1354
  this.screen.render();
892
1355
  return;
@@ -895,6 +1358,7 @@ async function runChat(projectRoot) {
895
1358
  if (key.name === "end") {
896
1359
  cursorPos = this.value.length;
897
1360
  resetPreferredCol();
1361
+ ensureInputCursorVisible();
898
1362
  this._updateCursor();
899
1363
  this.screen.render();
900
1364
  return;
@@ -919,7 +1383,7 @@ async function runChat(projectRoot) {
919
1383
  }
920
1384
  }
921
1385
  if (key.name === "up" || key.name === "down") {
922
- const innerWidth = getInnerWidth();
1386
+ const innerWidth = getWrapWidth();
923
1387
  if (innerWidth > 0) {
924
1388
  const { row, col } = getCursorRowCol(this.value, cursorPos, innerWidth);
925
1389
  if (preferredCol === null) preferredCol = col;
@@ -936,6 +1400,7 @@ async function runChat(projectRoot) {
936
1400
  : Math.min(totalRows - 1, row + 1);
937
1401
  cursorPos = getCursorPosForRowCol(this.value, targetRow, preferredCol, innerWidth);
938
1402
  }
1403
+ ensureInputCursorVisible();
939
1404
  this._updateCursor();
940
1405
  this.screen.render();
941
1406
  return;
@@ -952,6 +1417,7 @@ async function runChat(projectRoot) {
952
1417
  cursorPos--;
953
1418
  resetPreferredCol();
954
1419
  resizeInput();
1420
+ ensureInputCursorVisible();
955
1421
  this._updateCursor();
956
1422
  updateDraftFromInput();
957
1423
 
@@ -972,6 +1438,7 @@ async function runChat(projectRoot) {
972
1438
  this.value = this.value.slice(0, cursorPos) + this.value.slice(cursorPos + 1);
973
1439
  resetPreferredCol();
974
1440
  resizeInput();
1441
+ ensureInputCursorVisible();
975
1442
  this._updateCursor();
976
1443
  this.screen.render();
977
1444
  updateDraftFromInput();
@@ -1008,27 +1475,16 @@ async function runChat(projectRoot) {
1008
1475
  input._updateCursor = function() {
1009
1476
  if (this.screen.focused !== this) return;
1010
1477
 
1011
- const lpos = this._getCoords();
1478
+ let lpos;
1479
+ try { lpos = this._getCoords(); } catch { return; }
1012
1480
  if (!lpos) return;
1013
1481
 
1014
- const innerWidth = getInnerWidth();
1482
+ const innerWidth = getWrapWidth();
1015
1483
  if (innerWidth <= 0) return;
1016
1484
 
1485
+ ensureInputCursorVisible();
1017
1486
  const { row, col } = getCursorRowCol(this.value, cursorPos, innerWidth);
1018
- const innerHeight = this.height || 1;
1019
-
1020
- let scrollOffset = this.childBase || 0;
1021
- if (row < scrollOffset) {
1022
- scrollOffset = row;
1023
- } else if (row >= scrollOffset + innerHeight) {
1024
- scrollOffset = row - innerHeight + 1;
1025
- }
1026
- if (scrollOffset !== this.childBase) {
1027
- this.childBase = scrollOffset;
1028
- if (typeof this.scrollTo === "function") {
1029
- this.scrollTo(scrollOffset);
1030
- }
1031
- }
1487
+ const scrollOffset = this.childBase || 0;
1032
1488
 
1033
1489
  const displayRow = row - scrollOffset;
1034
1490
  const safeCol = Math.min(Math.max(0, col), innerWidth - 1);
@@ -1064,49 +1520,102 @@ async function runChat(projectRoot) {
1064
1520
  let completionCommands = [];
1065
1521
  let completionIndex = 0;
1066
1522
  let completionScrollOffset = 0;
1523
+ let completionVisibleCount = 0;
1524
+ let completionEnterSuppressed = false;
1525
+ let completionEnterReset = null;
1067
1526
 
1068
- const COMMAND_REGISTRY = [
1069
- { cmd: "/doctor", desc: "Health check diagnostics" },
1070
- { cmd: "/status", desc: "Status display" },
1071
- {
1072
- cmd: "/daemon",
1527
+ const COMMAND_TREE = {
1528
+ "/bus": {
1529
+ desc: "Event bus operations",
1530
+ children: {
1531
+ activate: { desc: "Activate agent terminal" },
1532
+ list: { desc: "List all agents" },
1533
+ rename: { desc: "Rename agent nickname" },
1534
+ send: { desc: "Send message to agent" },
1535
+ status: { desc: "Bus status" },
1536
+ },
1537
+ },
1538
+ "/ctx": {
1539
+ desc: "Context management",
1540
+ children: {
1541
+ decisions: { desc: "List all decisions" },
1542
+ doctor: { desc: "Check context integrity" },
1543
+ status: { desc: "Show context status (default)" },
1544
+ },
1545
+ },
1546
+ "/daemon": {
1073
1547
  desc: "Daemon management",
1074
- subcommands: [
1075
- { cmd: "start", desc: "Start daemon" },
1076
- { cmd: "stop", desc: "Stop daemon" },
1077
- { cmd: "restart", desc: "Restart daemon" },
1078
- { cmd: "status", desc: "Daemon status" },
1079
- ]
1548
+ children: {
1549
+ restart: { desc: "Restart daemon" },
1550
+ start: { desc: "Start daemon" },
1551
+ status: { desc: "Daemon status" },
1552
+ stop: { desc: "Stop daemon" },
1553
+ },
1080
1554
  },
1081
- { cmd: "/init", desc: "Initialize modules" },
1082
- {
1083
- cmd: "/bus",
1084
- desc: "Event bus operations",
1085
- subcommands: [
1086
- { cmd: "send", desc: "Send message to agent" },
1087
- { cmd: "rename", desc: "Rename agent nickname" },
1088
- { cmd: "list", desc: "List all agents" },
1089
- { cmd: "status", desc: "Bus status" },
1090
- ]
1555
+ "/doctor": { desc: "Health check diagnostics" },
1556
+ "/init": { desc: "Initialize modules" },
1557
+ "/launch": {
1558
+ desc: "Launch new agent",
1559
+ children: {
1560
+ claude: { desc: "Launch Claude agent" },
1561
+ codex: { desc: "Launch Codex agent" },
1562
+ },
1091
1563
  },
1092
- { cmd: "/ctx", desc: "Context management" },
1093
- { cmd: "/skills", desc: "Skills management" },
1094
- { cmd: "/ubus", desc: "Check bus messages" },
1095
- { cmd: "/uctx", desc: "Context status" },
1096
- { cmd: "/uinit", desc: "Initialize/repair" },
1097
- { cmd: "/ustatus", desc: "Unified status" },
1098
- ];
1564
+ "/resume": { desc: "Resume agents (optional nickname)" },
1565
+ "/skills": {
1566
+ desc: "Skills management",
1567
+ children: {
1568
+ install: { desc: "Install skills (use: all or name)" },
1569
+ list: { desc: "List available skills" },
1570
+ },
1571
+ },
1572
+ "/status": { desc: "Status display" },
1573
+ };
1574
+
1575
+ function buildCommandRegistry(tree) {
1576
+ return Object.keys(tree)
1577
+ .sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }))
1578
+ .map((cmd) => {
1579
+ const node = tree[cmd] || {};
1580
+ const entry = { cmd, desc: node.desc || "" };
1581
+ if (node.children) {
1582
+ entry.subcommands = Object.keys(node.children)
1583
+ .sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }))
1584
+ .map((sub) => ({
1585
+ cmd: sub,
1586
+ desc: (node.children[sub] && node.children[sub].desc) || "",
1587
+ }));
1588
+ }
1589
+ return entry;
1590
+ });
1591
+ }
1592
+
1593
+ const COMMAND_REGISTRY = buildCommandRegistry(COMMAND_TREE);
1099
1594
 
1100
1595
  // Agent selection state
1101
1596
  let activeAgents = [];
1102
1597
  let activeAgentLabelMap = new Map();
1598
+ let activeAgentMetaMap = new Map(); // Store full meta including launch_mode
1103
1599
  let agentListWindowStart = 0;
1104
1600
  const MAX_AGENT_WINDOW = 5;
1105
1601
  let selectedAgentIndex = -1; // -1 = not in dashboard selection mode
1106
1602
  let targetAgent = null; // Selected agent for direct messaging
1107
1603
  let focusMode = "input"; // "input" or "dashboard"
1108
- let dashboardView = "agents"; // "agents" or "mode"
1109
- let selectedModeIndex = launchMode === "internal" ? 1 : 0;
1604
+ let dashboardView = "agents"; // "agents" | "mode" | "provider" | "resume"
1605
+ const launchModes = ["auto", "terminal", "tmux", "internal"];
1606
+ function modeToIndex(m) { const i = launchModes.indexOf(m); return i >= 0 ? i : 0; }
1607
+ let selectedModeIndex = modeToIndex(launchMode);
1608
+ const providerOptions = [
1609
+ { label: "codex", value: "codex-cli" },
1610
+ { label: "claude", value: "claude-cli" },
1611
+ ];
1612
+ let selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
1613
+ const resumeOptions = [
1614
+ { label: "Auto", value: true },
1615
+ { label: "Off", value: false },
1616
+ ];
1617
+ let selectedResumeIndex = autoResume ? 0 : 1;
1618
+ let restartInProgress = false;
1110
1619
 
1111
1620
  function getAgentLabel(agentId) {
1112
1621
  return activeAgentLabelMap.get(agentId) || agentId;
@@ -1131,6 +1640,11 @@ async function runChat(projectRoot) {
1131
1640
  }
1132
1641
 
1133
1642
  function send(req) {
1643
+ if (!client || client.destroyed) {
1644
+ enqueueRequest(req);
1645
+ void ensureConnected();
1646
+ return;
1647
+ }
1134
1648
  client.write(`${JSON.stringify(req)}\n`);
1135
1649
  }
1136
1650
 
@@ -1169,12 +1683,91 @@ async function runChat(projectRoot) {
1169
1683
  function setLaunchMode(mode) {
1170
1684
  const next = normalizeLaunchMode(mode);
1171
1685
  if (next === launchMode) return;
1686
+ // Check tmux availability before switching
1687
+ if (next === "tmux" && !process.env.TMUX) {
1688
+ logMessage("error", "{red-fg}✗{/red-fg} tmux mode requires running inside a tmux session");
1689
+ return;
1690
+ }
1172
1691
  launchMode = next;
1173
- selectedModeIndex = launchMode === "internal" ? 1 : 0;
1692
+ selectedModeIndex = modeToIndex(launchMode);
1174
1693
  saveConfig(projectRoot, { launchMode });
1175
1694
  logMessage("status", `{magenta-fg}⚙{/magenta-fg} Launch mode: ${launchMode}`);
1176
1695
  renderDashboard();
1177
1696
  screen.render();
1697
+ void restartDaemon();
1698
+ }
1699
+
1700
+
1701
+ function providerLabel(value) {
1702
+ return value === "claude-cli" ? "claude" : "codex";
1703
+ }
1704
+
1705
+ function clearUfooAgentIdentity() {
1706
+ const agentDir = getUfooPaths(projectRoot).agentDir;
1707
+ const stateFile = path.join(agentDir, "ufoo-agent.json");
1708
+ const historyFile = path.join(agentDir, "ufoo-agent.history.jsonl");
1709
+ try {
1710
+ fs.rmSync(stateFile, { force: true });
1711
+ } catch {
1712
+ // ignore
1713
+ }
1714
+ try {
1715
+ fs.rmSync(historyFile, { force: true });
1716
+ } catch {
1717
+ // ignore
1718
+ }
1719
+ }
1720
+
1721
+ function setAgentProvider(provider) {
1722
+ const next = normalizeAgentProvider(provider);
1723
+ if (next === agentProvider) return;
1724
+ agentProvider = next;
1725
+ selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
1726
+ saveConfig(projectRoot, { agentProvider });
1727
+ clearUfooAgentIdentity();
1728
+ logMessage("status", `{magenta-fg}⚙{/magenta-fg} ufoo-agent: ${providerLabel(agentProvider)}`);
1729
+ renderDashboard();
1730
+ screen.render();
1731
+ void restartDaemon();
1732
+ }
1733
+
1734
+ function setAutoResume(value) {
1735
+ const next = value !== false;
1736
+ if (next === autoResume) return;
1737
+ autoResume = next;
1738
+ selectedResumeIndex = autoResume ? 0 : 1;
1739
+ saveConfig(projectRoot, { autoResume });
1740
+ const label = autoResume ? "Auto" : "Off";
1741
+ logMessage("status", `{magenta-fg}⚙{/magenta-fg} Resume: ${label}`);
1742
+ renderDashboard();
1743
+ screen.render();
1744
+ }
1745
+
1746
+ async function restartDaemon() {
1747
+ if (restartInProgress) return;
1748
+ restartInProgress = true;
1749
+ logMessage("status", "{magenta-fg}⚙{/magenta-fg} Restarting daemon...");
1750
+ try {
1751
+ if (client) {
1752
+ client.removeAllListeners();
1753
+ try {
1754
+ client.end();
1755
+ } catch {
1756
+ // ignore
1757
+ }
1758
+ }
1759
+ stopDaemon(projectRoot);
1760
+ startDaemon(projectRoot, { forceResume: true });
1761
+ const newClient = await connectClient();
1762
+ if (newClient) {
1763
+ attachClient(newClient);
1764
+ logMessage("status", "{green-fg}✓{/green-fg} Daemon reconnected");
1765
+ } else {
1766
+ logMessage("error", "{red-fg}✗{/red-fg} Failed to reconnect to daemon");
1767
+ }
1768
+ } finally {
1769
+ restartInProgress = false;
1770
+ }
1178
1771
  }
1179
1772
 
1180
1773
  function clearLog() {
@@ -1189,14 +1782,40 @@ async function runChat(projectRoot) {
1189
1782
  let content = " ";
1190
1783
  if (focusMode === "dashboard") {
1191
1784
  if (dashboardView === "mode") {
1192
- const modes = ["terminal", "internal"];
1193
- const modeParts = modes.map((mode, i) => {
1785
+ const modeParts = launchModes.map((mode, i) => {
1194
1786
  if (i === selectedModeIndex) {
1195
1787
  return `{inverse}${mode}{/inverse}`;
1196
1788
  }
1789
+ if (mode === launchMode) {
1790
+ return `{bold}{cyan-fg}${mode}{/cyan-fg}{/bold}`;
1791
+ }
1197
1792
  return `{cyan-fg}${mode}{/cyan-fg}`;
1198
1793
  });
1199
1794
  content += `{gray-fg}Mode:{/gray-fg} ${modeParts.join(" ")}`;
1795
+ content += " {gray-fg}│ ←/→ select, Enter confirm, ↓ agent, ↑ back{/gray-fg}";
1796
+ } else if (dashboardView === "provider") {
1797
+ const providerParts = providerOptions.map((opt, i) => {
1798
+ if (i === selectedProviderIndex) {
1799
+ return `{inverse}${opt.label}{/inverse}`;
1800
+ }
1801
+ if (opt.value === agentProvider) {
1802
+ return `{bold}{cyan-fg}${opt.label}{/cyan-fg}{/bold}`;
1803
+ }
1804
+ return `{cyan-fg}${opt.label}{/cyan-fg}`;
1805
+ });
1806
+ content += `{gray-fg}Agent:{/gray-fg} ${providerParts.join(" ")}`;
1807
+ content += " {gray-fg}│ ←/→ select, Enter confirm, ↓ resume, ↑ back{/gray-fg}";
1808
+ } else if (dashboardView === "resume") {
1809
+ const resumeParts = resumeOptions.map((opt, i) => {
1810
+ if (i === selectedResumeIndex) {
1811
+ return `{inverse}${opt.label}{/inverse}`;
1812
+ }
1813
+ if (opt.value === autoResume) {
1814
+ return `{bold}{cyan-fg}${opt.label}{/cyan-fg}{/bold}`;
1815
+ }
1816
+ return `{cyan-fg}${opt.label}{/cyan-fg}`;
1817
+ });
1818
+ content += `{gray-fg}Resume:{/gray-fg} ${resumeParts.join(" ")}`;
1200
1819
  content += " {gray-fg}│ ←/→ select, Enter confirm, ↑ back{/gray-fg}";
1201
1820
  } else {
1202
1821
  if (activeAgents.length > 0) {
@@ -1217,7 +1836,7 @@ async function runChat(projectRoot) {
1217
1836
  const rightMore = end < activeAgents.length ? " {gray-fg}»{/gray-fg}" : "";
1218
1837
  content += `{gray-fg}Agents:{/gray-fg} ${agentParts.join(" ")}`;
1219
1838
  content = `${content.replace("{gray-fg}Agents:{/gray-fg} ", `{gray-fg}Agents:{/gray-fg} ${leftMore}`)}${rightMore}`;
1220
- content += " {gray-fg}│ ←/→ select, Enter confirm, ↓ mode, ↑ back{/gray-fg}";
1839
+ content += " {gray-fg}│ ←/→ select, Enter confirm, ^X close, ↓ mode, ↑ back{/gray-fg}";
1221
1840
  } else {
1222
1841
  content += "{gray-fg}Agents:{/gray-fg} {cyan-fg}none{/cyan-fg}";
1223
1842
  content += " {gray-fg}│ ↓ mode, ↑ back{/gray-fg}";
@@ -1226,10 +1845,15 @@ async function runChat(projectRoot) {
1226
1845
  } else {
1227
1846
  // Normal dashboard display (input mode)
1228
1847
  const agents = activeAgents.length > 0
1229
- ? activeAgents.slice(0, 3).map((id) => getAgentLabel(id)).join(", ") + (activeAgents.length > 3 ? ` +${activeAgents.length - 3}` : "")
1848
+ ? activeAgents.slice(0, 3).map((id) => {
1849
+ const label = getAgentLabel(id);
1850
+ return label;
1851
+ }).join(", ") + (activeAgents.length > 3 ? ` +${activeAgents.length - 3}` : "")
1230
1852
  : "none";
1231
1853
  content += `{gray-fg}Agents:{/gray-fg} {cyan-fg}${agents}{/cyan-fg}`;
1232
1854
  content += ` {gray-fg}Mode:{/gray-fg} {cyan-fg}${launchMode}{/cyan-fg}`;
1855
+ content += ` {gray-fg}Agent:{/gray-fg} {cyan-fg}${providerLabel(agentProvider)}{/cyan-fg}`;
1856
+ content += ` {gray-fg}Resume:{/gray-fg} {cyan-fg}${autoResume ? "auto" : "off"}{/cyan-fg}`;
1233
1857
  }
1234
1858
  dashboard.setContent(content);
1235
1859
  }
@@ -1238,13 +1862,14 @@ async function runChat(projectRoot) {
1238
1862
  activeAgents = status.active || [];
1239
1863
  const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
1240
1864
  activeAgentLabelMap = new Map();
1865
+ activeAgentMetaMap = new Map();
1241
1866
  let fallbackMap = null;
1242
1867
  if (metaList.length === 0 && activeAgents.length > 0) {
1243
1868
  try {
1244
- const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
1869
+ const busPath = getUfooPaths(projectRoot).agentsFile;
1245
1870
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
1246
1871
  fallbackMap = new Map();
1247
- for (const [id, meta] of Object.entries(bus.subscribers || {})) {
1872
+ for (const [id, meta] of Object.entries(bus.agents || {})) {
1248
1873
  if (meta && meta.nickname) fallbackMap.set(id, meta.nickname);
1249
1874
  }
1250
1875
  } catch {
@@ -1257,8 +1882,31 @@ async function runChat(projectRoot) {
1257
1882
  ? meta.nickname
1258
1883
  : (fallbackMap && fallbackMap.get(id)) || id;
1259
1884
  activeAgentLabelMap.set(id, label);
1885
+ if (meta) {
1886
+ activeAgentMetaMap.set(id, meta);
1887
+ }
1260
1888
  }
1261
1889
  clampAgentWindow();
1890
+
1891
+ // Check if viewed agent went offline
1892
+ if (currentView === "agent" && viewingAgent && !activeAgents.includes(viewingAgent)) {
1893
+ writeToAgentTerm("\r\n\x1b[1;31m[Agent went offline]\x1b[0m\r\n");
1894
+ exitAgentView();
1895
+ return;
1896
+ }
1897
+
1898
+ // In agent view, only update the dashboard bar (via ANSI, blessed is frozen)
1899
+ if (currentView === "agent") {
1900
+ if (focusMode === "dashboard") {
1901
+ const totalItems = 1 + activeAgents.length;
1902
+ if (selectedAgentIndex < 0 || selectedAgentIndex >= totalItems) {
1903
+ selectedAgentIndex = 0;
1904
+ }
1905
+ }
1906
+ renderAgentDashboard();
1907
+ return;
1908
+ }
1909
+
1262
1910
  if (focusMode === "dashboard") {
1263
1911
  if (dashboardView === "agents") {
1264
1912
  if (activeAgents.length === 0) {
@@ -1279,7 +1927,14 @@ async function runChat(projectRoot) {
1279
1927
  selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
1280
1928
  agentListWindowStart = 0;
1281
1929
  clampAgentWindow();
1282
- selectedModeIndex = launchMode === "internal" ? 1 : 0;
1930
+ selectedModeIndex = modeToIndex(launchMode);
1931
+ selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
1932
+ selectedResumeIndex = autoResume ? 0 : 1;
1933
+ // Immediately set @target when first agent is selected
1934
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
1935
+ targetAgent = activeAgents[selectedAgentIndex];
1936
+ updatePromptBox();
1937
+ }
1283
1938
  screen.grabKeys = true;
1284
1939
  renderDashboard();
1285
1940
  screen.program.hideCursor();
@@ -1288,33 +1943,190 @@ async function runChat(projectRoot) {
1288
1943
 
1289
1944
  function handleDashboardKey(key) {
1290
1945
  if (!key || focusMode !== "dashboard") return false;
1291
- if (dashboardView === "mode") {
1946
+
1947
+ // Agent TTY view dashboard navigation
1948
+ // Items: [ufoo(0), agent1(1), agent2(2), ...]
1949
+ if (currentView === "agent") {
1950
+ const totalItems = 1 + activeAgents.length; // ufoo + agents
1292
1951
  if (key.name === "left") {
1293
- selectedModeIndex = selectedModeIndex <= 0 ? 1 : 0;
1294
- renderDashboard();
1295
- screen.render();
1952
+ if (selectedAgentIndex > 0) {
1953
+ selectedAgentIndex--;
1954
+ }
1955
+ renderAgentDashboard();
1296
1956
  return true;
1297
1957
  }
1298
1958
  if (key.name === "right") {
1299
- selectedModeIndex = selectedModeIndex >= 1 ? 0 : 1;
1300
- renderDashboard();
1301
- screen.render();
1959
+ if (selectedAgentIndex < totalItems - 1) {
1960
+ selectedAgentIndex++;
1961
+ }
1962
+ renderAgentDashboard();
1302
1963
  return true;
1303
1964
  }
1304
- if (key.name === "up") {
1305
- dashboardView = "agents";
1306
- renderDashboard();
1307
- screen.render();
1965
+ if (key.name === "enter" || key.name === "return") {
1966
+ if (selectedAgentIndex === 0) {
1967
+ // "ufoo" selected -> exit agent view back to main chat
1968
+ exitAgentView();
1969
+ } else {
1970
+ // Another agent selected -> switch based on launch mode
1971
+ const agentId = activeAgents[selectedAgentIndex - 1];
1972
+ if (agentId && agentId !== viewingAgent) {
1973
+ const meta = activeAgentMetaMap.get(agentId);
1974
+ const agentLaunchMode = meta?.launch_mode || "";
1975
+
1976
+ if (agentLaunchMode === "tmux" || agentLaunchMode === "terminal") {
1977
+ // Exit PTY view, then activate agent's terminal/pane
1978
+ exitAgentView();
1979
+ try {
1980
+ const activator = new AgentActivator(projectRoot);
1981
+ activator.activate(agentId).catch(() => {});
1982
+ } catch { /* ignore */ }
1983
+ } else {
1984
+ // Internal mode: switch PTY view
1985
+ focusMode = "input";
1986
+ enterAgentView(agentId);
1987
+ }
1988
+ } else {
1989
+ // Same agent, just exit dashboard
1990
+ focusMode = "input";
1991
+ renderAgentDashboard();
1992
+ }
1993
+ }
1308
1994
  return true;
1309
1995
  }
1310
- if (key.name === "enter" || key.name === "return") {
1311
- const modes = ["terminal", "internal"];
1312
- setLaunchMode(modes[selectedModeIndex]);
1313
- exitDashboardMode(false);
1996
+ if (key.name === "up") {
1997
+ // Up exits dashboard back to agent PTY view
1998
+ focusMode = "input";
1999
+ renderAgentDashboard();
1314
2000
  return true;
1315
2001
  }
1316
- if (key.name === "escape") {
1317
- exitDashboardMode(false);
2002
+ if (key.name === "x" && key.ctrl) {
2003
+ // Ctrl+x: close selected agent (not ufoo)
2004
+ if (selectedAgentIndex > 0 && selectedAgentIndex <= activeAgents.length) {
2005
+ const agentId = activeAgents[selectedAgentIndex - 1];
2006
+ const label = getAgentLabel(agentId);
2007
+ // If closing the currently viewed agent, exit view first
2008
+ if (agentId === viewingAgent) {
2009
+ exitAgentView();
2010
+ }
2011
+ closeAgentViaDaemon(agentId, label);
2012
+ }
2013
+ return true;
2014
+ }
2015
+ return true;
2016
+ }
2017
+
2018
+ if (dashboardView === "mode") {
2019
+ const maxMode = launchModes.length - 1;
2020
+ if (key.name === "left") {
2021
+ selectedModeIndex = selectedModeIndex <= 0 ? maxMode : selectedModeIndex - 1;
2022
+ renderDashboard();
2023
+ screen.render();
2024
+ return true;
2025
+ }
2026
+ if (key.name === "right") {
2027
+ selectedModeIndex = selectedModeIndex >= maxMode ? 0 : selectedModeIndex + 1;
2028
+ renderDashboard();
2029
+ screen.render();
2030
+ return true;
2031
+ }
2032
+ if (key.name === "down") {
2033
+ dashboardView = "provider";
2034
+ selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
2035
+ renderDashboard();
2036
+ screen.render();
2037
+ return true;
2038
+ }
2039
+ if (key.name === "up") {
2040
+ dashboardView = "agents";
2041
+ // Restore @target when returning to agents page
2042
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
2043
+ targetAgent = activeAgents[selectedAgentIndex];
2044
+ updatePromptBox();
2045
+ }
2046
+ renderDashboard();
2047
+ screen.render();
2048
+ return true;
2049
+ }
2050
+ if (key.name === "enter" || key.name === "return") {
2051
+ setLaunchMode(launchModes[selectedModeIndex]);
2052
+ exitDashboardMode(false);
2053
+ return true;
2054
+ }
2055
+ if (key.name === "escape") {
2056
+ exitDashboardMode(false);
2057
+ return true;
2058
+ }
2059
+ return true;
2060
+ }
2061
+ if (dashboardView === "provider") {
2062
+ if (key.name === "left") {
2063
+ selectedProviderIndex = selectedProviderIndex <= 0 ? providerOptions.length - 1 : selectedProviderIndex - 1;
2064
+ renderDashboard();
2065
+ screen.render();
2066
+ return true;
2067
+ }
2068
+ if (key.name === "right") {
2069
+ selectedProviderIndex = selectedProviderIndex >= providerOptions.length - 1 ? 0 : selectedProviderIndex + 1;
2070
+ renderDashboard();
2071
+ screen.render();
2072
+ return true;
2073
+ }
2074
+ if (key.name === "down") {
2075
+ dashboardView = "resume";
2076
+ selectedResumeIndex = autoResume ? 0 : 1;
2077
+ renderDashboard();
2078
+ screen.render();
2079
+ return true;
2080
+ }
2081
+ if (key.name === "up") {
2082
+ dashboardView = "mode";
2083
+ renderDashboard();
2084
+ screen.render();
2085
+ return true;
2086
+ }
2087
+ if (key.name === "enter" || key.name === "return") {
2088
+ const selected = providerOptions[selectedProviderIndex];
2089
+ if (selected) setAgentProvider(selected.value);
2090
+ exitDashboardMode(false);
2091
+ return true;
2092
+ }
2093
+ if (key.name === "escape") {
2094
+ exitDashboardMode(false);
2095
+ return true;
2096
+ }
2097
+ return true;
2098
+ }
2099
+ if (dashboardView === "resume") {
2100
+ if (key.name === "left") {
2101
+ selectedResumeIndex = selectedResumeIndex <= 0 ? resumeOptions.length - 1 : selectedResumeIndex - 1;
2102
+ renderDashboard();
2103
+ screen.render();
2104
+ return true;
2105
+ }
2106
+ if (key.name === "right") {
2107
+ selectedResumeIndex = selectedResumeIndex >= resumeOptions.length - 1 ? 0 : selectedResumeIndex + 1;
2108
+ renderDashboard();
2109
+ screen.render();
2110
+ return true;
2111
+ }
2112
+ if (key.name === "up") {
2113
+ dashboardView = "provider";
2114
+ renderDashboard();
2115
+ screen.render();
2116
+ return true;
2117
+ }
2118
+ if (key.name === "enter" || key.name === "return") {
2119
+ const selected = resumeOptions[selectedResumeIndex];
2120
+ if (selected) {
2121
+ setAutoResume(selected.value);
2122
+ const label = selected.value ? "Auto" : "Off";
2123
+ logMessage("status", `{magenta-fg}⚙{/magenta-fg} Resume: ${label}`);
2124
+ }
2125
+ exitDashboardMode(false);
2126
+ return true;
2127
+ }
2128
+ if (key.name === "escape") {
2129
+ exitDashboardMode(false);
1318
2130
  return true;
1319
2131
  }
1320
2132
  return true;
@@ -1324,6 +2136,9 @@ async function runChat(projectRoot) {
1324
2136
  if (activeAgents.length > 0 && selectedAgentIndex > 0) {
1325
2137
  selectedAgentIndex--;
1326
2138
  clampAgentWindow();
2139
+ // Update @target in real-time as user navigates
2140
+ targetAgent = activeAgents[selectedAgentIndex];
2141
+ updatePromptBox();
1327
2142
  renderDashboard();
1328
2143
  screen.render();
1329
2144
  }
@@ -1333,24 +2148,72 @@ async function runChat(projectRoot) {
1333
2148
  if (activeAgents.length > 0 && selectedAgentIndex < activeAgents.length - 1) {
1334
2149
  selectedAgentIndex++;
1335
2150
  clampAgentWindow();
2151
+ // Update @target in real-time as user navigates
2152
+ targetAgent = activeAgents[selectedAgentIndex];
2153
+ updatePromptBox();
1336
2154
  renderDashboard();
1337
2155
  screen.render();
1338
2156
  }
1339
2157
  return true;
1340
2158
  }
1341
2159
  if (key.name === "down") {
2160
+ // Leaving agents page: clear temporary @target
2161
+ clearTargetAgent();
1342
2162
  dashboardView = "mode";
1343
- selectedModeIndex = launchMode === "internal" ? 1 : 0;
2163
+ selectedModeIndex = modeToIndex(launchMode);
1344
2164
  renderDashboard();
1345
2165
  screen.render();
1346
2166
  return true;
1347
2167
  }
1348
2168
  if (key.name === "up" || key.name === "escape") {
2169
+ // Cancel: clear @target, back to normal chat
2170
+ clearTargetAgent();
1349
2171
  exitDashboardMode(false);
1350
2172
  return true;
1351
2173
  }
2174
+ if (key.name === "x" && key.ctrl) {
2175
+ // Ctrl+x: close selected agent
2176
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
2177
+ const agentId = activeAgents[selectedAgentIndex];
2178
+ const label = getAgentLabel(agentId);
2179
+ closeAgentViaDaemon(agentId, label);
2180
+ clearTargetAgent();
2181
+ exitDashboardMode(false);
2182
+ }
2183
+ return true;
2184
+ }
1352
2185
  if (key.name === "enter" || key.name === "return") {
1353
- exitDashboardMode(true);
2186
+ // Enter: action depends on agent's launch mode
2187
+ if (selectedAgentIndex >= 0 && selectedAgentIndex < activeAgents.length) {
2188
+ const agentId = activeAgents[selectedAgentIndex];
2189
+ const meta = activeAgentMetaMap.get(agentId);
2190
+ const agentLaunchMode = meta?.launch_mode || "";
2191
+
2192
+ if (agentLaunchMode === "tmux" || agentLaunchMode === "terminal") {
2193
+ // Tmux: select pane; Terminal: activate tab/window by tty
2194
+ clearTargetAgent();
2195
+ exitDashboardMode(false);
2196
+ try {
2197
+ const activator = new AgentActivator(projectRoot);
2198
+ activator.activate(agentId).catch(() => {});
2199
+ } catch { /* ignore */ }
2200
+ return true;
2201
+ }
2202
+
2203
+ // Internal / internal-pty mode: enter PTY view if inject.sock exists
2204
+ const sockPath = getInjectSockPath(agentId);
2205
+ if (fs.existsSync(sockPath)) {
2206
+ clearTargetAgent();
2207
+ focusMode = "input";
2208
+ dashboardView = "agents";
2209
+ selectedAgentIndex = -1;
2210
+ screen.grabKeys = false;
2211
+ enterAgentView(agentId);
2212
+ return true;
2213
+ }
2214
+ }
2215
+ // Fallback: just exit dashboard, keep @target for messaging
2216
+ exitDashboardMode(false);
1354
2217
  return true;
1355
2218
  }
1356
2219
  return false;
@@ -1376,96 +2239,437 @@ async function runChat(projectRoot) {
1376
2239
  screen.render();
1377
2240
  }
1378
2241
 
2242
+ function getInjectSockPath(agentId) {
2243
+ const safeName = subscriberToSafeName(agentId);
2244
+ return path.join(getUfooPaths(projectRoot).busQueuesDir, safeName, "inject.sock");
2245
+ }
2246
+
2247
+ function closeAgentViaDaemon(agentId, label) {
2248
+ logMessage("system", `{yellow-fg}⚙{/yellow-fg} Closing ${label}...`);
2249
+ const sockFile = socketPath(projectRoot);
2250
+ try {
2251
+ const conn = net.createConnection(sockFile, () => {
2252
+ conn.write(JSON.stringify({ type: "close_agent", agentId }) + "\n");
2253
+ });
2254
+ let buffer = "";
2255
+ conn.on("data", (data) => {
2256
+ buffer += data.toString("utf8");
2257
+ const lines = buffer.split("\n");
2258
+ buffer = lines.pop() || "";
2259
+ for (const line of lines) {
2260
+ if (!line.trim()) continue;
2261
+ try {
2262
+ const res = JSON.parse(line);
2263
+ if (res.type === "close_agent_ok") {
2264
+ if (res.ok) {
2265
+ logMessage("system", `{green-fg}✓{/green-fg} Closed ${label}`);
2266
+ } else {
2267
+ logMessage("system", `{red-fg}✗{/red-fg} Agent ${label} not found or already stopped`);
2268
+ }
2269
+ }
2270
+ } catch { /* ignore */ }
2271
+ }
2272
+ });
2273
+ conn.on("error", () => {
2274
+ logMessage("error", `{red-fg}✗{/red-fg} Failed to connect to daemon`);
2275
+ });
2276
+ setTimeout(() => { try { conn.destroy(); } catch {} }, 3000);
2277
+ } catch {
2278
+ logMessage("error", `{red-fg}✗{/red-fg} Failed to close ${label}`);
2279
+ }
2280
+ }
2281
+
2282
+ // Freeze blessed rendering during agent PTY view (direct stdout mode)
2283
+ const _originalRender = screen.render.bind(screen);
2284
+ let renderFrozen = false;
2285
+ screen.render = function() {
2286
+ if (renderFrozen) return;
2287
+ return _originalRender();
2288
+ };
2289
+
2290
+ // Render agent view dashboard bar via ANSI — matches blessed dashboard style
2291
+ function renderAgentDashboard() {
2292
+ const rows = process.stdout.rows || 24;
2293
+ const cols = process.stdout.columns || 80;
2294
+ let bar = " ";
2295
+
2296
+ if (focusMode === "dashboard") {
2297
+ // Dashboard mode: \x1b[90;7m = gray+inverse, matches blessed {inverse} on gray fg widget
2298
+ const ufooItem = selectedAgentIndex === 0
2299
+ ? "\x1b[90;7mufoo\x1b[0m"
2300
+ : "\x1b[36mufoo\x1b[0m";
2301
+ const agentParts = activeAgents.map((agent, i) => {
2302
+ const label = getAgentLabel(agent);
2303
+ const idx = i + 1; // +1 for ufoo at index 0
2304
+ if (idx === selectedAgentIndex) return `\x1b[90;7m${label}\x1b[0m`;
2305
+ if (agent === viewingAgent) return `\x1b[1;36m${label}\x1b[0m`;
2306
+ return `\x1b[36m${label}\x1b[0m`;
2307
+ });
2308
+ bar += `${ufooItem} ${agentParts.join(" ")}`;
2309
+ bar += ` \x1b[90m│ ←/→ select, Enter switch, ^X close, ↑ back\x1b[0m`;
2310
+ } else {
2311
+ // Normal PTY mode: bold current viewing agent
2312
+ const agentParts = activeAgents.map((agent) => {
2313
+ const label = getAgentLabel(agent);
2314
+ if (agent === viewingAgent) return `\x1b[1;36m${label}\x1b[0m`;
2315
+ return `\x1b[36m${label}\x1b[0m`;
2316
+ });
2317
+ bar += `\x1b[36mufoo\x1b[0m ${agentParts.join(" ")}`;
2318
+ bar += ` \x1b[90m│ ↓: agents\x1b[0m`;
2319
+ }
2320
+
2321
+ // Pad to full width
2322
+ const plainLen = bar.replace(/\x1b\[[0-9;]*m/g, "").length;
2323
+ const pad = Math.max(0, cols - plainLen);
2324
+ // Save cursor → move to last row → write bar → restore cursor
2325
+ process.stdout.write(`\x1b7\x1b[${rows};1H${bar}${" ".repeat(pad)}\x1b8`);
2326
+ }
2327
+
2328
+ function enterAgentView(agentId) {
2329
+ if (currentView === "agent" && viewingAgent === agentId) return;
2330
+ if (currentView === "agent") {
2331
+ disconnectAgentOutput();
2332
+ disconnectAgentInput();
2333
+ }
2334
+
2335
+ currentView = "agent";
2336
+ viewingAgent = agentId;
2337
+ focusMode = "input";
2338
+
2339
+ // Detach all blessed widgets from screen — nothing left to render
2340
+ _detachedChildren = [...screen.children];
2341
+ for (const child of _detachedChildren) screen.remove(child);
2342
+
2343
+ // Freeze blessed — we take over the terminal with direct stdout
2344
+ renderFrozen = true;
2345
+
2346
+ const rows = process.stdout.rows || 24;
2347
+ const cols = process.stdout.columns || 80;
2348
+ process.stdout.write("\x1b[2J\x1b[H"); // Clear + home
2349
+ process.stdout.write(`\x1b[1;${rows - 1}r`); // Scroll region
2350
+ process.stdout.write("\x1b[H"); // Cursor to top
2351
+ process.stdout.write("\x1b[?25h"); // Show cursor
2352
+
2353
+ // Render dashboard bar
2354
+ renderAgentDashboard();
2355
+
2356
+ // Suppress input forwarding briefly — prevents the Enter that triggered
2357
+ // view switch and any terminal query responses (CPR etc) from leaking
2358
+ agentInputSuppressUntil = Date.now() + 300;
2359
+
2360
+ // Connect to agent's inject.sock for output streaming and input
2361
+ const sockPath = getInjectSockPath(agentId);
2362
+ connectAgentOutput(sockPath);
2363
+ connectAgentInput(sockPath);
2364
+
2365
+ // Resize agent PTY to match our viewport (rows-1 for status bar)
2366
+ setTimeout(() => sendResizeToAgent(cols, rows - 1), 100);
2367
+ }
2368
+
2369
+ function exitAgentView() {
2370
+ if (currentView !== "agent") return;
2371
+
2372
+ // Restore agent PTY to full terminal size before disconnecting
2373
+ const rows = process.stdout.rows || 24;
2374
+ const cols = process.stdout.columns || 80;
2375
+ sendResizeToAgent(cols, rows);
2376
+
2377
+ disconnectAgentOutput();
2378
+ disconnectAgentInput();
2379
+
2380
+ currentView = "main";
2381
+ viewingAgent = null;
2382
+
2383
+ // Reset scroll region to full screen
2384
+ process.stdout.write(`\x1b[1;${rows}r`);
2385
+ process.stdout.write("\x1b[2J\x1b[H");
2386
+
2387
+ // Re-attach all blessed widgets to screen
2388
+ if (_detachedChildren) {
2389
+ for (const child of _detachedChildren) screen.append(child);
2390
+ _detachedChildren = null;
2391
+ }
2392
+
2393
+ // Unfreeze blessed and force full redraw
2394
+ renderFrozen = false;
2395
+ focusMode = "input";
2396
+ dashboardView = "agents";
2397
+ selectedAgentIndex = -1;
2398
+ screen.grabKeys = false;
2399
+ clearTargetAgent();
2400
+ renderDashboard();
2401
+ focusInput();
2402
+ resizeInput();
2403
+ screen.alloc();
2404
+ screen.render();
2405
+ }
2406
+
2407
+ function connectAgentOutput(sockPath) {
2408
+ if (agentOutputClient) {
2409
+ disconnectAgentOutput();
2410
+ }
2411
+ agentOutputBuffer = "";
2412
+
2413
+ if (!fs.existsSync(sockPath)) {
2414
+ writeToAgentTerm("\x1b[1;31m[Error]\x1b[0m inject.sock not found\r\n");
2415
+ writeToAgentTerm("\x1b[33m[Hint]\x1b[0m Agent may not be running in terminal mode\r\n");
2416
+ writeToAgentTerm("Press Esc to return\r\n");
2417
+ return;
2418
+ }
2419
+
2420
+ try {
2421
+ agentOutputClient = net.createConnection(sockPath, () => {
2422
+ agentOutputClient.write(JSON.stringify({ type: "subscribe" }) + "\n");
2423
+ });
2424
+
2425
+ // Connection timeout
2426
+ const connectTimeout = setTimeout(() => {
2427
+ if (agentOutputClient && !agentOutputClient.connecting) return;
2428
+ writeToAgentTerm("\x1b[1;31m[Timeout]\x1b[0m Could not connect\r\nPress Esc to return\r\n");
2429
+ disconnectAgentOutput();
2430
+ }, 5000);
2431
+
2432
+ agentOutputClient.on("connect", () => {
2433
+ clearTimeout(connectTimeout);
2434
+ });
2435
+
2436
+ agentOutputClient.on("data", (data) => {
2437
+ agentOutputBuffer += data.toString("utf8");
2438
+ const lines = agentOutputBuffer.split("\n");
2439
+ agentOutputBuffer = lines.pop() || "";
2440
+
2441
+ for (const line of lines) {
2442
+ if (!line.trim()) continue;
2443
+ try {
2444
+ const msg = JSON.parse(line);
2445
+ if (msg.type === "output" || msg.type === "replay") {
2446
+ if (msg.data) {
2447
+ writeToAgentTerm(msg.data);
2448
+ }
2449
+ }
2450
+ } catch {
2451
+ // ignore malformed messages
2452
+ }
2453
+ }
2454
+ });
2455
+
2456
+ agentOutputClient.on("error", (err) => {
2457
+ if (currentView === "agent") {
2458
+ writeToAgentTerm(`\r\n\x1b[1;31m[Connection error]\x1b[0m ${err.message}\r\nPress Esc to return\r\n`);
2459
+ }
2460
+ });
2461
+
2462
+ agentOutputClient.on("close", () => {
2463
+ agentOutputClient = null;
2464
+ if (currentView === "agent") {
2465
+ writeToAgentTerm("\r\n\x1b[1;33m[Agent disconnected]\x1b[0m\r\nPress Esc to return\r\n");
2466
+ }
2467
+ });
2468
+ } catch (err) {
2469
+ writeToAgentTerm(`\x1b[1;31m[Error]\x1b[0m ${err.message}\r\nPress Esc to return\r\n`);
2470
+ }
2471
+ }
2472
+
2473
+ function disconnectAgentOutput() {
2474
+ if (agentOutputClient) {
2475
+ try {
2476
+ agentOutputClient.removeAllListeners();
2477
+ agentOutputClient.destroy();
2478
+ } catch { /* ignore */ }
2479
+ agentOutputClient = null;
2480
+ }
2481
+ agentOutputBuffer = "";
2482
+ }
2483
+
2484
+ function connectAgentInput(sockPath) {
2485
+ if (agentInputClient) {
2486
+ disconnectAgentInput();
2487
+ }
2488
+ try {
2489
+ agentInputClient = net.createConnection(sockPath);
2490
+ agentInputClient.on("error", () => {
2491
+ agentInputClient = null;
2492
+ });
2493
+ agentInputClient.on("close", () => {
2494
+ agentInputClient = null;
2495
+ });
2496
+ } catch {
2497
+ agentInputClient = null;
2498
+ }
2499
+ }
2500
+
2501
+ function disconnectAgentInput() {
2502
+ if (agentInputClient) {
2503
+ try {
2504
+ agentInputClient.removeAllListeners();
2505
+ agentInputClient.destroy();
2506
+ } catch { /* ignore */ }
2507
+ agentInputClient = null;
2508
+ }
2509
+ }
2510
+
2511
+ function sendRawToAgent(data) {
2512
+ if (!agentInputClient || agentInputClient.destroyed) return;
2513
+ try {
2514
+ agentInputClient.write(JSON.stringify({ type: "raw", data }) + "\n");
2515
+ } catch {
2516
+ // ignore write errors
2517
+ }
2518
+ }
2519
+
2520
+ function sendResizeToAgent(cols, rows) {
2521
+ if (!agentInputClient || agentInputClient.destroyed) return;
2522
+ try {
2523
+ agentInputClient.write(JSON.stringify({ type: "resize", cols, rows }) + "\n");
2524
+ } catch {
2525
+ // ignore write errors
2526
+ }
2527
+ }
2528
+
2529
+ function writeToAgentTerm(text) {
2530
+ if (!text) return;
2531
+ if (currentView === "agent") {
2532
+ // Strip sequences that cause the real terminal to respond, feeding
2533
+ // garbage back into the agent's input:
2534
+ // - OSC queries: \x1b]10;?\x07 etc (color queries)
2535
+ // - CSI DSR: \x1b[6n / \x1b[?6n (cursor position query → CPR response)
2536
+ // - CSI DSR: \x1b[5n (device status query)
2537
+ // - CSI DA: \x1b[c / \x1b[>c / \x1b[=c (device attributes query)
2538
+ const cleaned = text
2539
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
2540
+ .replace(/\x1b\[(?:[?>=]?[0-9]*c|[?]?6n|5n)/g, "");
2541
+ if (cleaned) process.stdout.write(cleaned);
2542
+ // Always re-render dashboard bar — PTY output may overwrite it
2543
+ // via absolute cursor positioning before the resize takes effect
2544
+ renderAgentDashboard();
2545
+ }
2546
+ }
2547
+
1379
2548
  function requestStatus() {
1380
2549
  send({ type: "status" });
1381
2550
  }
1382
2551
 
1383
- let buffer = "";
1384
- client.on("data", (data) => {
1385
- buffer += data.toString("utf8");
1386
- const lines = buffer.split(/\r?\n/);
1387
- buffer = lines.pop() || "";
1388
- for (const line of lines.filter((l) => l.trim())) {
1389
- try {
2552
+ const detachClient = () => {
2553
+ if (!client) return;
2554
+ client.removeAllListeners("data");
2555
+ client.removeAllListeners("close");
2556
+ try {
2557
+ client.end();
2558
+ client.destroy();
2559
+ } catch {
2560
+ // ignore
2561
+ }
2562
+ };
2563
+
2564
+ const attachClient = (newClient) => {
2565
+ if (!newClient) return;
2566
+ detachClient();
2567
+ client = newClient;
2568
+ connectionLostNotified = false;
2569
+ let buffer = "";
2570
+ client.on("data", (data) => {
2571
+ buffer += data.toString("utf8");
2572
+ const lines = buffer.split(/\r?\n/);
2573
+ buffer = lines.pop() || "";
2574
+ for (const line of lines.filter((l) => l.trim())) {
2575
+ try {
1390
2576
  const msg = JSON.parse(line);
1391
- if (msg.type === "status") {
1392
- const data = msg.data || {};
1393
- if (typeof data.phase === "string") {
1394
- const text = data.text || "";
1395
- const item = { key: data.key, text };
1396
- if (data.phase === "start") {
1397
- enqueueBusStatus(item);
1398
- } else if (data.phase === "done" || data.phase === "error") {
1399
- resolveBusStatus(item);
1400
- if (text) {
1401
- const prefix = data.phase === "error"
1402
- ? "{red-fg}✗{/red-fg}"
1403
- : "{green-fg}✓{/green-fg}";
1404
- logMessage("status", `${prefix} ${text}`, data);
1405
- }
1406
- } else {
1407
- enqueueBusStatus(item);
2577
+ if (msg.type === "status") {
2578
+ const data = msg.data || {};
2579
+ if (typeof data.phase === "string") {
2580
+ const rawText = data.text == null ? "" : String(data.text);
2581
+ const item = { key: data.key, text: rawText };
2582
+ if (data.phase === "start") {
2583
+ enqueueBusStatus(item);
2584
+ } else if (data.phase === "done" || data.phase === "error") {
2585
+ resolveBusStatus(item);
2586
+ if (rawText) {
2587
+ const prefix = data.phase === "error"
2588
+ ? "{red-fg}✗{/red-fg}"
2589
+ : "{green-fg}✓{/green-fg}";
2590
+ logMessage("status", `${prefix} ${escapeBlessed(rawText)}`, data);
2591
+ }
2592
+ } else {
2593
+ enqueueBusStatus(item);
2594
+ }
2595
+ screen.render();
2596
+ } else {
2597
+ // 收到 dashboard 状态更新
2598
+ if (process.env.UFOO_DEBUG) {
2599
+ logMessage("debug", `[status] active: ${(data.active || []).length}`);
1408
2600
  }
1409
- screen.render();
1410
- } else {
1411
2601
  updateDashboard(data);
1412
2602
  }
1413
- } else if (msg.type === "response") {
1414
- const payload = msg.data || {};
1415
- if (payload.reply) {
1416
- resolveStatusLine(`{green-fg}←{/green-fg} ${payload.reply}`);
1417
- logMessage("reply", `{green-fg}←{/green-fg} ${payload.reply}`);
1418
- }
1419
- if (payload.dispatch && payload.dispatch.length > 0) {
1420
- logMessage("dispatch", `{blue-fg}→{/blue-fg} Dispatched to: ${payload.dispatch.map(d => d.target || d).join(", ")}`);
1421
- }
1422
- if (payload.disambiguate && Array.isArray(payload.disambiguate.candidates) && payload.disambiguate.candidates.length > 0) {
1423
- pending = { disambiguate: payload.disambiguate, original: pending?.original };
1424
- resolveStatusLine(`{yellow-fg}?{/yellow-fg} ${payload.disambiguate.prompt || "Choose target:"}`);
1425
- logMessage("disambiguate", `{yellow-fg}?{/yellow-fg} ${payload.disambiguate.prompt || "Choose target:"}`);
1426
- payload.disambiguate.candidates.forEach((c, i) => {
1427
- logMessage("disambiguate", ` {cyan-fg}${i + 1}){/cyan-fg} ${c.agent_id} {gray-fg}— ${c.reason || ""}{/gray-fg}`);
1428
- });
1429
- } else {
1430
- pending = null;
1431
- }
2603
+ } else if (msg.type === "response") {
2604
+ const payload = msg.data || {};
2605
+ if (payload.reply) {
2606
+ resolveStatusLine(`{green-fg}←{/green-fg} ${escapeBlessed(payload.reply)}`);
2607
+ logMessage("reply", `{green-fg}←{/green-fg} ${escapeBlessed(payload.reply)}`);
2608
+ }
2609
+ if (payload.dispatch && payload.dispatch.length > 0) {
2610
+ const targets = payload.dispatch.map((d) => d.target || d).join(", ");
2611
+ logMessage("dispatch", `{blue-fg}→{/blue-fg} Dispatched to: ${escapeBlessed(targets)}`);
2612
+ }
2613
+ if (payload.disambiguate && Array.isArray(payload.disambiguate.candidates) && payload.disambiguate.candidates.length > 0) {
2614
+ pending = { disambiguate: payload.disambiguate, original: pending?.original };
2615
+ const prompt = payload.disambiguate.prompt || "Choose target:";
2616
+ resolveStatusLine(`{yellow-fg}?{/yellow-fg} ${escapeBlessed(prompt)}`);
2617
+ logMessage("disambiguate", `{yellow-fg}?{/yellow-fg} ${escapeBlessed(prompt)}`);
2618
+ payload.disambiguate.candidates.forEach((c, i) => {
2619
+ const agentId = c.agent_id || "";
2620
+ const reason = c.reason || "";
2621
+ logMessage(
2622
+ "disambiguate",
2623
+ ` {cyan-fg}${i + 1}){/cyan-fg} ${escapeBlessed(agentId)} {gray-fg}— ${escapeBlessed(reason)}{/gray-fg}`
2624
+ );
2625
+ });
2626
+ } else {
2627
+ pending = null;
2628
+ }
1432
2629
  if (!payload.reply && !payload.disambiguate) {
1433
2630
  resolveStatusLine("{gray-fg}✓{/gray-fg} Done");
1434
2631
  }
1435
- if (msg.opsResults && msg.opsResults.length > 0) {
1436
- logMessage("ops", `{magenta-fg}⚡{/magenta-fg} ${JSON.stringify(msg.opsResults)}`);
1437
- }
2632
+ // opsResults are noisy JSON; keep them out of the log UI
1438
2633
  screen.render();
1439
- } else if (msg.type === "bus") {
1440
- const data = msg.data || {};
1441
- const prefix = data.event === "broadcast" ? "{magenta-fg}⇢{/magenta-fg}" : "{blue-fg}↔{/blue-fg}";
1442
- let publisher = data.publisher && data.publisher !== "unknown"
1443
- ? data.publisher
1444
- : (data.event === "broadcast" ? "broadcast" : "bus");
2634
+ } else if (msg.type === "bus") {
2635
+ const data = msg.data || {};
2636
+ const prefix = data.event === "broadcast" ? "{magenta-fg}⇢{/magenta-fg}" : "{blue-fg}↔{/blue-fg}";
2637
+ let publisher = data.publisher && data.publisher !== "unknown"
2638
+ ? data.publisher
2639
+ : (data.event === "broadcast" ? "broadcast" : "bus");
1445
2640
 
1446
2641
  // Try to parse message as JSON (from internal agents)
1447
- let displayMessage = data.message || "";
1448
- try {
1449
- const parsed = JSON.parse(data.message);
1450
- if (parsed && typeof parsed === "object" && parsed.reply) {
1451
- displayMessage = parsed.reply;
1452
- }
1453
- } catch {
1454
- // Not JSON, use as-is
2642
+ let displayMessage = data.message == null ? "" : String(data.message);
2643
+ let isStream = false;
2644
+ try {
2645
+ const parsed = JSON.parse(data.message);
2646
+ if (parsed && typeof parsed === "object" && parsed.reply) {
2647
+ displayMessage = parsed.reply == null ? "" : String(parsed.reply);
2648
+ } else if (parsed && typeof parsed === "object" && parsed.stream) {
2649
+ displayMessage = typeof parsed.delta === "string" ? parsed.delta : "";
2650
+ isStream = true;
2651
+ }
2652
+ } catch {
2653
+ // Not JSON, use as-is
2654
+ }
2655
+
2656
+ // Convert literal \n to actual newlines for better display
2657
+ if (typeof displayMessage === "string") {
2658
+ displayMessage = displayMessage.replace(/\\n/g, "\n");
1455
2659
  }
1456
2660
 
1457
2661
  // Extract nickname if publisher is in subscriber:id format
1458
- let displayName = publisher;
1459
- if (publisher.includes(":")) {
1460
- // Try to get nickname from activeAgentLabelMap or bus.json
2662
+ let displayName = publisher;
2663
+ if (publisher.includes(":")) {
2664
+ // Try to get nickname from activeAgentLabelMap or all-agents.json
1461
2665
  if (activeAgentLabelMap && activeAgentLabelMap.has(publisher)) {
1462
2666
  displayName = activeAgentLabelMap.get(publisher);
1463
2667
  } else {
1464
- // Fallback: read directly from bus.json
2668
+ // Fallback: read directly from all-agents.json
1465
2669
  try {
1466
- const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
2670
+ const busPath = getUfooPaths(projectRoot).agentsFile;
1467
2671
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
1468
- const meta = bus.subscribers && bus.subscribers[publisher];
2672
+ const meta = bus.agents && bus.agents[publisher];
1469
2673
  if (meta && meta.nickname) {
1470
2674
  displayName = meta.nickname;
1471
2675
  }
@@ -1473,30 +2677,436 @@ async function runChat(projectRoot) {
1473
2677
  // Keep original publisher ID
1474
2678
  }
1475
2679
  }
1476
- }
2680
+ }
1477
2681
 
1478
- const line = `${prefix} {gray-fg}${displayName}{/gray-fg}: ${displayMessage}`;
1479
- logMessage("bus", line, data);
1480
- if (data.event === "agent_renamed") {
2682
+ const line = `${prefix} {gray-fg}${escapeBlessed(displayName)}{/gray-fg}: ${escapeBlessed(displayMessage)}`;
2683
+ if (isStream) {
2684
+ recordLog("bus_stream", line, data, true);
2685
+ } else {
2686
+ logMessage("bus", line, data);
2687
+ }
2688
+ if (data.event === "agent_renamed" || data.event === "message") {
2689
+ // 收到消息时刷新 status,更新在线 agent 列表
1481
2690
  requestStatus();
1482
2691
  }
1483
2692
  screen.render();
1484
- } else if (msg.type === "error") {
1485
- resolveStatusLine(`{red-fg}✗{/red-fg} Error: ${msg.error}`);
1486
- logMessage("error", `{red-fg}✗{/red-fg} Error: ${msg.error}`);
1487
- screen.render();
1488
- }
2693
+ } else if (msg.type === "error") {
2694
+ resolveStatusLine(`{red-fg}✗{/red-fg} Error: ${escapeBlessed(msg.error)}`);
2695
+ logMessage("error", `{red-fg}✗{/red-fg} Error: ${escapeBlessed(msg.error)}`);
2696
+ screen.render();
2697
+ }
1489
2698
  } catch {
1490
2699
  // ignore
1491
2700
  }
1492
2701
  }
1493
2702
  });
2703
+ const handleDisconnect = () => {
2704
+ if (client === newClient) {
2705
+ client = null;
2706
+ }
2707
+ if (exitRequested) return;
2708
+ if (!connectionLostNotified) {
2709
+ connectionLostNotified = true;
2710
+ logMessage("status", "{red-fg}✗{/red-fg} Daemon disconnected");
2711
+ }
2712
+ void ensureConnected();
2713
+ };
2714
+ client.on("close", handleDisconnect);
2715
+ client.on("error", handleDisconnect);
2716
+ flushPendingRequests();
2717
+ };
2718
+
2719
+ attachClient(client);
2720
+
2721
+ // Command handlers
2722
+ async function handleDoctorCommand() {
2723
+ logMessage("system", "{yellow-fg}⚙{/yellow-fg} Running health check...");
2724
+
2725
+ // Capture console output safely
2726
+ const originalLog = console.log;
2727
+ const originalError = console.error;
2728
+
2729
+ console.log = (...args) => logMessage("system", args.join(" "));
2730
+ console.error = (...args) => logMessage("error", args.join(" "));
2731
+
2732
+ try {
2733
+ const UfooDoctor = require("../doctor");
2734
+ const doctor = new UfooDoctor(projectRoot);
2735
+ const result = doctor.run();
2736
+
2737
+ if (result) {
2738
+ logMessage("system", "{green-fg}✓{/green-fg} System healthy");
2739
+ } else {
2740
+ logMessage("error", "{red-fg}✗{/red-fg} Health check failed");
2741
+ }
2742
+ screen.render();
2743
+ } catch (err) {
2744
+ logMessage("error", `{red-fg}✗{/red-fg} Doctor check failed: ${err.message}`);
2745
+ screen.render();
2746
+ } finally {
2747
+ console.log = originalLog;
2748
+ console.error = originalError;
2749
+ }
2750
+ }
2751
+
2752
+ async function handleStatusCommand() {
2753
+ // Display current status directly instead of requesting
2754
+ if (activeAgents.length === 0) {
2755
+ logMessage("system", "{cyan-fg}Status:{/cyan-fg} No active agents");
2756
+ } else {
2757
+ logMessage("system", `{cyan-fg}Status:{/cyan-fg} ${activeAgents.length} active agent(s)`);
2758
+ for (const id of activeAgents) {
2759
+ const label = getAgentLabel(id);
2760
+ const meta = activeAgentMetaMap.get(id);
2761
+ const mode = meta?.launch_mode || "unknown";
2762
+ logMessage("system", ` • {cyan-fg}${label}{/cyan-fg} {gray-fg}[${mode}]{/gray-fg}`);
2763
+ }
2764
+ }
2765
+
2766
+ // Also show daemon status
2767
+ if (isRunning(projectRoot)) {
2768
+ logMessage("system", "{green-fg}✓{/green-fg} Daemon is running");
2769
+ } else {
2770
+ logMessage("system", "{red-fg}✗{/red-fg} Daemon is not running");
2771
+ }
2772
+ }
2773
+
2774
+ async function handleDaemonCommand(args) {
2775
+ const subcommand = args[0];
2776
+
2777
+ if (subcommand === "start") {
2778
+ if (isRunning(projectRoot)) {
2779
+ logMessage("system", "{yellow-fg}⚠{/yellow-fg} Daemon already running");
2780
+ } else {
2781
+ logMessage("system", "{yellow-fg}⚙{/yellow-fg} Starting daemon...");
2782
+ startDaemon(projectRoot);
2783
+ await new Promise(r => setTimeout(r, 1000));
2784
+ if (isRunning(projectRoot)) {
2785
+ logMessage("system", "{green-fg}✓{/green-fg} Daemon started");
2786
+ } else {
2787
+ logMessage("error", "{red-fg}✗{/red-fg} Failed to start daemon");
2788
+ }
2789
+ }
2790
+ } else if (subcommand === "stop") {
2791
+ logMessage("system", "{yellow-fg}⚙{/yellow-fg} Stopping daemon...");
2792
+ stopDaemon(projectRoot);
2793
+ await new Promise(r => setTimeout(r, 1000));
2794
+ if (!isRunning(projectRoot)) {
2795
+ logMessage("system", "{green-fg}✓{/green-fg} Daemon stopped");
2796
+ } else {
2797
+ logMessage("error", "{red-fg}✗{/red-fg} Failed to stop daemon");
2798
+ }
2799
+ } else if (subcommand === "restart") {
2800
+ logMessage("system", "{yellow-fg}⚙{/yellow-fg} Restarting daemon...");
2801
+ await restartDaemon();
2802
+ } else if (subcommand === "status") {
2803
+ if (isRunning(projectRoot)) {
2804
+ logMessage("system", "{green-fg}✓{/green-fg} Daemon is running");
2805
+ } else {
2806
+ logMessage("system", "{red-fg}✗{/red-fg} Daemon is not running");
2807
+ }
2808
+ } else {
2809
+ logMessage("error", "{red-fg}✗{/red-fg} Unknown daemon command. Use: start, stop, restart, status");
2810
+ }
2811
+ }
2812
+
2813
+ async function handleInitCommand(args) {
2814
+ logMessage("system", "{yellow-fg}⚙{/yellow-fg} Initializing ufoo modules...");
2815
+
2816
+ // Capture console output safely
2817
+ const originalLog = console.log;
2818
+ const originalError = console.error;
2819
+ const logs = [];
2820
+
2821
+ console.log = (...args) => {
2822
+ const msg = args.join(" ");
2823
+ logs.push(msg);
2824
+ // Also output to logMessage immediately to avoid UI blocking
2825
+ logMessage("system", msg);
2826
+ };
2827
+ console.error = (...args) => {
2828
+ const msg = args.join(" ");
2829
+ logs.push(`ERROR: ${msg}`);
2830
+ logMessage("error", msg);
2831
+ };
2832
+
2833
+ try {
2834
+ const repoRoot = path.join(__dirname, "..", "..");
2835
+ const init = new UfooInit(repoRoot);
2836
+ const modules = args.length > 0 ? args.join(",") : "context,bus";
2837
+ await init.init({ modules, project: projectRoot });
2838
+
2839
+ logMessage("system", "{green-fg}✓{/green-fg} Initialization complete");
2840
+ screen.render();
2841
+ } catch (err) {
2842
+ logMessage("error", `{red-fg}✗{/red-fg} Init failed: ${err.message}`);
2843
+ if (err.stack) {
2844
+ logMessage("error", err.stack);
2845
+ }
2846
+ screen.render();
2847
+ } finally {
2848
+ console.log = originalLog;
2849
+ console.error = originalError;
2850
+ }
2851
+ }
2852
+
2853
+ async function handleBusCommand(args) {
2854
+ const subcommand = args[0];
2855
+
2856
+ try {
2857
+ if (subcommand === "send") {
2858
+ if (args.length < 3) {
2859
+ logMessage("error", "{red-fg}✗{/red-fg} Usage: /bus send <target> <message>");
2860
+ return;
2861
+ }
2862
+ const target = args[1];
2863
+ const message = args.slice(2).join(" ");
2864
+ // Send via daemon to ensure proper publisher ID
2865
+ send({ type: "bus_send", target, message });
2866
+ logMessage("system", `{green-fg}✓{/green-fg} Message sent to ${target}`);
2867
+ return;
2868
+ }
2869
+
2870
+ const bus = new EventBus(projectRoot);
2871
+
2872
+ if (subcommand === "rename") {
2873
+ if (args.length < 3) {
2874
+ logMessage("error", "{red-fg}✗{/red-fg} Usage: /bus rename <agent> <nickname>");
2875
+ return;
2876
+ }
2877
+ const agentId = args[1];
2878
+ const nickname = args[2];
2879
+ await bus.rename(agentId, nickname);
2880
+ logMessage("system", `{green-fg}✓{/green-fg} Renamed ${agentId} to ${nickname}`);
2881
+ requestStatus();
2882
+ } else if (subcommand === "list") {
2883
+ bus.ensureBus();
2884
+ bus.loadBusData();
2885
+ const subscribers = Object.entries(bus.busData.agents || {});
2886
+ if (subscribers.length === 0) {
2887
+ logMessage("system", "{gray-fg}No active agents{/gray-fg}");
2888
+ } else {
2889
+ logMessage("system", "{cyan-fg}Active agents:{/cyan-fg}");
2890
+ for (const [id, meta] of subscribers) {
2891
+ const nickname = meta.nickname ? ` (${meta.nickname})` : "";
2892
+ const status = meta.status || "unknown";
2893
+ logMessage("system", ` • ${id}${nickname} {gray-fg}[${status}]{/gray-fg}`);
2894
+ }
2895
+ }
2896
+ } else if (subcommand === "status") {
2897
+ bus.ensureBus();
2898
+ bus.loadBusData();
2899
+ const count = Object.keys(bus.busData.agents || {}).length;
2900
+ logMessage("system", `{cyan-fg}Bus status:{/cyan-fg} ${count} agent(s) registered`);
2901
+ } else if (subcommand === "activate") {
2902
+ if (args.length < 2) {
2903
+ logMessage("error", "{red-fg}✗{/red-fg} Usage: /bus activate <agent>");
2904
+ return;
2905
+ }
2906
+ const target = args[1];
2907
+ const AgentActivator = require("../bus/activate");
2908
+ const activator = new AgentActivator(projectRoot);
2909
+ await activator.activate(target);
2910
+ logMessage("system", `{green-fg}✓{/green-fg} Activated ${target}`);
2911
+ } else {
2912
+ logMessage("error", "{red-fg}✗{/red-fg} Unknown bus command. Use: send, rename, list, status, activate");
2913
+ }
2914
+ } catch (err) {
2915
+ logMessage("error", `{red-fg}✗{/red-fg} Bus command failed: ${err.message}`);
2916
+ }
2917
+ }
2918
+
2919
+ async function handleCtxCommand(args) {
2920
+ logMessage("system", "{yellow-fg}⚙{/yellow-fg} Running context check...");
2921
+
2922
+ // Capture console output safely
2923
+ const originalLog = console.log;
2924
+ const originalError = console.error;
1494
2925
 
1495
- input.on("submit", (value) => {
2926
+ console.log = (...args) => logMessage("system", args.join(" "));
2927
+ console.error = (...args) => logMessage("error", args.join(" "));
2928
+
2929
+ try {
2930
+ const UfooContext = require("../context");
2931
+ const ctx = new UfooContext(projectRoot);
2932
+
2933
+ if (args.length === 0 || args[0] === "doctor") {
2934
+ await ctx.doctor();
2935
+ } else if (args[0] === "decisions") {
2936
+ await ctx.listDecisions();
2937
+ } else {
2938
+ await ctx.status();
2939
+ }
2940
+
2941
+ screen.render();
2942
+ } catch (err) {
2943
+ logMessage("error", `{red-fg}✗{/red-fg} Context check failed: ${err.message}`);
2944
+ screen.render();
2945
+ } finally {
2946
+ console.log = originalLog;
2947
+ console.error = originalError;
2948
+ }
2949
+ }
2950
+
2951
+ async function handleSkillsCommand(args) {
2952
+ const subcommand = args[0];
2953
+
2954
+ // Capture console output safely
2955
+ const originalLog = console.log;
2956
+ console.log = (...args) => logMessage("system", args.join(" "));
2957
+
2958
+ try {
2959
+ const UfooSkills = require("../skills");
2960
+ const skills = new UfooSkills(projectRoot);
2961
+
2962
+ if (subcommand === "list") {
2963
+ const skillList = skills.list();
2964
+ if (skillList.length === 0) {
2965
+ logMessage("system", "{gray-fg}No skills found{/gray-fg}");
2966
+ } else {
2967
+ logMessage("system", `{cyan-fg}Available skills:{/cyan-fg} ${skillList.length}`);
2968
+ for (const skill of skillList) {
2969
+ logMessage("system", ` • ${skill}`);
2970
+ }
2971
+ }
2972
+ } else if (subcommand === "install") {
2973
+ const target = args[1] || "all";
2974
+ logMessage("system", `{yellow-fg}⚙{/yellow-fg} Installing skills: ${target}...`);
2975
+ await skills.install(target);
2976
+ logMessage("system", "{green-fg}✓{/green-fg} Skills installed");
2977
+ } else {
2978
+ logMessage("error", "{red-fg}✗{/red-fg} Unknown skills command. Use: list, install");
2979
+ }
2980
+
2981
+ screen.render();
2982
+ } catch (err) {
2983
+ logMessage("error", `{red-fg}✗{/red-fg} Skills command failed: ${err.message}`);
2984
+ screen.render();
2985
+ } finally {
2986
+ console.log = originalLog;
2987
+ }
2988
+ }
2989
+
2990
+ async function handleLaunchCommand(args) {
2991
+ if (args.length === 0) {
2992
+ logMessage("error", "{red-fg}✗{/red-fg} Usage: /launch <claude|codex> [nickname=<name>] [count=<n>]");
2993
+ return;
2994
+ }
2995
+
2996
+ const agentType = args[0];
2997
+ if (agentType !== "claude" && agentType !== "codex") {
2998
+ logMessage("error", "{red-fg}✗{/red-fg} Unknown agent type. Use: claude or codex");
2999
+ return;
3000
+ }
3001
+
3002
+ // Parse options
3003
+ const options = {};
3004
+ for (let i = 1; i < args.length; i++) {
3005
+ const arg = args[i];
3006
+ if (arg.includes("=")) {
3007
+ const [key, value] = arg.split("=", 2);
3008
+ options[key] = value;
3009
+ }
3010
+ }
3011
+
3012
+ const nickname = options.nickname || "";
3013
+ const count = parseInt(options.count || "1", 10);
3014
+ if (nickname && count > 1) {
3015
+ logMessage("error", "{red-fg}✗{/red-fg} nickname requires count=1");
3016
+ return;
3017
+ }
3018
+
3019
+ try {
3020
+ const label = nickname ? ` (${nickname})` : "";
3021
+ logMessage("system", `{yellow-fg}⚙{/yellow-fg} Launching ${agentType}${label}...`);
3022
+ send({
3023
+ type: "launch_agent",
3024
+ agent: agentType,
3025
+ count: Number.isFinite(count) ? count : 1,
3026
+ nickname,
3027
+ });
3028
+ setTimeout(requestStatus, 1000);
3029
+ } catch (err) {
3030
+ logMessage("error", `{red-fg}✗{/red-fg} Launch failed: ${err.message}`);
3031
+ }
3032
+ }
3033
+
3034
+ async function handleResumeCommand(args) {
3035
+ const target = args[0] || "";
3036
+ const label = target ? ` (${target})` : "";
3037
+ logMessage("system", `{yellow-fg}⚙{/yellow-fg} Resuming agents${label}...`);
3038
+ send({ type: "resume_agents", target });
3039
+ setTimeout(requestStatus, 1000);
3040
+ }
3041
+
3042
+ function parseCommand(text) {
3043
+ if (!text.startsWith("/")) return null;
3044
+
3045
+ // Split by whitespace, respecting quotes
3046
+ const parts = text.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
3047
+ if (parts.length === 0) return null;
3048
+
3049
+ const command = parts[0].slice(1); // Remove leading /
3050
+ const args = parts.slice(1).map(arg => arg.replace(/^"|"$/g, "")); // Remove quotes
3051
+
3052
+ return { command, args };
3053
+ }
3054
+
3055
+ async function executeCommand(text) {
3056
+ const parsed = parseCommand(text);
3057
+ if (!parsed) return false;
3058
+
3059
+ const { command, args } = parsed;
3060
+
3061
+ switch (command) {
3062
+ case "doctor":
3063
+ await handleDoctorCommand();
3064
+ return true;
3065
+ case "status":
3066
+ await handleStatusCommand();
3067
+ return true;
3068
+ case "daemon":
3069
+ await handleDaemonCommand(args);
3070
+ return true;
3071
+ case "init":
3072
+ await handleInitCommand(args);
3073
+ return true;
3074
+ case "bus":
3075
+ await handleBusCommand(args);
3076
+ return true;
3077
+ case "ctx":
3078
+ await handleCtxCommand(args);
3079
+ return true;
3080
+ case "skills":
3081
+ await handleSkillsCommand(args);
3082
+ return true;
3083
+ case "launch":
3084
+ await handleLaunchCommand(args);
3085
+ return true;
3086
+ case "resume":
3087
+ await handleResumeCommand(args);
3088
+ return true;
3089
+ default:
3090
+ logMessage("error", `{red-fg}✗{/red-fg} Unknown command: /${command}`);
3091
+ return true;
3092
+ }
3093
+ }
3094
+
3095
+ input.on("submit", async (value) => {
1496
3096
  const text = value.trim();
1497
3097
  input.clearValue();
1498
3098
  screen.render();
1499
3099
  if (!text) {
3100
+ // Empty Enter with @target → enter TTY view
3101
+ if (targetAgent) {
3102
+ const agentId = targetAgent;
3103
+ const sockPath = getInjectSockPath(agentId);
3104
+ if (fs.existsSync(sockPath)) {
3105
+ clearTargetAgent();
3106
+ enterAgentView(agentId);
3107
+ return;
3108
+ }
3109
+ }
1500
3110
  input.focus();
1501
3111
  return;
1502
3112
  }
@@ -1505,18 +3115,58 @@ async function runChat(projectRoot) {
1505
3115
  historyIndex = inputHistory.length;
1506
3116
  historyDraft = "";
1507
3117
 
1508
- // If target agent is selected, send directly via bus
3118
+ // If target agent is selected, inject directly into agent's PTY
1509
3119
  if (targetAgent) {
1510
3120
  const label = getAgentLabel(targetAgent);
1511
- logMessage("user", `{cyan-fg}→{/cyan-fg} {magenta-fg}@${label}{/magenta-fg} ${text}`);
1512
- // Use bus send command
1513
- const { spawnSync } = require("child_process");
1514
- spawnSync("ufoo", ["bus", "send", targetAgent, text], { cwd: projectRoot });
3121
+ logMessage("user", `{magenta-fg}${escapeBlessed(label)}{/magenta-fg}: ${escapeBlessed(text)}`);
3122
+
3123
+ const meta = activeAgentMetaMap.get(targetAgent);
3124
+ const agentMode = meta?.launch_mode || "";
3125
+
3126
+ if (agentMode === "tmux" && meta?.tmux_pane) {
3127
+ // Tmux mode: use tmux send-keys
3128
+ // Send text first, then Enter after a delay (Claude Code needs time to process)
3129
+ const pane = meta.tmux_pane;
3130
+ const textProc = spawn("tmux", ["send-keys", "-t", pane, text]);
3131
+ textProc.on("close", () => {
3132
+ setTimeout(() => {
3133
+ spawn("tmux", ["send-keys", "-t", pane, "Enter"]);
3134
+ }, 150);
3135
+ });
3136
+ } else {
3137
+ // Terminal / internal mode: inject via inject.sock
3138
+ const sockPath = getInjectSockPath(targetAgent);
3139
+ try {
3140
+ const conn = net.createConnection(sockPath, () => {
3141
+ conn.write(JSON.stringify({ type: "raw", data: text }) + "\n");
3142
+ setTimeout(() => {
3143
+ conn.write(JSON.stringify({ type: "raw", data: "\r" }) + "\n");
3144
+ setTimeout(() => conn.destroy(), 500);
3145
+ }, 100);
3146
+ });
3147
+ conn.on("error", () => {});
3148
+ } catch {
3149
+ // ignore connection errors
3150
+ }
3151
+ }
3152
+
1515
3153
  clearTargetAgent();
1516
3154
  input.focus();
1517
3155
  return;
1518
3156
  }
1519
3157
 
3158
+ // Check if it's a command
3159
+ if (text.startsWith("/")) {
3160
+ logMessage("user", `{cyan-fg}→{/cyan-fg} ${escapeBlessed(text)}`);
3161
+ try {
3162
+ await executeCommand(text);
3163
+ } catch (err) {
3164
+ logMessage("error", `{red-fg}✗{/red-fg} Command error: ${escapeBlessed(err.message)}`);
3165
+ }
3166
+ input.focus();
3167
+ return;
3168
+ }
3169
+
1520
3170
  if (pending && pending.disambiguate) {
1521
3171
  const idx = parseInt(text, 10);
1522
3172
  const choice = pending.disambiguate.candidates[idx - 1];
@@ -1528,25 +3178,87 @@ async function runChat(projectRoot) {
1528
3178
  });
1529
3179
  pending = null;
1530
3180
  } else {
1531
- logMessage("error", "Invalid selection.");
3181
+ logMessage("error", escapeBlessed("Invalid selection."));
1532
3182
  }
1533
3183
  } else {
1534
3184
  pending = { original: text };
1535
3185
  queueStatusLine("ufoo-agent processing");
1536
3186
  send({ type: "prompt", text });
1537
- logMessage("user", `{cyan-fg}→{/cyan-fg} ${text}`);
3187
+ logMessage("user", `{cyan-fg}→{/cyan-fg} ${escapeBlessed(text)}`);
1538
3188
  }
1539
3189
  input.focus();
1540
3190
  });
1541
3191
 
1542
3192
  screen.key(["C-c"], exitHandler);
1543
3193
 
3194
+ // Agent TTY view: enter dashboard mode
3195
+ function enterAgentDashboardMode() {
3196
+ focusMode = "dashboard";
3197
+ dashboardView = "agents";
3198
+ // Find the current viewing agent's index in the [ufoo, ...agents] list
3199
+ selectedAgentIndex = 0; // Default to ufoo for quick exit
3200
+ renderAgentDashboard();
3201
+ }
3202
+
3203
+ // Map key names to ANSI escape sequences for raw PTY passthrough
3204
+ function keyToRaw(ch, key) {
3205
+ if (ch && ch.length === 1) return ch;
3206
+ if (!key) return null;
3207
+ switch (key.name) {
3208
+ case "return": case "enter": return "\r";
3209
+ case "backspace": return "\x7f";
3210
+ case "tab": return "\t";
3211
+ case "escape": return "\x1b";
3212
+ case "up": return "\x1b[A";
3213
+ case "down": return "\x1b[B";
3214
+ case "right": return "\x1b[C";
3215
+ case "left": return "\x1b[D";
3216
+ case "home": return "\x1b[H";
3217
+ case "end": return "\x1b[F";
3218
+ case "pageup": return "\x1b[5~";
3219
+ case "pagedown": return "\x1b[6~";
3220
+ case "delete": return "\x1b[3~";
3221
+ case "insert": return "\x1b[2~";
3222
+ default: return ch || null;
3223
+ }
3224
+ }
3225
+
1544
3226
  // Dashboard navigation - use screen.on to capture even when input is focused
1545
3227
  screen.on("keypress", (ch, key) => {
3228
+ // Agent TTY view: handle keystrokes
3229
+ if (currentView === "agent") {
3230
+ if (focusMode === "dashboard") {
3231
+ handleDashboardKey(key);
3232
+ return;
3233
+ }
3234
+ // Suppress input briefly after entering agent view (prevents Enter
3235
+ // leak from dashboard selection and terminal query responses like CPR)
3236
+ if (Date.now() < agentInputSuppressUntil) {
3237
+ return;
3238
+ }
3239
+ // Ctrl+C exits entire app
3240
+ if (key && key.ctrl && key.name === "c") {
3241
+ return; // handled by screen.key(["C-c"])
3242
+ }
3243
+ // Down arrow: enter agents bar (same pattern as normal chat dashboard)
3244
+ if (key && key.name === "down") {
3245
+ enterAgentDashboardMode();
3246
+ return;
3247
+ }
3248
+ // All other keys (including Esc) go to agent PTY
3249
+ const raw = keyToRaw(ch, key);
3250
+ if (raw) {
3251
+ sendRawToAgent(raw);
3252
+ }
3253
+ return;
3254
+ }
3255
+
3256
+ // Normal mode: dashboard key handling
1546
3257
  handleDashboardKey(key);
1547
3258
  });
1548
3259
 
1549
3260
  screen.key(["tab"], () => {
3261
+ if (currentView === "agent") return; // Tab goes to PTY via keypress handler
1550
3262
  if (focusMode === "dashboard") {
1551
3263
  exitDashboardMode(false);
1552
3264
  } else {
@@ -1555,10 +3267,13 @@ async function runChat(projectRoot) {
1555
3267
  });
1556
3268
 
1557
3269
  screen.key(["C-k", "M-k"], () => {
3270
+ if (currentView === "agent") return;
1558
3271
  clearLog();
1559
3272
  });
1560
3273
 
3274
+
1561
3275
  screen.key(["i", "enter"], () => {
3276
+ if (currentView === "agent") return;
1562
3277
  if (focusMode === "dashboard") return;
1563
3278
  if (screen.focused === input) return;
1564
3279
  focusInput();
@@ -1618,10 +3333,27 @@ async function runChat(projectRoot) {
1618
3333
  }
1619
3334
  loadHistory();
1620
3335
  loadInputHistory();
3336
+ renderDashboard();
1621
3337
  resizeInput();
1622
3338
  requestStatus();
1623
- setInterval(requestStatus, 2000);
3339
+
3340
+ // 定期刷新 dashboard 状态(兜底,daemon 会主动推送变化)
3341
+ setInterval(() => {
3342
+ if (client && !client.destroyed) {
3343
+ requestStatus();
3344
+ }
3345
+ }, 30000);
3346
+
1624
3347
  screen.on("resize", () => {
3348
+ if (currentView === "agent") {
3349
+ // Update scroll region and agent PTY size for new terminal dimensions
3350
+ const rows = process.stdout.rows || 24;
3351
+ const cols = process.stdout.columns || 80;
3352
+ process.stdout.write(`\x1b[1;${rows - 1}r`);
3353
+ sendResizeToAgent(cols, rows - 1);
3354
+ renderAgentDashboard();
3355
+ return;
3356
+ }
1625
3357
  resizeInput();
1626
3358
  if (completionActive) hideCompletion();
1627
3359
  input._updateCursor();