u-foo 1.0.3 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +67 -8
  2. package/README.zh-CN.md +9 -7
  3. package/SKILLS/ufoo/SKILL.md +117 -0
  4. package/SKILLS/uinit/SKILL.md +73 -0
  5. package/SKILLS/ustatus/SKILL.md +36 -0
  6. package/bin/uclaude.js +13 -0
  7. package/bin/ucodex.js +13 -0
  8. package/bin/ufoo +9 -31
  9. package/bin/ufoo.js +13 -0
  10. package/modules/AGENTS.template.md +15 -7
  11. package/modules/bus/README.md +28 -23
  12. package/modules/bus/SKILLS/ubus/SKILL.md +18 -8
  13. package/modules/context/README.md +18 -40
  14. package/modules/context/SKILLS/uctx/SKILL.md +61 -1
  15. package/package.json +16 -4
  16. package/scripts/.archived/bash-to-js-migration/README.md +46 -0
  17. package/scripts/.archived/bash-to-js-migration/banner.sh +89 -0
  18. package/scripts/{bus-inject.sh → .archived/bash-to-js-migration/bus-inject.sh} +35 -3
  19. package/scripts/{bus.sh → .archived/bash-to-js-migration/bus.sh} +3 -1
  20. package/scripts/banner.sh +2 -89
  21. package/scripts/postinstall.js +59 -0
  22. package/src/agent/cliRunner.js +33 -5
  23. package/src/agent/internalRunner.js +78 -51
  24. package/src/agent/launcher.js +702 -0
  25. package/src/agent/notifier.js +200 -0
  26. package/src/agent/ptyRunner.js +377 -0
  27. package/src/agent/ptyWrapper.js +354 -0
  28. package/src/agent/readyDetector.js +159 -0
  29. package/src/agent/ufooAgent.js +37 -42
  30. package/src/bus/API_DESIGN.md +204 -0
  31. package/src/bus/activate.js +156 -0
  32. package/src/bus/daemon.js +308 -0
  33. package/src/bus/index.js +785 -0
  34. package/src/bus/inject.js +285 -0
  35. package/src/bus/message.js +302 -0
  36. package/src/bus/nickname.js +86 -0
  37. package/src/bus/queue.js +131 -0
  38. package/src/bus/shake.js +26 -0
  39. package/src/bus/subscriber.js +296 -0
  40. package/src/bus/utils.js +357 -0
  41. package/src/chat/index.js +1842 -249
  42. package/src/cli.js +658 -95
  43. package/src/config.js +9 -2
  44. package/src/context/decisions.js +314 -0
  45. package/src/context/doctor.js +183 -0
  46. package/src/context/index.js +38 -0
  47. package/src/daemon/index.js +749 -94
  48. package/src/daemon/ops.js +395 -51
  49. package/src/daemon/providerSessions.js +291 -0
  50. package/src/daemon/run.js +34 -1
  51. package/src/daemon/status.js +24 -7
  52. package/src/doctor/index.js +50 -0
  53. package/src/init/index.js +264 -0
  54. package/src/skills/index.js +159 -0
  55. package/src/status/index.js +252 -0
  56. package/src/terminal/detect.js +64 -0
  57. package/src/terminal/index.js +8 -0
  58. package/src/terminal/iterm2.js +126 -0
  59. package/src/ufoo/agentsStore.js +41 -0
  60. package/src/ufoo/paths.js +46 -0
  61. package/src/utils/banner.js +73 -0
  62. package/bin/uclaude +0 -65
  63. package/bin/ucodex +0 -65
  64. package/modules/bus/scripts/bus-alert.sh +0 -185
  65. package/modules/bus/scripts/bus-listen.sh +0 -117
  66. package/modules/context/ASSUMPTIONS.md +0 -7
  67. package/modules/context/CONSTRAINTS.md +0 -7
  68. package/modules/context/CONTEXT-STRUCTURE.md +0 -49
  69. package/modules/context/DECISION-PROTOCOL.md +0 -62
  70. package/modules/context/HANDOFF.md +0 -33
  71. package/modules/context/RULES.md +0 -15
  72. package/modules/context/SKILLS/README.md +0 -14
  73. package/modules/context/SYSTEM.md +0 -18
  74. package/modules/context/TEMPLATES/assumptions.md +0 -4
  75. package/modules/context/TEMPLATES/constraints.md +0 -4
  76. package/modules/context/TEMPLATES/decision.md +0 -16
  77. package/modules/context/TEMPLATES/project-context-readme.md +0 -6
  78. package/modules/context/TEMPLATES/system.md +0 -3
  79. package/modules/context/TEMPLATES/terminology.md +0 -4
  80. package/modules/context/TERMINOLOGY.md +0 -10
  81. /package/scripts/{bus-alert.sh → .archived/bash-to-js-migration/bus-alert.sh} +0 -0
  82. /package/scripts/{bus-autotrigger.sh → .archived/bash-to-js-migration/bus-autotrigger.sh} +0 -0
  83. /package/scripts/{bus-daemon.sh → .archived/bash-to-js-migration/bus-daemon.sh} +0 -0
  84. /package/scripts/{bus-listen.sh → .archived/bash-to-js-migration/bus-listen.sh} +0 -0
  85. /package/scripts/{context-decisions.sh → .archived/bash-to-js-migration/context-decisions.sh} +0 -0
  86. /package/scripts/{context-doctor.sh → .archived/bash-to-js-migration/context-doctor.sh} +0 -0
  87. /package/scripts/{context-lint.sh → .archived/bash-to-js-migration/context-lint.sh} +0 -0
  88. /package/scripts/{doctor.sh → .archived/bash-to-js-migration/doctor.sh} +0 -0
  89. /package/scripts/{init.sh → .archived/bash-to-js-migration/init.sh} +0 -0
  90. /package/scripts/{skills.sh → .archived/bash-to-js-migration/skills.sh} +0 -0
  91. /package/scripts/{status.sh → .archived/bash-to-js-migration/status.sh} +0 -0
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
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,12 +24,16 @@ 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
  }
@@ -52,12 +61,30 @@ async function connectWithRetry(sockPath, retries, delayMs) {
52
61
  }
53
62
 
54
63
  async function runChat(projectRoot) {
55
- if (!fs.existsSync(path.join(projectRoot, ".ufoo"))) {
56
- const initScript = resolveProjectFile(projectRoot, path.join("scripts", "init.sh"), path.join("scripts", "init.sh"));
57
- spawnSync("bash", [initScript, "--modules", "context,bus", "--project", projectRoot], {
58
- stdio: "inherit",
59
- });
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}`;
60
86
  }
87
+
61
88
  if (!isRunning(projectRoot)) {
62
89
  startDaemon(projectRoot);
63
90
  }
@@ -65,6 +92,11 @@ async function runChat(projectRoot) {
65
92
  const daemonBin = resolveProjectFile(projectRoot, path.join("bin", "ufoo.js"), path.join("bin", "ufoo.js"));
66
93
  const sock = socketPath(projectRoot);
67
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;
68
100
 
69
101
  const connectClient = async () => {
70
102
  let newClient = await connectWithRetry(sock, 25, 200);
@@ -72,17 +104,61 @@ async function runChat(projectRoot) {
72
104
  // Retry once with a fresh daemon start and longer wait.
73
105
  if (!isRunning(projectRoot)) {
74
106
  startDaemon(projectRoot);
107
+ // Wait for daemon to write PID file and create socket
108
+ await new Promise(r => setTimeout(r, 1000));
75
109
  }
76
110
  newClient = await connectWithRetry(sock, 50, 200);
77
111
  }
78
112
  return newClient;
79
113
  };
80
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;
154
+ }
155
+ }
156
+
81
157
  client = await connectClient();
82
158
  if (!client) {
83
159
  // Check if daemon failed to start
84
160
  if (!isRunning(projectRoot)) {
85
- const logFile = path.join(projectRoot, ".ufoo", "run", "ufoo-daemon.log");
161
+ const logFile = getUfooPaths(projectRoot).ufooDaemonLog;
86
162
  // eslint-disable-next-line no-console
87
163
  console.error("Failed to start ufoo daemon. Check logs at:", logFile);
88
164
  throw new Error("Daemon failed to start. Check the daemon log for details.");
@@ -94,17 +170,28 @@ async function runChat(projectRoot) {
94
170
  smartCSR: true,
95
171
  title: "ufoo chat",
96
172
  fullUnicode: true,
97
- // Allow terminal native copy by not fully grabbing mouse
98
- // Hold Option/Alt to use native selection in most terminals
173
+ // Toggle mouse at runtime to balance copy vs scroll
99
174
  sendFocus: true,
100
175
  mouse: false,
101
176
  // Allow Ctrl+C to exit even when input grabs keys
102
177
  ignoreLocked: ["C-c"],
103
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
+ }
104
190
 
105
191
  const config = loadConfig(projectRoot);
106
192
  let launchMode = config.launchMode;
107
193
  let agentProvider = config.agentProvider;
194
+ let autoResume = config.autoResume !== false;
108
195
 
109
196
  // Dynamic input height settings
110
197
  // Layout: topLine(1) + content + bottomLine(1) + dashboard(1)
@@ -123,11 +210,11 @@ async function runChat(projectRoot) {
123
210
  scrollable: true,
124
211
  alwaysScroll: true,
125
212
  scrollback: 10000,
126
- scrollbar: { ch: "│", style: { fg: "cyan" } },
213
+ scrollbar: null,
127
214
  keys: true,
128
215
  vi: true,
129
- // Enable mouse wheel scrolling in log area (use Option/Alt for native selection)
130
- mouse: true,
216
+ // Mouse handled globally (toggleable) to keep copy working
217
+ mouse: false,
131
218
  });
132
219
 
133
220
  // Status line just above input
@@ -145,7 +232,7 @@ async function runChat(projectRoot) {
145
232
  const bannerText = `{bold}UFOO{/bold} · Multi-Agent Manager{|}v${pkg.version}`;
146
233
  statusLine.setContent(bannerText);
147
234
 
148
- const historyDir = path.join(projectRoot, ".ufoo", "chat");
235
+ const historyDir = path.join(getUfooPaths(projectRoot).ufooDir, "chat");
149
236
  const historyFile = path.join(historyDir, "history.jsonl");
150
237
  const inputHistoryFile = path.join(historyDir, "input-history.jsonl");
151
238
 
@@ -159,13 +246,19 @@ async function runChat(projectRoot) {
159
246
  let lastLogType = null;
160
247
  let hasLoggedAny = false;
161
248
 
162
- function shouldSpace(type) {
163
- 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;
164
253
  }
165
254
 
166
255
  function writeSpacer(writeHistory) {
167
256
  if (lastLogWasSpacer || !hasLoggedAny) return;
168
- logBox.log(" ");
257
+ try {
258
+ logBox.log(" ");
259
+ } catch {
260
+ // ignore rendering errors
261
+ }
169
262
  if (writeHistory) {
170
263
  appendHistory({
171
264
  ts: new Date().toISOString(),
@@ -180,15 +273,16 @@ async function runChat(projectRoot) {
180
273
  }
181
274
 
182
275
  function recordLog(type, text, meta = {}, writeHistory = true) {
183
- if (type !== "spacer" && shouldSpace(type)) {
276
+ const lineText = text == null ? "" : String(text);
277
+ if (type !== "spacer" && shouldSpace(type, text)) {
184
278
  writeSpacer(writeHistory);
185
279
  }
186
- logBox.log(text);
280
+ appendToLogBox(lineText);
187
281
  if (writeHistory) {
188
282
  appendHistory({
189
283
  ts: new Date().toISOString(),
190
284
  type,
191
- text,
285
+ text: lineText,
192
286
  meta,
193
287
  });
194
288
  }
@@ -201,6 +295,39 @@ async function runChat(projectRoot) {
201
295
  recordLog(type, text, meta, true);
202
296
  }
203
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
+
204
331
  function loadHistory(limit = 2000) {
205
332
  try {
206
333
  const lines = fs.readFileSync(historyFile, "utf8").trim().split(/\r?\n/).filter(Boolean);
@@ -214,7 +341,7 @@ async function runChat(projectRoot) {
214
341
  }
215
342
  if (!item.text) continue;
216
343
  if (hasSpacer) {
217
- logBox.log(item.text);
344
+ appendToLogBox(item.text);
218
345
  lastLogWasSpacer = false;
219
346
  lastLogType = item.type || null;
220
347
  hasLoggedAny = true;
@@ -255,16 +382,69 @@ async function runChat(projectRoot) {
255
382
  const pendingStatusLines = [];
256
383
  const busStatusQueue = [];
257
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;
258
393
 
259
394
  function formatProcessingText(text) {
260
395
  if (!text) return text;
261
396
  if (text.includes("{")) return text;
262
397
  if (!/processing/i.test(text)) return text;
263
- 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}";
434
+ }
435
+
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}`;
264
441
  }
265
442
 
266
- function renderStatusLine() {
443
+ function renderStatusLine(nowMs = Date.now()) {
267
444
  let content = primaryStatusText || "";
445
+ if (primaryStatusPending) {
446
+ content = renderPendingStatus(primaryStatusText, nowMs);
447
+ }
268
448
  if (busStatusQueue.length > 0) {
269
449
  const extra = busStatusQueue.length > 1
270
450
  ? ` {gray-fg}(+${busStatusQueue.length - 1}){/gray-fg}`
@@ -277,16 +457,31 @@ async function runChat(projectRoot) {
277
457
  statusLine.setContent(content);
278
458
  }
279
459
 
280
- 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 = {}) {
281
474
  primaryStatusText = text || "";
475
+ primaryStatusPending = Boolean(options.pending);
476
+ updateStatusAnimation();
282
477
  renderStatusLine();
283
478
  }
284
479
 
285
480
  function queueStatusLine(text) {
286
- const formatted = formatProcessingText(text);
287
- pendingStatusLines.push(formatted);
481
+ let raw = text || "";
482
+ pendingStatusLines.push(raw);
288
483
  if (pendingStatusLines.length === 1) {
289
- setPrimaryStatus(formatted);
484
+ setPrimaryStatus(raw, { pending: true });
290
485
  screen.render();
291
486
  }
292
487
  }
@@ -296,17 +491,18 @@ async function runChat(projectRoot) {
296
491
  pendingStatusLines.shift();
297
492
  }
298
493
  if (pendingStatusLines.length > 0) {
299
- setPrimaryStatus(pendingStatusLines[0]);
494
+ setPrimaryStatus(pendingStatusLines[0], { pending: true });
300
495
  } else {
301
- setPrimaryStatus(text || "");
496
+ setPrimaryStatus(text || "", { pending: false });
302
497
  }
303
498
  screen.render();
304
499
  }
305
500
 
306
501
  function enqueueBusStatus(item) {
307
502
  if (!item || !item.text) return;
308
- const key = item.key || item.text;
309
- 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));
310
506
  const existing = busStatusQueue.find((entry) => entry.key === key);
311
507
  if (existing) {
312
508
  existing.text = formatted;
@@ -318,7 +514,8 @@ async function runChat(projectRoot) {
318
514
 
319
515
  function resolveBusStatus(item) {
320
516
  if (!item) return;
321
- const key = item.key || item.text;
517
+ const rawText = item.text == null ? "" : String(item.text);
518
+ const key = item.key || rawText;
322
519
  let index = -1;
323
520
  if (key) {
324
521
  index = busStatusQueue.findIndex((entry) => entry.key === key);
@@ -339,6 +536,7 @@ async function runChat(projectRoot) {
339
536
  width: "100%",
340
537
  height: 0,
341
538
  hidden: true,
539
+ wrap: false,
342
540
  border: {
343
541
  type: "line",
344
542
  top: true,
@@ -371,6 +569,15 @@ async function runChat(projectRoot) {
371
569
  tags: true,
372
570
  });
373
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
+
374
581
  // Bottom border line for input area (above dashboard)
375
582
  const inputBottomLine = blessed.line({
376
583
  parent: screen,
@@ -418,6 +625,8 @@ async function runChat(projectRoot) {
418
625
  // Add cursor position tracking
419
626
  let cursorPos = 0;
420
627
  let preferredCol = null;
628
+ const unicode = blessed.unicode;
629
+ const wideRegex = new RegExp(unicode.chars.all.source);
421
630
 
422
631
  // Get inner width
423
632
  function getInnerWidth() {
@@ -438,13 +647,86 @@ async function runChat(projectRoot) {
438
647
  return 1;
439
648
  }
440
649
 
441
- // 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)
442
724
  function countLines(text, width) {
443
725
  if (width <= 0) return 1;
444
- const lines = text.split("\n");
726
+ const lines = (text || "").split("\n");
445
727
  let total = 0;
446
728
  for (const line of lines) {
447
- const lineWidth = input.strWidth(line);
729
+ const lineWidth = visualLength(line);
448
730
  total += Math.max(1, Math.ceil(lineWidth / width));
449
731
  }
450
732
  return total;
@@ -452,45 +734,33 @@ async function runChat(projectRoot) {
452
734
 
453
735
  function getCursorRowCol(text, pos, width) {
454
736
  if (width <= 0) return { row: 0, col: 0 };
455
- const before = text.slice(0, pos);
456
- const lines = before.split("\n");
737
+ const before = (text || "").slice(0, pos);
738
+ const transformed = transformText(before);
739
+ const lines = transformed.split("\n");
457
740
  let row = 0;
458
741
  for (let i = 0; i < lines.length - 1; i++) {
459
- const lineWidth = input.strWidth(lines[i]);
742
+ const lineWidth = lines[i].length;
460
743
  row += Math.max(1, Math.ceil(lineWidth / width));
461
744
  }
462
745
  const lastLine = lines[lines.length - 1] || "";
463
- const lastWidth = input.strWidth(lastLine);
746
+ const lastWidth = lastLine.length;
464
747
  row += Math.floor(lastWidth / width);
465
748
  const col = lastWidth % width;
466
749
  return { row, col };
467
750
  }
468
751
 
469
- function getLinePosForCol(line, targetCol) {
470
- if (targetCol <= 0) return 0;
471
- let col = 0;
472
- let offset = 0;
473
- for (const ch of Array.from(line)) {
474
- const w = input.strWidth(ch);
475
- if (col + w > targetCol) return offset;
476
- col += w;
477
- offset += ch.length;
478
- }
479
- return offset;
480
- }
481
-
482
752
  function getCursorPosForRowCol(text, targetRow, targetCol, width) {
483
753
  if (width <= 0) return 0;
484
- const lines = text.split("\n");
754
+ const lines = (text || "").split("\n");
485
755
  let row = 0;
486
756
  let pos = 0;
487
757
  for (const line of lines) {
488
- const lineWidth = input.strWidth(line);
758
+ const lineWidth = visualLength(line);
489
759
  const wrappedRows = Math.max(1, Math.ceil(lineWidth / width));
490
760
  if (targetRow < row + wrappedRows) {
491
761
  const rowInLine = targetRow - row;
492
762
  const visualCol = rowInLine * width + Math.max(0, targetCol);
493
- return pos + getLinePosForCol(line, visualCol);
763
+ return pos + originalIndexForVisual(line, Math.min(visualCol, lineWidth));
494
764
  }
495
765
  pos += line.length + 1;
496
766
  row += wrappedRows;
@@ -498,6 +768,34 @@ async function runChat(projectRoot) {
498
768
  return text.length;
499
769
  }
500
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
+
501
799
  function resetPreferredCol() {
502
800
  preferredCol = null;
503
801
  }
@@ -547,6 +845,7 @@ async function runChat(projectRoot) {
547
845
  normalizeCommandPrefix();
548
846
  resetPreferredCol();
549
847
  resizeInput();
848
+ ensureInputCursorVisible();
550
849
  input._updateCursor();
551
850
  screen.render();
552
851
  updateDraftFromInput();
@@ -557,6 +856,7 @@ async function runChat(projectRoot) {
557
856
  cursorPos = input.value.length;
558
857
  resetPreferredCol();
559
858
  resizeInput();
859
+ ensureInputCursorVisible();
560
860
  input._updateCursor();
561
861
  screen.render();
562
862
  }
@@ -590,9 +890,17 @@ async function runChat(projectRoot) {
590
890
  }
591
891
 
592
892
  function exitHandler() {
893
+ exitRequested = true;
894
+ // Clean up agent view connections
895
+ disconnectAgentOutput();
896
+ disconnectAgentInput();
593
897
  if (screen && screen.program && typeof screen.program.decrst === "function") {
594
898
  screen.program.decrst(2004);
595
899
  }
900
+ if (statusAnimationTimer) {
901
+ clearInterval(statusAnimationTimer);
902
+ statusAnimationTimer = null;
903
+ }
596
904
  if (client) {
597
905
  client.end();
598
906
  }
@@ -630,9 +938,12 @@ async function runChat(projectRoot) {
630
938
  const parts = filterText.split(/\s+/);
631
939
  let commands = [];
632
940
 
633
- 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("/")) {
634
946
  // Subcommand mode: "/bus rename"
635
- const mainCmd = parts[0];
636
947
  const subFilter = parts[1] || "";
637
948
 
638
949
  // Find the main command
@@ -640,31 +951,41 @@ async function runChat(projectRoot) {
640
951
  item.cmd.toLowerCase() === mainCmd.toLowerCase()
641
952
  );
642
953
 
643
- if (mainCmdObj && mainCmdObj.subcommands) {
644
- // Filter subcommands
645
- commands = mainCmdObj.subcommands
646
- .filter(sub => sub.cmd.toLowerCase().startsWith(subFilter.toLowerCase()))
647
- .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
+ }
648
982
  }
649
983
  } else {
650
984
  // Main command mode: "/bus"
651
- const prefixMatches = COMMAND_REGISTRY.filter(item =>
652
- item.cmd.toLowerCase().startsWith(filterText.toLowerCase())
653
- );
654
- // Also allow fuzzy matches on the command body (e.g. "/b" -> /bus + /ubus)
655
- let fuzzyMatches = [];
656
- if (filterText.startsWith("/") && parts.length === 1) {
657
- const needle = filterText.slice(1).toLowerCase();
658
- if (needle) {
659
- fuzzyMatches = COMMAND_REGISTRY.filter(item =>
660
- item.cmd.toLowerCase().includes(needle)
661
- );
662
- }
663
- }
664
- const merged = new Map();
665
- for (const item of prefixMatches) merged.set(item.cmd, item);
666
- for (const item of fuzzyMatches) merged.set(item.cmd, item);
667
- 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" }));
668
989
  }
669
990
 
670
991
  if (commands.length === 0) {
@@ -677,9 +998,12 @@ async function runChat(projectRoot) {
677
998
  completionIndex = 0;
678
999
  completionScrollOffset = 0;
679
1000
 
680
- // Calculate panel height (max 8 visible + 1 for top border)
681
- const visibleItems = Math.min(8, completionCommands.length);
682
- 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;
683
1007
  completionPanel.bottom = currentInputHeight - 1;
684
1008
  completionPanel.hidden = false;
685
1009
 
@@ -691,6 +1015,7 @@ async function runChat(projectRoot) {
691
1015
  completionCommands = [];
692
1016
  completionIndex = 0;
693
1017
  completionScrollOffset = 0;
1018
+ completionVisibleCount = 0;
694
1019
  completionPanel.hidden = true;
695
1020
  screen.render();
696
1021
  }
@@ -698,7 +1023,11 @@ async function runChat(projectRoot) {
698
1023
  function renderCompletionPanel() {
699
1024
  if (!completionActive || completionCommands.length === 0) return;
700
1025
 
701
- 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;
702
1031
 
703
1032
  // Adjust scroll offset to keep selected item visible
704
1033
  if (completionIndex < completionScrollOffset) {
@@ -712,21 +1041,37 @@ async function runChat(projectRoot) {
712
1041
  const visibleEnd = Math.min(completionScrollOffset + maxVisible, completionCommands.length);
713
1042
  const visibleCommands = completionCommands.slice(visibleStart, visibleEnd);
714
1043
 
1044
+ const panelWidth = typeof completionPanel.width === "number"
1045
+ ? completionPanel.width
1046
+ : screen.width;
715
1047
  const lines = visibleCommands.map((item, i) => {
716
1048
  const actualIndex = visibleStart + i;
1049
+ const cmdText = item.cmd;
1050
+ const descText = item.desc || "";
717
1051
  const cmdPart = actualIndex === completionIndex
718
- ? `{inverse}${item.cmd}{/inverse}`
719
- : `{cyan-fg}${item.cmd}{/cyan-fg}`;
720
- const descPart = `{gray-fg}${item.desc}{/gray-fg}`;
721
- // Use promptBox width (2) to align with input position
1052
+ ? `{inverse}${cmdText}{/inverse}`
1053
+ : `{cyan-fg}${cmdText}{/cyan-fg}`;
722
1054
  const indent = " ".repeat(promptBox.width || 2);
723
- 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}`;
724
1062
  });
725
1063
 
726
1064
  completionPanel.setContent(lines.join("\n"));
727
1065
  screen.render();
728
1066
  }
729
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
+
730
1075
  function completionUp() {
731
1076
  if (completionCommands.length === 0) return;
732
1077
  completionIndex = completionIndex <= 0
@@ -743,6 +1088,55 @@ async function runChat(projectRoot) {
743
1088
  renderCompletionPanel();
744
1089
  }
745
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
+
746
1140
  function confirmCompletion() {
747
1141
  if (!completionActive || completionCommands.length === 0) return;
748
1142
 
@@ -790,9 +1184,43 @@ async function runChat(projectRoot) {
790
1184
  confirmCompletion();
791
1185
  return true;
792
1186
  }
1187
+ if (key.name === "pageup") {
1188
+ completionPageUp();
1189
+ return true;
1190
+ }
1191
+ if (key.name === "pagedown") {
1192
+ completionPageDown();
1193
+ return true;
1194
+ }
793
1195
  if (key.name === "enter" || key.name === "return") {
794
- // 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
795
1218
  hideCompletion();
1219
+ completionEnterSuppressed = true;
1220
+ if (completionEnterReset) clearImmediate(completionEnterReset);
1221
+ completionEnterReset = setImmediate(() => {
1222
+ completionEnterSuppressed = false;
1223
+ });
796
1224
  return false;
797
1225
  }
798
1226
  if (key.name === "escape") {
@@ -816,7 +1244,7 @@ async function runChat(projectRoot) {
816
1244
 
817
1245
  // Resize input box based on content
818
1246
  function resizeInput() {
819
- const innerWidth = getInnerWidth();
1247
+ const innerWidth = getWrapWidth();
820
1248
  if (innerWidth <= 0) return;
821
1249
 
822
1250
  const numLines = countLines(input.value, innerWidth);
@@ -833,13 +1261,21 @@ async function runChat(projectRoot) {
833
1261
  // Reposition completion panel if active
834
1262
  if (completionActive) {
835
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();
836
1270
  }
837
1271
  // dashboard and inputBottomLine stay fixed at bottom 0 and 1
838
1272
  logBox.height = Math.max(1, screen.height - currentInputHeight - 1);
1273
+ ensureInputCursorVisible();
839
1274
  }
840
1275
 
841
1276
  // Override the internal listener to support cursor movement
842
1277
  input._listener = function(ch, key) {
1278
+ if (currentView === "agent") return; // Agent view handles keys at screen level
843
1279
  if (key && key.ctrl && key.name === "c") {
844
1280
  exitHandler();
845
1281
  return;
@@ -848,20 +1284,27 @@ async function runChat(projectRoot) {
848
1284
  return;
849
1285
  }
850
1286
  normalizeCommandPrefix();
851
- if (key && (key.name === "pageup" || key.name === "pagedown")) {
852
- const delta = Math.max(1, Math.floor(logBox.height / 2));
853
- scrollLog(key.name === "pageup" ? -delta : delta);
854
- return;
855
- }
856
1287
  if (focusMode === "dashboard") {
857
1288
  if (handleDashboardKey(key)) return;
858
- 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
+ }
859
1297
  }
860
1298
 
861
1299
  // Command completion mode
862
1300
  if (completionActive) {
863
1301
  if (handleCompletionKey(ch, key)) return;
864
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
+ }
865
1308
 
866
1309
  // Treat multi-char input (paste) as insertion, including newlines.
867
1310
  if (ch && ch.length > 1 && (!key || !key.name || key.name.length !== 1)) {
@@ -888,6 +1331,7 @@ async function runChat(projectRoot) {
888
1331
  if (key.name === "left") {
889
1332
  if (cursorPos > 0) cursorPos--;
890
1333
  resetPreferredCol();
1334
+ ensureInputCursorVisible();
891
1335
  this._updateCursor();
892
1336
  this.screen.render();
893
1337
  return;
@@ -896,6 +1340,7 @@ async function runChat(projectRoot) {
896
1340
  if (key.name === "right") {
897
1341
  if (cursorPos < this.value.length) cursorPos++;
898
1342
  resetPreferredCol();
1343
+ ensureInputCursorVisible();
899
1344
  this._updateCursor();
900
1345
  this.screen.render();
901
1346
  return;
@@ -904,6 +1349,7 @@ async function runChat(projectRoot) {
904
1349
  if (key.name === "home") {
905
1350
  cursorPos = 0;
906
1351
  resetPreferredCol();
1352
+ ensureInputCursorVisible();
907
1353
  this._updateCursor();
908
1354
  this.screen.render();
909
1355
  return;
@@ -912,6 +1358,7 @@ async function runChat(projectRoot) {
912
1358
  if (key.name === "end") {
913
1359
  cursorPos = this.value.length;
914
1360
  resetPreferredCol();
1361
+ ensureInputCursorVisible();
915
1362
  this._updateCursor();
916
1363
  this.screen.render();
917
1364
  return;
@@ -936,7 +1383,7 @@ async function runChat(projectRoot) {
936
1383
  }
937
1384
  }
938
1385
  if (key.name === "up" || key.name === "down") {
939
- const innerWidth = getInnerWidth();
1386
+ const innerWidth = getWrapWidth();
940
1387
  if (innerWidth > 0) {
941
1388
  const { row, col } = getCursorRowCol(this.value, cursorPos, innerWidth);
942
1389
  if (preferredCol === null) preferredCol = col;
@@ -953,6 +1400,7 @@ async function runChat(projectRoot) {
953
1400
  : Math.min(totalRows - 1, row + 1);
954
1401
  cursorPos = getCursorPosForRowCol(this.value, targetRow, preferredCol, innerWidth);
955
1402
  }
1403
+ ensureInputCursorVisible();
956
1404
  this._updateCursor();
957
1405
  this.screen.render();
958
1406
  return;
@@ -969,6 +1417,7 @@ async function runChat(projectRoot) {
969
1417
  cursorPos--;
970
1418
  resetPreferredCol();
971
1419
  resizeInput();
1420
+ ensureInputCursorVisible();
972
1421
  this._updateCursor();
973
1422
  updateDraftFromInput();
974
1423
 
@@ -989,6 +1438,7 @@ async function runChat(projectRoot) {
989
1438
  this.value = this.value.slice(0, cursorPos) + this.value.slice(cursorPos + 1);
990
1439
  resetPreferredCol();
991
1440
  resizeInput();
1441
+ ensureInputCursorVisible();
992
1442
  this._updateCursor();
993
1443
  this.screen.render();
994
1444
  updateDraftFromInput();
@@ -1025,27 +1475,16 @@ async function runChat(projectRoot) {
1025
1475
  input._updateCursor = function() {
1026
1476
  if (this.screen.focused !== this) return;
1027
1477
 
1028
- const lpos = this._getCoords();
1478
+ let lpos;
1479
+ try { lpos = this._getCoords(); } catch { return; }
1029
1480
  if (!lpos) return;
1030
1481
 
1031
- const innerWidth = getInnerWidth();
1482
+ const innerWidth = getWrapWidth();
1032
1483
  if (innerWidth <= 0) return;
1033
1484
 
1485
+ ensureInputCursorVisible();
1034
1486
  const { row, col } = getCursorRowCol(this.value, cursorPos, innerWidth);
1035
- const innerHeight = this.height || 1;
1036
-
1037
- let scrollOffset = this.childBase || 0;
1038
- if (row < scrollOffset) {
1039
- scrollOffset = row;
1040
- } else if (row >= scrollOffset + innerHeight) {
1041
- scrollOffset = row - innerHeight + 1;
1042
- }
1043
- if (scrollOffset !== this.childBase) {
1044
- this.childBase = scrollOffset;
1045
- if (typeof this.scrollTo === "function") {
1046
- this.scrollTo(scrollOffset);
1047
- }
1048
- }
1487
+ const scrollOffset = this.childBase || 0;
1049
1488
 
1050
1489
  const displayRow = row - scrollOffset;
1051
1490
  const safeCol = Math.min(Math.max(0, col), innerWidth - 1);
@@ -1081,54 +1520,101 @@ async function runChat(projectRoot) {
1081
1520
  let completionCommands = [];
1082
1521
  let completionIndex = 0;
1083
1522
  let completionScrollOffset = 0;
1523
+ let completionVisibleCount = 0;
1524
+ let completionEnterSuppressed = false;
1525
+ let completionEnterReset = null;
1084
1526
 
1085
- const COMMAND_REGISTRY = [
1086
- { cmd: "/doctor", desc: "Health check diagnostics" },
1087
- { cmd: "/status", desc: "Status display" },
1088
- {
1089
- 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": {
1090
1547
  desc: "Daemon management",
1091
- subcommands: [
1092
- { cmd: "start", desc: "Start daemon" },
1093
- { cmd: "stop", desc: "Stop daemon" },
1094
- { cmd: "restart", desc: "Restart daemon" },
1095
- { cmd: "status", desc: "Daemon status" },
1096
- ]
1548
+ children: {
1549
+ restart: { desc: "Restart daemon" },
1550
+ start: { desc: "Start daemon" },
1551
+ status: { desc: "Daemon status" },
1552
+ stop: { desc: "Stop daemon" },
1553
+ },
1097
1554
  },
1098
- { cmd: "/init", desc: "Initialize modules" },
1099
- {
1100
- cmd: "/bus",
1101
- desc: "Event bus operations",
1102
- subcommands: [
1103
- { cmd: "send", desc: "Send message to agent" },
1104
- { cmd: "rename", desc: "Rename agent nickname" },
1105
- { cmd: "list", desc: "List all agents" },
1106
- { cmd: "status", desc: "Bus status" },
1107
- ]
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
+ },
1108
1563
  },
1109
- { cmd: "/ctx", desc: "Context management" },
1110
- { cmd: "/skills", desc: "Skills management" },
1111
- { cmd: "/ubus", desc: "Check bus messages" },
1112
- { cmd: "/uctx", desc: "Context status" },
1113
- { cmd: "/uinit", desc: "Initialize/repair" },
1114
- { cmd: "/ustatus", desc: "Unified status" },
1115
- ];
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);
1116
1594
 
1117
1595
  // Agent selection state
1118
1596
  let activeAgents = [];
1119
1597
  let activeAgentLabelMap = new Map();
1598
+ let activeAgentMetaMap = new Map(); // Store full meta including launch_mode
1120
1599
  let agentListWindowStart = 0;
1121
1600
  const MAX_AGENT_WINDOW = 5;
1122
1601
  let selectedAgentIndex = -1; // -1 = not in dashboard selection mode
1123
1602
  let targetAgent = null; // Selected agent for direct messaging
1124
1603
  let focusMode = "input"; // "input" or "dashboard"
1125
- let dashboardView = "agents"; // "agents" or "mode"
1126
- 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);
1127
1608
  const providerOptions = [
1128
1609
  { label: "codex", value: "codex-cli" },
1129
1610
  { label: "claude", value: "claude-cli" },
1130
1611
  ];
1131
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;
1132
1618
  let restartInProgress = false;
1133
1619
 
1134
1620
  function getAgentLabel(agentId) {
@@ -1154,7 +1640,11 @@ async function runChat(projectRoot) {
1154
1640
  }
1155
1641
 
1156
1642
  function send(req) {
1157
- if (!client || client.destroyed) return;
1643
+ if (!client || client.destroyed) {
1644
+ enqueueRequest(req);
1645
+ void ensureConnected();
1646
+ return;
1647
+ }
1158
1648
  client.write(`${JSON.stringify(req)}\n`);
1159
1649
  }
1160
1650
 
@@ -1193,30 +1683,66 @@ async function runChat(projectRoot) {
1193
1683
  function setLaunchMode(mode) {
1194
1684
  const next = normalizeLaunchMode(mode);
1195
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
+ }
1196
1691
  launchMode = next;
1197
- selectedModeIndex = launchMode === "internal" ? 1 : 0;
1692
+ selectedModeIndex = modeToIndex(launchMode);
1198
1693
  saveConfig(projectRoot, { launchMode });
1199
1694
  logMessage("status", `{magenta-fg}⚙{/magenta-fg} Launch mode: ${launchMode}`);
1200
1695
  renderDashboard();
1201
1696
  screen.render();
1697
+ void restartDaemon();
1202
1698
  }
1203
1699
 
1700
+
1204
1701
  function providerLabel(value) {
1205
1702
  return value === "claude-cli" ? "claude" : "codex";
1206
1703
  }
1207
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
+
1208
1721
  function setAgentProvider(provider) {
1209
1722
  const next = normalizeAgentProvider(provider);
1210
1723
  if (next === agentProvider) return;
1211
1724
  agentProvider = next;
1212
1725
  selectedProviderIndex = agentProvider === "claude-cli" ? 1 : 0;
1213
1726
  saveConfig(projectRoot, { agentProvider });
1727
+ clearUfooAgentIdentity();
1214
1728
  logMessage("status", `{magenta-fg}⚙{/magenta-fg} ufoo-agent: ${providerLabel(agentProvider)}`);
1215
1729
  renderDashboard();
1216
1730
  screen.render();
1217
1731
  void restartDaemon();
1218
1732
  }
1219
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
+
1220
1746
  async function restartDaemon() {
1221
1747
  if (restartInProgress) return;
1222
1748
  restartInProgress = true;
@@ -1231,7 +1757,7 @@ async function runChat(projectRoot) {
1231
1757
  }
1232
1758
  }
1233
1759
  stopDaemon(projectRoot);
1234
- startDaemon(projectRoot);
1760
+ startDaemon(projectRoot, { forceResume: true });
1235
1761
  const newClient = await connectClient();
1236
1762
  if (newClient) {
1237
1763
  attachClient(newClient);
@@ -1256,11 +1782,13 @@ async function runChat(projectRoot) {
1256
1782
  let content = " ";
1257
1783
  if (focusMode === "dashboard") {
1258
1784
  if (dashboardView === "mode") {
1259
- const modes = ["terminal", "internal"];
1260
- const modeParts = modes.map((mode, i) => {
1785
+ const modeParts = launchModes.map((mode, i) => {
1261
1786
  if (i === selectedModeIndex) {
1262
1787
  return `{inverse}${mode}{/inverse}`;
1263
1788
  }
1789
+ if (mode === launchMode) {
1790
+ return `{bold}{cyan-fg}${mode}{/cyan-fg}{/bold}`;
1791
+ }
1264
1792
  return `{cyan-fg}${mode}{/cyan-fg}`;
1265
1793
  });
1266
1794
  content += `{gray-fg}Mode:{/gray-fg} ${modeParts.join(" ")}`;
@@ -1270,9 +1798,24 @@ async function runChat(projectRoot) {
1270
1798
  if (i === selectedProviderIndex) {
1271
1799
  return `{inverse}${opt.label}{/inverse}`;
1272
1800
  }
1801
+ if (opt.value === agentProvider) {
1802
+ return `{bold}{cyan-fg}${opt.label}{/cyan-fg}{/bold}`;
1803
+ }
1273
1804
  return `{cyan-fg}${opt.label}{/cyan-fg}`;
1274
1805
  });
1275
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(" ")}`;
1276
1819
  content += " {gray-fg}│ ←/→ select, Enter confirm, ↑ back{/gray-fg}";
1277
1820
  } else {
1278
1821
  if (activeAgents.length > 0) {
@@ -1293,7 +1836,7 @@ async function runChat(projectRoot) {
1293
1836
  const rightMore = end < activeAgents.length ? " {gray-fg}»{/gray-fg}" : "";
1294
1837
  content += `{gray-fg}Agents:{/gray-fg} ${agentParts.join(" ")}`;
1295
1838
  content = `${content.replace("{gray-fg}Agents:{/gray-fg} ", `{gray-fg}Agents:{/gray-fg} ${leftMore}`)}${rightMore}`;
1296
- content += " {gray-fg}│ ←/→ select, Enter confirm, ↓ mode, ↑ back{/gray-fg}";
1839
+ content += " {gray-fg}│ ←/→ select, Enter confirm, ^X close, ↓ mode, ↑ back{/gray-fg}";
1297
1840
  } else {
1298
1841
  content += "{gray-fg}Agents:{/gray-fg} {cyan-fg}none{/cyan-fg}";
1299
1842
  content += " {gray-fg}│ ↓ mode, ↑ back{/gray-fg}";
@@ -1302,11 +1845,15 @@ async function runChat(projectRoot) {
1302
1845
  } else {
1303
1846
  // Normal dashboard display (input mode)
1304
1847
  const agents = activeAgents.length > 0
1305
- ? 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}` : "")
1306
1852
  : "none";
1307
1853
  content += `{gray-fg}Agents:{/gray-fg} {cyan-fg}${agents}{/cyan-fg}`;
1308
1854
  content += ` {gray-fg}Mode:{/gray-fg} {cyan-fg}${launchMode}{/cyan-fg}`;
1309
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}`;
1310
1857
  }
1311
1858
  dashboard.setContent(content);
1312
1859
  }
@@ -1315,13 +1862,14 @@ async function runChat(projectRoot) {
1315
1862
  activeAgents = status.active || [];
1316
1863
  const metaList = Array.isArray(status.active_meta) ? status.active_meta : [];
1317
1864
  activeAgentLabelMap = new Map();
1865
+ activeAgentMetaMap = new Map();
1318
1866
  let fallbackMap = null;
1319
1867
  if (metaList.length === 0 && activeAgents.length > 0) {
1320
1868
  try {
1321
- const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
1869
+ const busPath = getUfooPaths(projectRoot).agentsFile;
1322
1870
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
1323
1871
  fallbackMap = new Map();
1324
- for (const [id, meta] of Object.entries(bus.subscribers || {})) {
1872
+ for (const [id, meta] of Object.entries(bus.agents || {})) {
1325
1873
  if (meta && meta.nickname) fallbackMap.set(id, meta.nickname);
1326
1874
  }
1327
1875
  } catch {
@@ -1334,8 +1882,31 @@ async function runChat(projectRoot) {
1334
1882
  ? meta.nickname
1335
1883
  : (fallbackMap && fallbackMap.get(id)) || id;
1336
1884
  activeAgentLabelMap.set(id, label);
1885
+ if (meta) {
1886
+ activeAgentMetaMap.set(id, meta);
1887
+ }
1337
1888
  }
1338
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
+
1339
1910
  if (focusMode === "dashboard") {
1340
1911
  if (dashboardView === "agents") {
1341
1912
  if (activeAgents.length === 0) {
@@ -1356,8 +1927,14 @@ async function runChat(projectRoot) {
1356
1927
  selectedAgentIndex = activeAgents.length > 0 ? 0 : -1;
1357
1928
  agentListWindowStart = 0;
1358
1929
  clampAgentWindow();
1359
- selectedModeIndex = launchMode === "internal" ? 1 : 0;
1930
+ selectedModeIndex = modeToIndex(launchMode);
1360
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
+ }
1361
1938
  screen.grabKeys = true;
1362
1939
  renderDashboard();
1363
1940
  screen.program.hideCursor();
@@ -1366,15 +1943,88 @@ async function runChat(projectRoot) {
1366
1943
 
1367
1944
  function handleDashboardKey(key) {
1368
1945
  if (!key || focusMode !== "dashboard") return false;
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
1951
+ if (key.name === "left") {
1952
+ if (selectedAgentIndex > 0) {
1953
+ selectedAgentIndex--;
1954
+ }
1955
+ renderAgentDashboard();
1956
+ return true;
1957
+ }
1958
+ if (key.name === "right") {
1959
+ if (selectedAgentIndex < totalItems - 1) {
1960
+ selectedAgentIndex++;
1961
+ }
1962
+ renderAgentDashboard();
1963
+ return true;
1964
+ }
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
+ }
1994
+ return true;
1995
+ }
1996
+ if (key.name === "up") {
1997
+ // Up exits dashboard back to agent PTY view
1998
+ focusMode = "input";
1999
+ renderAgentDashboard();
2000
+ return true;
2001
+ }
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
+
1369
2018
  if (dashboardView === "mode") {
2019
+ const maxMode = launchModes.length - 1;
1370
2020
  if (key.name === "left") {
1371
- selectedModeIndex = selectedModeIndex <= 0 ? 1 : 0;
2021
+ selectedModeIndex = selectedModeIndex <= 0 ? maxMode : selectedModeIndex - 1;
1372
2022
  renderDashboard();
1373
2023
  screen.render();
1374
2024
  return true;
1375
2025
  }
1376
2026
  if (key.name === "right") {
1377
- selectedModeIndex = selectedModeIndex >= 1 ? 0 : 1;
2027
+ selectedModeIndex = selectedModeIndex >= maxMode ? 0 : selectedModeIndex + 1;
1378
2028
  renderDashboard();
1379
2029
  screen.render();
1380
2030
  return true;
@@ -1388,13 +2038,17 @@ async function runChat(projectRoot) {
1388
2038
  }
1389
2039
  if (key.name === "up") {
1390
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
+ }
1391
2046
  renderDashboard();
1392
2047
  screen.render();
1393
2048
  return true;
1394
2049
  }
1395
2050
  if (key.name === "enter" || key.name === "return") {
1396
- const modes = ["terminal", "internal"];
1397
- setLaunchMode(modes[selectedModeIndex]);
2051
+ setLaunchMode(launchModes[selectedModeIndex]);
1398
2052
  exitDashboardMode(false);
1399
2053
  return true;
1400
2054
  }
@@ -1417,6 +2071,13 @@ async function runChat(projectRoot) {
1417
2071
  screen.render();
1418
2072
  return true;
1419
2073
  }
2074
+ if (key.name === "down") {
2075
+ dashboardView = "resume";
2076
+ selectedResumeIndex = autoResume ? 0 : 1;
2077
+ renderDashboard();
2078
+ screen.render();
2079
+ return true;
2080
+ }
1420
2081
  if (key.name === "up") {
1421
2082
  dashboardView = "mode";
1422
2083
  renderDashboard();
@@ -1435,38 +2096,124 @@ async function runChat(projectRoot) {
1435
2096
  }
1436
2097
  return true;
1437
2098
  }
1438
-
1439
- if (key.name === "left") {
1440
- if (activeAgents.length > 0 && selectedAgentIndex > 0) {
1441
- selectedAgentIndex--;
1442
- clampAgentWindow();
2099
+ if (dashboardView === "resume") {
2100
+ if (key.name === "left") {
2101
+ selectedResumeIndex = selectedResumeIndex <= 0 ? resumeOptions.length - 1 : selectedResumeIndex - 1;
1443
2102
  renderDashboard();
1444
2103
  screen.render();
2104
+ return true;
1445
2105
  }
1446
- return true;
1447
- }
1448
- if (key.name === "right") {
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);
2130
+ return true;
2131
+ }
2132
+ return true;
2133
+ }
2134
+
2135
+ if (key.name === "left") {
2136
+ if (activeAgents.length > 0 && selectedAgentIndex > 0) {
2137
+ selectedAgentIndex--;
2138
+ clampAgentWindow();
2139
+ // Update @target in real-time as user navigates
2140
+ targetAgent = activeAgents[selectedAgentIndex];
2141
+ updatePromptBox();
2142
+ renderDashboard();
2143
+ screen.render();
2144
+ }
2145
+ return true;
2146
+ }
2147
+ if (key.name === "right") {
1449
2148
  if (activeAgents.length > 0 && selectedAgentIndex < activeAgents.length - 1) {
1450
2149
  selectedAgentIndex++;
1451
2150
  clampAgentWindow();
2151
+ // Update @target in real-time as user navigates
2152
+ targetAgent = activeAgents[selectedAgentIndex];
2153
+ updatePromptBox();
1452
2154
  renderDashboard();
1453
2155
  screen.render();
1454
2156
  }
1455
2157
  return true;
1456
2158
  }
1457
2159
  if (key.name === "down") {
2160
+ // Leaving agents page: clear temporary @target
2161
+ clearTargetAgent();
1458
2162
  dashboardView = "mode";
1459
- selectedModeIndex = launchMode === "internal" ? 1 : 0;
2163
+ selectedModeIndex = modeToIndex(launchMode);
1460
2164
  renderDashboard();
1461
2165
  screen.render();
1462
2166
  return true;
1463
2167
  }
1464
2168
  if (key.name === "up" || key.name === "escape") {
2169
+ // Cancel: clear @target, back to normal chat
2170
+ clearTargetAgent();
1465
2171
  exitDashboardMode(false);
1466
2172
  return true;
1467
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
+ }
1468
2185
  if (key.name === "enter" || key.name === "return") {
1469
- 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);
1470
2217
  return true;
1471
2218
  }
1472
2219
  return false;
@@ -1492,6 +2239,312 @@ async function runChat(projectRoot) {
1492
2239
  screen.render();
1493
2240
  }
1494
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
+
1495
2548
  function requestStatus() {
1496
2549
  send({ type: "status" });
1497
2550
  }
@@ -1512,6 +2565,7 @@ async function runChat(projectRoot) {
1512
2565
  if (!newClient) return;
1513
2566
  detachClient();
1514
2567
  client = newClient;
2568
+ connectionLostNotified = false;
1515
2569
  let buffer = "";
1516
2570
  client.on("data", (data) => {
1517
2571
  buffer += data.toString("utf8");
@@ -1520,84 +2574,102 @@ async function runChat(projectRoot) {
1520
2574
  for (const line of lines.filter((l) => l.trim())) {
1521
2575
  try {
1522
2576
  const msg = JSON.parse(line);
1523
- if (msg.type === "status") {
1524
- const data = msg.data || {};
1525
- if (typeof data.phase === "string") {
1526
- const text = data.text || "";
1527
- const item = { key: data.key, text };
1528
- if (data.phase === "start") {
1529
- enqueueBusStatus(item);
1530
- } else if (data.phase === "done" || data.phase === "error") {
1531
- resolveBusStatus(item);
1532
- if (text) {
1533
- const prefix = data.phase === "error"
1534
- ? "{red-fg}✗{/red-fg}"
1535
- : "{green-fg}✓{/green-fg}";
1536
- logMessage("status", `${prefix} ${text}`, data);
1537
- }
1538
- } else {
1539
- 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}`);
1540
2600
  }
1541
- screen.render();
1542
- } else {
1543
2601
  updateDashboard(data);
1544
2602
  }
1545
- } else if (msg.type === "response") {
1546
- const payload = msg.data || {};
1547
- if (payload.reply) {
1548
- resolveStatusLine(`{green-fg}←{/green-fg} ${payload.reply}`);
1549
- logMessage("reply", `{green-fg}←{/green-fg} ${payload.reply}`);
1550
- }
1551
- if (payload.dispatch && payload.dispatch.length > 0) {
1552
- logMessage("dispatch", `{blue-fg}→{/blue-fg} Dispatched to: ${payload.dispatch.map(d => d.target || d).join(", ")}`);
1553
- }
1554
- if (payload.disambiguate && Array.isArray(payload.disambiguate.candidates) && payload.disambiguate.candidates.length > 0) {
1555
- pending = { disambiguate: payload.disambiguate, original: pending?.original };
1556
- resolveStatusLine(`{yellow-fg}?{/yellow-fg} ${payload.disambiguate.prompt || "Choose target:"}`);
1557
- logMessage("disambiguate", `{yellow-fg}?{/yellow-fg} ${payload.disambiguate.prompt || "Choose target:"}`);
1558
- payload.disambiguate.candidates.forEach((c, i) => {
1559
- logMessage("disambiguate", ` {cyan-fg}${i + 1}){/cyan-fg} ${c.agent_id} {gray-fg}— ${c.reason || ""}{/gray-fg}`);
1560
- });
1561
- } else {
1562
- pending = null;
1563
- }
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
+ }
1564
2629
  if (!payload.reply && !payload.disambiguate) {
1565
2630
  resolveStatusLine("{gray-fg}✓{/gray-fg} Done");
1566
2631
  }
1567
- if (msg.opsResults && msg.opsResults.length > 0) {
1568
- logMessage("ops", `{magenta-fg}⚡{/magenta-fg} ${JSON.stringify(msg.opsResults)}`);
1569
- }
2632
+ // opsResults are noisy JSON; keep them out of the log UI
1570
2633
  screen.render();
1571
- } else if (msg.type === "bus") {
1572
- const data = msg.data || {};
1573
- const prefix = data.event === "broadcast" ? "{magenta-fg}⇢{/magenta-fg}" : "{blue-fg}↔{/blue-fg}";
1574
- let publisher = data.publisher && data.publisher !== "unknown"
1575
- ? data.publisher
1576
- : (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");
1577
2640
 
1578
2641
  // Try to parse message as JSON (from internal agents)
1579
- let displayMessage = data.message || "";
1580
- try {
1581
- const parsed = JSON.parse(data.message);
1582
- if (parsed && typeof parsed === "object" && parsed.reply) {
1583
- displayMessage = parsed.reply;
1584
- }
1585
- } catch {
1586
- // 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");
1587
2659
  }
1588
2660
 
1589
2661
  // Extract nickname if publisher is in subscriber:id format
1590
- let displayName = publisher;
1591
- if (publisher.includes(":")) {
1592
- // 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
1593
2665
  if (activeAgentLabelMap && activeAgentLabelMap.has(publisher)) {
1594
2666
  displayName = activeAgentLabelMap.get(publisher);
1595
2667
  } else {
1596
- // Fallback: read directly from bus.json
2668
+ // Fallback: read directly from all-agents.json
1597
2669
  try {
1598
- const busPath = path.join(projectRoot, ".ufoo", "bus", "bus.json");
2670
+ const busPath = getUfooPaths(projectRoot).agentsFile;
1599
2671
  const bus = JSON.parse(fs.readFileSync(busPath, "utf8"));
1600
- const meta = bus.subscribers && bus.subscribers[publisher];
2672
+ const meta = bus.agents && bus.agents[publisher];
1601
2673
  if (meta && meta.nickname) {
1602
2674
  displayName = meta.nickname;
1603
2675
  }
@@ -1605,36 +2677,436 @@ async function runChat(projectRoot) {
1605
2677
  // Keep original publisher ID
1606
2678
  }
1607
2679
  }
1608
- }
2680
+ }
1609
2681
 
1610
- const line = `${prefix} {gray-fg}${displayName}{/gray-fg}: ${displayMessage}`;
1611
- logMessage("bus", line, data);
1612
- 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 列表
1613
2690
  requestStatus();
1614
2691
  }
1615
2692
  screen.render();
1616
- } else if (msg.type === "error") {
1617
- resolveStatusLine(`{red-fg}✗{/red-fg} Error: ${msg.error}`);
1618
- logMessage("error", `{red-fg}✗{/red-fg} Error: ${msg.error}`);
1619
- screen.render();
1620
- }
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
+ }
1621
2698
  } catch {
1622
2699
  // ignore
1623
2700
  }
1624
2701
  }
1625
2702
  });
1626
- client.on("close", () => {
1627
- client = null;
1628
- });
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();
1629
2717
  };
1630
2718
 
1631
2719
  attachClient(client);
1632
2720
 
1633
- input.on("submit", (value) => {
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;
2925
+
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) => {
1634
3096
  const text = value.trim();
1635
3097
  input.clearValue();
1636
3098
  screen.render();
1637
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
+ }
1638
3110
  input.focus();
1639
3111
  return;
1640
3112
  }
@@ -1643,18 +3115,58 @@ async function runChat(projectRoot) {
1643
3115
  historyIndex = inputHistory.length;
1644
3116
  historyDraft = "";
1645
3117
 
1646
- // If target agent is selected, send directly via bus
3118
+ // If target agent is selected, inject directly into agent's PTY
1647
3119
  if (targetAgent) {
1648
3120
  const label = getAgentLabel(targetAgent);
1649
- logMessage("user", `{cyan-fg}→{/cyan-fg} {magenta-fg}@${label}{/magenta-fg} ${text}`);
1650
- // Use bus send command
1651
- const { spawnSync } = require("child_process");
1652
- 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
+
1653
3153
  clearTargetAgent();
1654
3154
  input.focus();
1655
3155
  return;
1656
3156
  }
1657
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
+
1658
3170
  if (pending && pending.disambiguate) {
1659
3171
  const idx = parseInt(text, 10);
1660
3172
  const choice = pending.disambiguate.candidates[idx - 1];
@@ -1666,25 +3178,87 @@ async function runChat(projectRoot) {
1666
3178
  });
1667
3179
  pending = null;
1668
3180
  } else {
1669
- logMessage("error", "Invalid selection.");
3181
+ logMessage("error", escapeBlessed("Invalid selection."));
1670
3182
  }
1671
3183
  } else {
1672
3184
  pending = { original: text };
1673
3185
  queueStatusLine("ufoo-agent processing");
1674
3186
  send({ type: "prompt", text });
1675
- logMessage("user", `{cyan-fg}→{/cyan-fg} ${text}`);
3187
+ logMessage("user", `{cyan-fg}→{/cyan-fg} ${escapeBlessed(text)}`);
1676
3188
  }
1677
3189
  input.focus();
1678
3190
  });
1679
3191
 
1680
3192
  screen.key(["C-c"], exitHandler);
1681
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
+
1682
3226
  // Dashboard navigation - use screen.on to capture even when input is focused
1683
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
1684
3257
  handleDashboardKey(key);
1685
3258
  });
1686
3259
 
1687
3260
  screen.key(["tab"], () => {
3261
+ if (currentView === "agent") return; // Tab goes to PTY via keypress handler
1688
3262
  if (focusMode === "dashboard") {
1689
3263
  exitDashboardMode(false);
1690
3264
  } else {
@@ -1693,10 +3267,13 @@ async function runChat(projectRoot) {
1693
3267
  });
1694
3268
 
1695
3269
  screen.key(["C-k", "M-k"], () => {
3270
+ if (currentView === "agent") return;
1696
3271
  clearLog();
1697
3272
  });
1698
3273
 
3274
+
1699
3275
  screen.key(["i", "enter"], () => {
3276
+ if (currentView === "agent") return;
1700
3277
  if (focusMode === "dashboard") return;
1701
3278
  if (screen.focused === input) return;
1702
3279
  focusInput();
@@ -1759,8 +3336,24 @@ async function runChat(projectRoot) {
1759
3336
  renderDashboard();
1760
3337
  resizeInput();
1761
3338
  requestStatus();
1762
- setInterval(requestStatus, 2000);
3339
+
3340
+ // 定期刷新 dashboard 状态(兜底,daemon 会主动推送变化)
3341
+ setInterval(() => {
3342
+ if (client && !client.destroyed) {
3343
+ requestStatus();
3344
+ }
3345
+ }, 30000);
3346
+
1763
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
+ }
1764
3357
  resizeInput();
1765
3358
  if (completionActive) hideCompletion();
1766
3359
  input._updateCursor();