u-foo 1.8.3 → 1.8.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "1.8.3",
3
+ "version": "1.8.4",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -177,6 +177,19 @@ function shouldShowLaunchBanner(agentType = "") {
177
177
  return true;
178
178
  }
179
179
 
180
+ function computeInjectedSubmitDelayMs(agentType, text) {
181
+ const normalizedAgent = String(agentType || "").trim().toLowerCase();
182
+ const input = typeof text === "string" ? text : "";
183
+ let delayMs = normalizedAgent === "claude-code" ? 350 : 200;
184
+ if (input.includes("\n")) {
185
+ delayMs += normalizedAgent === "claude-code" ? 250 : 120;
186
+ }
187
+ if (input.length > 512) {
188
+ delayMs += Math.min(1200, Math.ceil(input.length / 512) * 90);
189
+ }
190
+ return delayMs;
191
+ }
192
+
180
193
  async function resolveHostRegistrationData(launchMode) {
181
194
  if (launchMode !== "host") {
182
195
  return {
@@ -557,10 +570,10 @@ class AgentLauncher {
557
570
  let shouldUsePty = false;
558
571
 
559
572
  // 显式开关(优先级最高)
560
- if (process.env.UFOO_DISABLE_PTY === "1") {
561
- shouldUsePty = false; // 强制回退spawn (CI/回滚)
562
- } else if (process.env.UFOO_FORCE_PTY === "1") {
573
+ if (process.env.UFOO_FORCE_PTY === "1") {
563
574
  shouldUsePty = true; // 强制使用PTY (测试/调试)
575
+ } else if (process.env.UFOO_DISABLE_PTY === "1") {
576
+ shouldUsePty = false; // 强制回退spawn (CI/回滚)
564
577
  } else {
565
578
  // 自动检测:Terminal/tmux模式 + 非internal
566
579
  shouldUsePty =
@@ -778,13 +791,15 @@ class AgentLauncher {
778
791
  // Claude Code (Ink TUI) interprets ESC+CR within ~100ms as
779
792
  // Alt+Enter (newline) instead of two separate keys. Use a
780
793
  // longer gap so the escape sequence parser times out.
781
- wrapper.write(req.command);
794
+ const commandText = String(req.command);
795
+ const submitDelayMs = computeInjectedSubmitDelayMs(this.agentType, commandText);
796
+ wrapper.write(commandText);
782
797
  if (normalizedAgentType === "claude-code") {
783
798
  // Claude Code: send CR directly without ESC.
784
799
  // ESC before CR is interpreted as Alt+Enter (newline).
785
800
  setTimeout(() => {
786
801
  wrapper.write("\r");
787
- }, 200);
802
+ }, submitDelayMs);
788
803
  } else {
789
804
  // Codex/others: ESC dismisses autocomplete, then CR submits.
790
805
  setTimeout(() => {
@@ -792,7 +807,7 @@ class AgentLauncher {
792
807
  setTimeout(() => {
793
808
  wrapper.write("\r");
794
809
  }, 100);
795
- }, 200);
810
+ }, submitDelayMs);
796
811
  }
797
812
  client.write(JSON.stringify({ ok: true }) + "\n");
798
813
  if (wrapper.logger) {
@@ -85,6 +85,29 @@ function parseInputMessage(message) {
85
85
  return { raw: false, text: message };
86
86
  }
87
87
 
88
+ function getOuterTerminalSize() {
89
+ const cols = Number.isInteger(process.stdout && process.stdout.columns) && process.stdout.columns > 0
90
+ ? process.stdout.columns
91
+ : 80;
92
+ const rows = Number.isInteger(process.stdout && process.stdout.rows) && process.stdout.rows > 0
93
+ ? process.stdout.rows
94
+ : 24;
95
+ return { cols, rows };
96
+ }
97
+
98
+ function computeInjectedSubmitDelayMs(agentType, text) {
99
+ const normalizedAgent = String(agentType || "").trim().toLowerCase();
100
+ const input = typeof text === "string" ? text : "";
101
+ let delayMs = normalizedAgent === "claude-code" ? 350 : 200;
102
+ if (input.includes("\n")) {
103
+ delayMs += normalizedAgent === "claude-code" ? 250 : 120;
104
+ }
105
+ if (input.length > 512) {
106
+ delayMs += Math.min(1200, Math.ceil(input.length / 512) * 90);
107
+ }
108
+ return delayMs;
109
+ }
110
+
88
111
  function buildPrompt(text, marker) {
89
112
  if (!marker) return text;
90
113
  return `${text}\n\n请在完成后输出以下标记(单独一行):\n${marker}\n`;
@@ -223,6 +246,9 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
223
246
  return 512 * 1024;
224
247
  })();
225
248
  let outputRingBuffer = "";
249
+ let outerInputHandler = null;
250
+ let outerResizeHandler = null;
251
+ let outerRawModeEnabled = false;
226
252
 
227
253
  function initScreenBuffer(cols = 80, rows = 24) {
228
254
  if (!Terminal || !SerializeAddon) return null;
@@ -302,6 +328,13 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
302
328
  function broadcastOutput(data) {
303
329
  const text = Buffer.from(data || "").toString("utf8");
304
330
  if (!text) return;
331
+ if (process.stdout && process.stdout.isTTY && typeof process.stdout.write === "function") {
332
+ try {
333
+ process.stdout.write(text);
334
+ } catch {
335
+ // ignore outer terminal write failures
336
+ }
337
+ }
305
338
  enqueueTermWrite(text);
306
339
  outputRingBuffer += text;
307
340
  if (outputRingBuffer.length > OUTPUT_RING_MAX) {
@@ -337,7 +370,9 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
337
370
  if (req.type === "inject" && req.command) {
338
371
  if (ptyProcess && ptyAlive) {
339
372
  const isClaude = agentType === "claude-code";
340
- ptyProcess.write(String(req.command));
373
+ const commandText = String(req.command);
374
+ const submitDelayMs = computeInjectedSubmitDelayMs(agentType, commandText);
375
+ ptyProcess.write(commandText);
341
376
  if (isClaude) {
342
377
  // Claude Code: send CR directly without ESC.
343
378
  // ESC before CR is interpreted as Alt+Enter (newline).
@@ -345,7 +380,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
345
380
  if (ptyProcess && ptyAlive) {
346
381
  ptyProcess.write("\r");
347
382
  }
348
- }, 200);
383
+ }, submitDelayMs);
349
384
  } else {
350
385
  // Codex/others: ESC dismisses autocomplete, then CR submits.
351
386
  setTimeout(() => {
@@ -356,7 +391,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
356
391
  ptyProcess.write("\r");
357
392
  }
358
393
  }, 100);
359
- }, 200);
394
+ }, submitDelayMs);
360
395
  }
361
396
  client.write(JSON.stringify({ ok: true }) + "\n");
362
397
  } else {
@@ -443,6 +478,60 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
443
478
  return server;
444
479
  }
445
480
 
481
+ function syncOuterTerminalSize() {
482
+ const { cols, rows } = getOuterTerminalSize();
483
+ if (ptyProcess && ptyAlive && typeof ptyProcess.resize === "function") {
484
+ try {
485
+ ptyProcess.resize(cols, rows);
486
+ } catch {
487
+ // ignore outer resize failures
488
+ }
489
+ }
490
+ if (term && typeof term.resize === "function") {
491
+ try {
492
+ term.resize(cols, rows);
493
+ } catch {
494
+ // ignore local screen buffer resize failures
495
+ }
496
+ }
497
+ }
498
+
499
+ function attachOuterTerminalBridge() {
500
+ if (process.stdin && typeof process.stdin.on === "function" && !outerInputHandler) {
501
+ outerInputHandler = (chunk) => {
502
+ if (!ptyProcess || !ptyAlive) return;
503
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
504
+ if (!text) return;
505
+ try {
506
+ ptyProcess.write(text);
507
+ } catch {
508
+ // ignore transient PTY bridge failures
509
+ }
510
+ };
511
+ process.stdin.on("data", outerInputHandler);
512
+ if (typeof process.stdin.resume === "function") {
513
+ process.stdin.resume();
514
+ }
515
+ }
516
+
517
+ if (process.stdin && process.stdin.isTTY && typeof process.stdin.setRawMode === "function" && !outerRawModeEnabled) {
518
+ try {
519
+ process.stdin.setRawMode(true);
520
+ outerRawModeEnabled = true;
521
+ } catch {
522
+ // ignore raw mode failures on unsupported hosts
523
+ }
524
+ }
525
+
526
+ if (process.stdout && process.stdout.isTTY && typeof process.stdout.on === "function" && !outerResizeHandler) {
527
+ outerResizeHandler = () => {
528
+ syncOuterTerminalSize();
529
+ };
530
+ process.stdout.on("resize", outerResizeHandler);
531
+ syncOuterTerminalSize();
532
+ }
533
+ }
534
+
446
535
  function cleanupInjectServer(server) {
447
536
  for (const sub of outputSubscribers) {
448
537
  try { sub.destroy(); } catch { /* ignore */ }
@@ -728,10 +817,11 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
728
817
  clearTimeout(readyTimer);
729
818
  readyTimer = null;
730
819
  }
820
+ const { cols, rows } = getOuterTerminalSize();
731
821
  const proc = pty.spawn(command, args, {
732
822
  name: "xterm-256color",
733
- cols: 80,
734
- rows: 24,
823
+ cols,
824
+ rows,
735
825
  cwd: projectRoot,
736
826
  env,
737
827
  });
@@ -762,6 +852,30 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
762
852
  const stop = () => {
763
853
  running = false;
764
854
  cleanupInjectServer(injectServer);
855
+ if (process.stdin && outerInputHandler) {
856
+ if (typeof process.stdin.off === "function") {
857
+ process.stdin.off("data", outerInputHandler);
858
+ } else if (typeof process.stdin.removeListener === "function") {
859
+ process.stdin.removeListener("data", outerInputHandler);
860
+ }
861
+ outerInputHandler = null;
862
+ }
863
+ if (process.stdout && outerResizeHandler) {
864
+ if (typeof process.stdout.off === "function") {
865
+ process.stdout.off("resize", outerResizeHandler);
866
+ } else if (typeof process.stdout.removeListener === "function") {
867
+ process.stdout.removeListener("resize", outerResizeHandler);
868
+ }
869
+ outerResizeHandler = null;
870
+ }
871
+ if (process.stdin && outerRawModeEnabled && typeof process.stdin.setRawMode === "function") {
872
+ try {
873
+ process.stdin.setRawMode(false);
874
+ } catch {
875
+ // ignore raw mode cleanup failures
876
+ }
877
+ outerRawModeEnabled = false;
878
+ }
765
879
  try {
766
880
  if (ptyProcess) ptyProcess.kill();
767
881
  } catch {
@@ -776,6 +890,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
776
890
  process.on("SIGHUP", () => {});
777
891
 
778
892
  ptyProcess = spawnPtyProcess();
893
+ attachOuterTerminalBridge();
779
894
 
780
895
  function processQueue() {
781
896
  if (busy || messageQueue.length === 0 || !running || !ptyAlive || !ptyReady) return;
@@ -19,6 +19,32 @@ function asTrimmedString(value) {
19
19
  return value.trim();
20
20
  }
21
21
 
22
+ function resolveBootstrapInjectSettleMs(agentType = "", promptText = "", options = {}) {
23
+ const byAgent = options && typeof options.byAgent === "object" ? options.byAgent : {};
24
+ const normalizedAgent = asTrimmedString(agentType).toLowerCase();
25
+ const explicit = Number.isFinite(byAgent[normalizedAgent]) ? byAgent[normalizedAgent] : Number.NaN;
26
+ if (Number.isFinite(explicit) && explicit > 0) {
27
+ return explicit;
28
+ }
29
+
30
+ let delayMs = 0;
31
+ if (normalizedAgent === "claude") {
32
+ delayMs = 800;
33
+ } else if (normalizedAgent === "codex") {
34
+ delayMs = 250;
35
+ }
36
+
37
+ const text = typeof promptText === "string" ? promptText : "";
38
+ if (!text) return delayMs;
39
+ if (text.includes("\n")) {
40
+ delayMs += normalizedAgent === "claude" ? 200 : 100;
41
+ }
42
+ if (text.length > 1024) {
43
+ delayMs += Math.min(800, Math.ceil(text.length / 1024) * 120);
44
+ }
45
+ return delayMs;
46
+ }
47
+
22
48
  function asStringArray(value) {
23
49
  if (!Array.isArray(value)) return [];
24
50
  return value.map((item) => asTrimmedString(item)).filter(Boolean);
@@ -608,6 +634,7 @@ function createGroupOrchestrator(options = {}) {
608
634
  bootstrapRetryDelayMs = 250,
609
635
  bootstrapProtectionMs = 3000,
610
636
  bootstrapWorkingGraceMs = 10000,
637
+ bootstrapInjectSettleMsByAgent = {},
611
638
  } = options;
612
639
 
613
640
  if (!projectRoot) {
@@ -775,6 +802,7 @@ function createGroupOrchestrator(options = {}) {
775
802
 
776
803
  const rollbackTargets = [];
777
804
  const eventBus = new EventBus(projectRoot);
805
+ const tmuxLayoutContext = { mode: "group-right-column" };
778
806
 
779
807
  for (let i = 0; i < compiled.executionPlan.length; i += 1) {
780
808
  const item = compiled.executionPlan[i];
@@ -810,6 +838,8 @@ function createGroupOrchestrator(options = {}) {
810
838
  agent: item.type,
811
839
  count: 1,
812
840
  nickname: item.nickname,
841
+ require_activity_monitor: true,
842
+ tmux_layout_context: tmuxLayoutContext,
813
843
  ...launchHostContext,
814
844
  };
815
845
  if (Object.keys(extraEnv).length > 0) {
@@ -924,6 +954,18 @@ function createGroupOrchestrator(options = {}) {
924
954
  member.bootstrap_error
925
955
  );
926
956
  }
957
+ const settleDelayMs = resolveBootstrapInjectSettleMs(
958
+ item.type,
959
+ item.bootstrap_prompt,
960
+ { byAgent: bootstrapInjectSettleMsByAgent }
961
+ );
962
+ if (settleDelayMs > 0) {
963
+ // Claude/Codex TUIs can render their prompt before the input handler
964
+ // is fully ready for a large bootstrap paste. Give the UI a short
965
+ // provider-specific settle window before injecting the group prompt.
966
+ // eslint-disable-next-line no-await-in-loop
967
+ await sleep(settleDelayMs);
968
+ }
927
969
  // eslint-disable-next-line no-await-in-loop
928
970
  const injected = await injectBootstrapPrompt(
929
971
  eventBus,
@@ -1058,4 +1100,5 @@ module.exports = {
1058
1100
  createGroupOrchestrator,
1059
1101
  buildLaunchPlan,
1060
1102
  normalizeGroupId,
1103
+ resolveBootstrapInjectSettleMs,
1061
1104
  };
@@ -472,6 +472,12 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
472
472
  const launchResult = await launchAgent(projectRoot, agent, count, nickname, processManager, {
473
473
  launchScope: op.launch_scope || "",
474
474
  terminalApp: op.terminal_app || "",
475
+ tmuxLayoutContext:
476
+ op.tmux_layout_context && typeof op.tmux_layout_context === "object"
477
+ ? op.tmux_layout_context
478
+ : ((op.tmuxLayoutContext && typeof op.tmuxLayoutContext === "object")
479
+ ? op.tmuxLayoutContext
480
+ : null),
475
481
  extraEnv:
476
482
  op.extra_env && typeof op.extra_env === "object"
477
483
  ? op.extra_env
@@ -486,6 +492,8 @@ async function handleOps(projectRoot, ops = [], processManager = null) {
486
492
  : ((op.hostCapabilities && typeof op.hostCapabilities === "object")
487
493
  ? op.hostCapabilities
488
494
  : null),
495
+ requireActivityMonitor:
496
+ op.require_activity_monitor === true || op.requireActivityMonitor === true,
489
497
  });
490
498
  if (launchResult.mode === "internal" && launchResult.subscriberIds && launchResult.subscriberIds.length > 0) {
491
499
  const probeAgentType = agent === "codex"
package/src/daemon/ops.js CHANGED
@@ -102,6 +102,7 @@ function resolveHostLaunchContext(options = {}) {
102
102
  options.hostCapabilities && typeof options.hostCapabilities === "object"
103
103
  ? { ...options.hostCapabilities }
104
104
  : null,
105
+ requireActivityMonitor: options.requireActivityMonitor === true,
105
106
  };
106
107
  }
107
108
 
@@ -450,7 +451,7 @@ async function spawnManagedHostAgent(
450
451
  nickname = "",
451
452
  processManager = null,
452
453
  extraArgs = [],
453
- extraEnv = "",
454
+ extraEnv = {},
454
455
  hostOptions = {}
455
456
  ) {
456
457
  void processManager;
@@ -462,11 +463,11 @@ async function spawnManagedHostAgent(
462
463
  }
463
464
 
464
465
  const hostContext = resolveHostLaunchContext(hostOptions);
466
+ const requireActivityMonitor = hostContext.requireActivityMonitor === true;
465
467
  if (!hostContext.hostDaemonSock) {
466
468
  throw new Error("host launch requires UFOO_HOST_DAEMON_SOCK");
467
469
  }
468
470
 
469
- const existing = listSubscribers(projectRoot, agentType);
470
471
  const createOptions = {};
471
472
  if (hostOptions.groupId) {
472
473
  createOptions.group_id = String(hostOptions.groupId).trim();
@@ -474,22 +475,87 @@ async function spawnManagedHostAgent(
474
475
  createOptions.source_session_id = hostContext.hostSessionId;
475
476
  }
476
477
 
478
+ // Pre-register subscriber on the bus so waitForNewSubscriber resolves immediately
479
+ const crypto = require("crypto");
480
+ const EventBus = require("../bus");
481
+ const existing = listSubscribers(projectRoot, agentType);
482
+ let subscriberId = "";
483
+ let preRegistrationError = null;
484
+ try {
485
+ const bus = new EventBus(projectRoot);
486
+ await bus.init();
487
+ if (bus.subscriberManager) {
488
+ const sessionToken = crypto.randomBytes(4).toString("hex");
489
+ subscriberId = `${agentType}:${sessionToken}`;
490
+ const defaultNickname = agentType === "ufoo-code" ? "ucode" : normalizedAgent;
491
+ const finalNickname = nickname || defaultNickname;
492
+ await bus.subscriberManager.join(sessionToken, agentType, finalNickname, {
493
+ launchMode: "host",
494
+ parentPid: process.pid,
495
+ });
496
+ bus.saveBusData();
497
+ }
498
+ } catch (err) {
499
+ preRegistrationError = err;
500
+ subscriberId = "";
501
+ }
502
+
477
503
  const args = Array.isArray(extraArgs) ? extraArgs : [];
478
504
  const argText = args.length > 0 ? ` ${args.map(shellEscape).join(" ")}` : "";
479
- const envParts = ["UFOO_LAUNCH_MODE=host"];
505
+
506
+ const titleCmd = buildTitleCmd(nickname);
507
+ const hasPreRegisteredSubscriber = !!subscriberId;
508
+
509
+ // Pass env vars to Horizon via the env parameter (Horizon will set them for the child process)
510
+ const env = {
511
+ UFOO_LAUNCH_MODE: "host",
512
+ };
513
+ if (requireActivityMonitor) {
514
+ env.UFOO_FORCE_PTY = "1";
515
+ }
516
+ if (subscriberId) {
517
+ env.UFOO_SUBSCRIBER_ID = subscriberId;
518
+ }
480
519
  if (nickname) {
481
- envParts.push(`UFOO_NICKNAME=${shellEscape(nickname)}`);
520
+ env.UFOO_NICKNAME = nickname;
482
521
  }
483
- if (extraEnv) {
484
- envParts.push(String(extraEnv).trim());
522
+ // Parse extraEnv string (e.g., "UFOO_UCODE_BOOTSTRAP_FILE=/path/to/file") and add to env
523
+ if (extraEnv && typeof extraEnv === "object") {
524
+ for (const [key, value] of Object.entries(extraEnv)) {
525
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(key || ""))) {
526
+ env[String(key)] = String(value ?? "");
527
+ }
528
+ }
485
529
  }
486
530
 
487
- const titleCmd = buildTitleCmd(nickname);
488
- const launchCmd = `${envParts.join(" ")} ${binary}${argText}`.trim();
489
- const runCmd = titleCmd
490
- ? `cd ${shellEscape(projectRoot)} && ${titleCmd} && ${launchCmd}`
491
- : `cd ${shellEscape(projectRoot)} && ${launchCmd}`;
531
+ let runCmd;
532
+ if (hasPreRegisteredSubscriber) {
533
+ // Group mode: use ufoo launcher for activity_state monitoring
534
+ // This enables ReadyDetector and bootstrap to work correctly
535
+ const ufooRunner = path.join(projectRoot, "bin", "ufoo.js");
536
+ const launchCmd = `${shellEscape(process.execPath)} ${shellEscape(ufooRunner)} agent-pty-runner ${shellEscape(normalizedAgent)}${argText}`.trim();
537
+ runCmd = titleCmd
538
+ ? `cd ${shellEscape(projectRoot)} && ${titleCmd} && ${launchCmd}`
539
+ : `cd ${shellEscape(projectRoot)} && ${launchCmd}`;
540
+ // Force PTY wrapper so ReadyDetector + ActivityDetector work for activity_state monitoring.
541
+ // Horizon sets UFOO_DISABLE_PTY=1 unconditionally; UFOO_FORCE_PTY=1 takes priority over it.
542
+ env.UFOO_FORCE_PTY = "1";
543
+ } else {
544
+ if (preRegistrationError) {
545
+ console.error(
546
+ `[host-launch] pre-registration failed for ${nickname || agentType}: ${preRegistrationError.message || String(preRegistrationError)}`
547
+ );
548
+ }
549
+ // Fallback launch still goes through the regular agent launcher binary.
550
+ // For group/bootstrap-monitored flows we also force the PTY wrapper so
551
+ // activity_state can progress out of "starting" after self-registration.
552
+ const directCmd = `${binary}${argText}`;
553
+ runCmd = titleCmd
554
+ ? `cd ${shellEscape(projectRoot)} && ${titleCmd} && ${directCmd}`
555
+ : `cd ${shellEscape(projectRoot)} && ${directCmd}`;
556
+ }
492
557
  createOptions.command = runCmd;
558
+ createOptions.env = env;
493
559
 
494
560
  const created = await createHostSession(hostContext.hostDaemonSock, createOptions);
495
561
  const sessionId = normalizeOptionalString(created?.session_id);
@@ -498,8 +564,16 @@ async function spawnManagedHostAgent(
498
564
  throw new Error("host create_session returned incomplete session info");
499
565
  }
500
566
 
501
- const subscriberId = await waitForNewSubscriber(projectRoot, agentType, existing, 20000);
502
- return { child: null, subscriberId: subscriberId || null, sessionId, injectSock };
567
+ // If pre-registration succeeded we already have the subscriber ID;
568
+ // otherwise fall back to polling (slower but still works).
569
+ if (!subscriberId) {
570
+ subscriberId = await waitForNewSubscriber(projectRoot, agentType, existing, 20000);
571
+ }
572
+
573
+ // Return format must match what launchAgent expects: { mode, launchScope, subscriberIds }
574
+ // subscriberIds is an array for consistency with other launch modes
575
+ const resultSubscriberId = subscriberId || null;
576
+ return { child: null, subscriberId: resultSubscriberId, subscriberIds: [resultSubscriberId].filter(Boolean), sessionId, injectSock };
503
577
  }
504
578
 
505
579
  async function spawnInternalAgent(projectRoot, agent, count = 1, nickname = "", processManager = null, extraEnv = {}) {
@@ -679,7 +753,49 @@ function resolveTmuxPaneTarget() {
679
753
  return "";
680
754
  }
681
755
 
682
- function spawnTmuxPane(projectRoot, agent, nickname = "", extraArgs = [], extraEnv = "", target = "") {
756
+ function runTmuxCommand(tmuxArgs = [], failureMessage = "tmux command failed", captureStdout = false) {
757
+ return new Promise((resolve, reject) => {
758
+ const proc = spawn("tmux", tmuxArgs);
759
+ let stdout = "";
760
+ let stderr = "";
761
+ proc.stdout.on("data", (d) => {
762
+ stdout += d.toString("utf8");
763
+ });
764
+ proc.stderr.on("data", (d) => {
765
+ stderr += d.toString("utf8");
766
+ });
767
+ proc.on("error", (err) => reject(err));
768
+ proc.on("close", (code) => {
769
+ if (code === 0) {
770
+ resolve(captureStdout ? stdout.trim() : "");
771
+ } else {
772
+ reject(new Error(stderr || failureMessage));
773
+ }
774
+ });
775
+ });
776
+ }
777
+
778
+ function applyTmuxLayout(layout = "", target = "") {
779
+ const normalizedLayout = String(layout || "").trim();
780
+ if (!normalizedLayout) return Promise.resolve("");
781
+ const tmuxArgs = ["select-layout"];
782
+ const normalizedTarget = String(target || "").trim();
783
+ if (normalizedTarget) {
784
+ tmuxArgs.push("-t", normalizedTarget);
785
+ }
786
+ tmuxArgs.push(normalizedLayout);
787
+ return runTmuxCommand(tmuxArgs, "tmux select-layout failed");
788
+ }
789
+
790
+ function spawnTmuxPane(
791
+ projectRoot,
792
+ agent,
793
+ nickname = "",
794
+ extraArgs = [],
795
+ extraEnv = "",
796
+ target = "",
797
+ splitOptions = {}
798
+ ) {
683
799
  return new Promise((resolve, reject) => {
684
800
  const normalizedAgent = normalizeLaunchAgent(agent);
685
801
  const binary = toTmuxBinary(normalizedAgent);
@@ -697,21 +813,25 @@ function spawnTmuxPane(projectRoot, agent, nickname = "", extraArgs = [], extraE
697
813
  const runCmd = `cd ${shellEscape(projectRoot)} && ${setPaneEnv}${modeEnv}${nickEnv}${ttyEnv}${envPrefix}${binary}${argText}`;
698
814
 
699
815
  const tmuxArgs = ["split-window", "-d"];
816
+ const orientation = String(splitOptions.orientation || "").trim().toLowerCase();
817
+ if (orientation === "horizontal") {
818
+ tmuxArgs.push("-h");
819
+ } else if (orientation === "vertical") {
820
+ tmuxArgs.push("-v");
821
+ }
822
+ const capturePaneId = splitOptions.capturePaneId === true;
823
+ if (capturePaneId) {
824
+ tmuxArgs.push("-P", "-F", "#{pane_id}");
825
+ }
700
826
  const normalizedTarget = String(target || "").trim();
701
827
  if (normalizedTarget) {
702
828
  tmuxArgs.push("-t", normalizedTarget);
703
829
  }
704
830
  tmuxArgs.push(runCmd);
705
831
 
706
- const proc = spawn("tmux", tmuxArgs);
707
- let stderr = "";
708
- proc.stderr.on("data", (d) => {
709
- stderr += d.toString("utf8");
710
- });
711
- proc.on("close", (code) => {
712
- if (code === 0) resolve();
713
- else reject(new Error(stderr || "tmux split-window failed"));
714
- });
832
+ runTmuxCommand(tmuxArgs, "tmux split-window failed", capturePaneId)
833
+ .then((paneId) => resolve({ paneId: capturePaneId ? paneId : "" }))
834
+ .catch(reject);
715
835
  });
716
836
  }
717
837
 
@@ -762,6 +882,12 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
762
882
  }
763
883
  const paneTarget = resolveTmuxPaneTarget();
764
884
  const useSeparateWindow = launchScope === "window";
885
+ const tmuxLayoutContext = options.tmuxLayoutContext && typeof options.tmuxLayoutContext === "object"
886
+ ? options.tmuxLayoutContext
887
+ : null;
888
+ const useGroupRightColumnLayout = !useSeparateWindow
889
+ && tmuxLayoutContext
890
+ && tmuxLayoutContext.mode === "group-right-column";
765
891
  for (let i = 0; i < count; i += 1) {
766
892
  // Use "ucode" as default nickname for ufoo/ucode agents
767
893
  const defaultNick = normalizedAgent === "ufoo" ? "ucode" : normalizedAgent;
@@ -769,6 +895,31 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
769
895
  if (useSeparateWindow) {
770
896
  // eslint-disable-next-line no-await-in-loop
771
897
  await spawnTmuxWindow(projectRoot, normalizedAgent, nick, [], extraEnvPrefix);
898
+ } else if (useGroupRightColumnLayout && paneTarget) {
899
+ const basePane = String(tmuxLayoutContext.basePane || paneTarget).trim() || paneTarget;
900
+ tmuxLayoutContext.basePane = basePane;
901
+ const rightColumnPane = String(tmuxLayoutContext.rightColumnPane || "").trim();
902
+ const splitTarget = rightColumnPane || basePane;
903
+ const splitOrientation = rightColumnPane ? "vertical" : "horizontal";
904
+ let splitResult;
905
+ try {
906
+ // eslint-disable-next-line no-await-in-loop
907
+ splitResult = await spawnTmuxPane(projectRoot, normalizedAgent, nick, [], extraEnvPrefix, splitTarget, {
908
+ orientation: splitOrientation,
909
+ capturePaneId: !rightColumnPane,
910
+ });
911
+ } catch {
912
+ // Fallback to new window when current pane target cannot be resolved.
913
+ // eslint-disable-next-line no-await-in-loop
914
+ await spawnTmuxWindow(projectRoot, normalizedAgent, nick, [], extraEnvPrefix);
915
+ continue;
916
+ }
917
+ if (!rightColumnPane && splitResult && splitResult.paneId) {
918
+ tmuxLayoutContext.rightColumnPane = splitResult.paneId;
919
+ }
920
+ // Keep the original chat pane on the left while stacking agents evenly on the right.
921
+ // eslint-disable-next-line no-await-in-loop
922
+ await applyTmuxLayout("main-vertical", basePane);
772
923
  } else {
773
924
  try {
774
925
  // eslint-disable-next-line no-await-in-loop
@@ -795,7 +946,7 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
795
946
  nick,
796
947
  processManager,
797
948
  [],
798
- extraEnvPrefix,
949
+ extraEnvObject,
799
950
  hostContext
800
951
  );
801
952
  if (result.subscriberId) subscriberIds.push(result.subscriberId);
@@ -334,7 +334,7 @@ async function requestCloseSession(sockPath) {
334
334
  /**
335
335
  * Create a new terminal session via the daemon management socket.
336
336
  * @param {string} [daemonSock] - Override daemon socket path (defaults to env)
337
- * @param {object} [opts] - Options: { group_id, source_session_id, command }
337
+ * @param {object} [opts] - Options: { group_id, source_session_id, command, env }
338
338
  * @returns {Promise<{session_id: string, inject_sock: string}>}
339
339
  */
340
340
  async function createSession(daemonSock, opts = {}) {
@@ -343,6 +343,7 @@ async function createSession(daemonSock, opts = {}) {
343
343
  if (opts.group_id) req.group_id = opts.group_id;
344
344
  if (opts.source_session_id) req.source_session_id = opts.source_session_id;
345
345
  if (opts.command) req.command = opts.command;
346
+ if (opts.env) req.env = opts.env;
346
347
  return sendToSocket(sock, req);
347
348
  }
348
349