@a5c-ai/agent-mux-cli 0.4.10-staging.ff407b73 → 5.0.1-staging.04a3db697

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.
@@ -14,6 +14,7 @@ import { printError, printJsonError } from '../output.js';
14
14
  /** Launch-specific flag definitions (global flags like model/json/debug are excluded). */
15
15
  export const LAUNCH_FLAGS = {
16
16
  'api-key': { type: 'string' },
17
+ 'profile': { type: 'string' },
17
18
  'api-base': { type: 'string' },
18
19
  'region': { type: 'string' },
19
20
  'project': { type: 'string' },
@@ -29,6 +30,7 @@ export const LAUNCH_FLAGS = {
29
30
  'resume': { short: 'r', type: 'string' },
30
31
  'session-id': { short: 's', type: 'string' },
31
32
  'prompt': { short: 'p', type: 'string' },
33
+ 'interactive': { short: 'i', type: 'boolean' },
32
34
  'max-turns': { type: 'number' },
33
35
  'max-budget-usd': { type: 'number' },
34
36
  'dry-run': { type: 'boolean' },
@@ -39,14 +41,16 @@ export const LAUNCH_FLAGS = {
39
41
  'workspace-mode': { type: 'string' },
40
42
  'workspace-repo': { type: 'string', repeatable: true },
41
43
  'workspace-name': { type: 'string' },
44
+ 'yolo': { type: 'boolean' },
45
+ 'bridge-interactive': { type: 'boolean' },
46
+ 'bridge-hooks': { type: 'boolean' },
42
47
  };
43
48
  // ---------------------------------------------------------------------------
44
49
  // Plan resolution
45
50
  // ---------------------------------------------------------------------------
46
51
  export function resolveLaunchPlan(input) {
47
- const providerId = (input.provider ?? 'anthropic');
48
52
  const providerConfig = resolveProvider({
49
- provider: providerId,
53
+ provider: input.provider,
50
54
  model: input.model,
51
55
  transport: input.transport,
52
56
  apiKey: input.apiKey,
@@ -56,6 +60,7 @@ export function resolveLaunchPlan(input) {
56
60
  resourceGroup: input.resourceGroup,
57
61
  endpointName: input.endpointName,
58
62
  authCommand: input.authCommand,
63
+ profile: input.profile,
59
64
  });
60
65
  // Merge extra provider args into params
61
66
  if (input.providerArgs) {
@@ -71,29 +76,34 @@ export function resolveLaunchPlan(input) {
71
76
  }
72
77
  else {
73
78
  proxyNeeded = false;
74
- proxyReason = `${input.harness} supports ${providerId} natively`;
79
+ proxyReason = `${input.harness} supports ${providerConfig.provider} natively`;
75
80
  }
76
81
  }
77
82
  else {
78
83
  if (input.proxyMode === 'never') {
79
- throw new Error(`${input.harness} does not support ${providerId} natively. ` +
84
+ throw new Error(`${input.harness} does not support ${providerConfig.provider} natively. ` +
80
85
  `Use --with-proxy-if-needed to auto-launch the proxy.`);
81
86
  }
82
87
  proxyReason =
83
- `${input.harness} does not support ${providerId} natively; ` +
84
- `proxy bridges ${providerId} → ${translation.proxyExposedTransport}`;
88
+ `${input.harness} does not support ${providerConfig.provider} natively; ` +
89
+ `proxy bridges ${providerConfig.provider} → ${translation.proxyExposedTransport}`;
85
90
  }
86
91
  const proxy = proxyNeeded
87
92
  ? {
88
- targetProvider: providerId,
93
+ targetProvider: providerConfig.provider,
89
94
  targetModel: providerConfig.model,
90
95
  exposedTransport: translation.proxyExposedTransport ?? 'openai-chat',
91
96
  port: input.proxyPort ?? 0,
97
+ apiBase: providerConfig.params['apiBase'] ? String(providerConfig.params['apiBase']) : undefined,
98
+ apiKey: providerConfig.auth.apiKey,
99
+ project: providerConfig.params['project'] ? String(providerConfig.params['project']) : undefined,
100
+ location: providerConfig.params['region'] ? String(providerConfig.params['region']) : undefined,
101
+ useVertexAi: providerConfig.provider === 'vertex' || Boolean(providerConfig.params['useVertexAi']),
92
102
  }
93
103
  : undefined;
94
104
  return {
95
105
  harness: input.harness,
96
- provider: providerId,
106
+ provider: providerConfig.provider,
97
107
  transport: providerConfig.transport,
98
108
  model: providerConfig.model,
99
109
  proxyNeeded,
@@ -105,14 +115,19 @@ export function resolveLaunchPlan(input) {
105
115
  };
106
116
  }
107
117
  function appendHarnessSessionArgs(plan, session) {
118
+ const interactive = session.interactive !== false;
108
119
  switch (plan.harness) {
109
120
  case 'claude':
121
+ if (session.bridgeInteractive) {
122
+ plan.args.push('--bare');
123
+ }
110
124
  if (session.resumeId)
111
125
  plan.args.push('--resume', session.resumeId);
112
126
  if (session.sessionId)
113
127
  plan.args.push('--session-id', session.sessionId);
114
- if (session.prompt)
115
- plan.args.push('--print', session.prompt);
128
+ if (session.prompt && !interactive) {
129
+ plan.args.push('-p', session.prompt);
130
+ }
116
131
  if (session.maxTurns)
117
132
  plan.args.push('--max-turns', String(session.maxTurns));
118
133
  break;
@@ -120,7 +135,7 @@ function appendHarnessSessionArgs(plan, session) {
120
135
  if (session.resumeId) {
121
136
  plan.args.unshift('resume', session.resumeId);
122
137
  }
123
- else if (session.prompt) {
138
+ else if (session.prompt && !interactive) {
124
139
  plan.args.unshift('exec', session.prompt);
125
140
  }
126
141
  break;
@@ -128,10 +143,14 @@ function appendHarnessSessionArgs(plan, session) {
128
143
  if (session.prompt)
129
144
  plan.args.push('--prompt', session.prompt);
130
145
  break;
146
+ case 'pi':
147
+ if (session.prompt && !interactive) {
148
+ plan.args.push('--prompt', session.prompt);
149
+ }
150
+ break;
131
151
  case 'opencode':
132
152
  if (session.resumeId)
133
153
  plan.args.push('--session', session.resumeId);
134
- // OpenCode has no non-interactive prompt flag; prompt delivered via stdin after launch
135
154
  break;
136
155
  }
137
156
  }
@@ -276,6 +295,7 @@ export async function launchCommand(client, args) {
276
295
  resourceGroup: flagStr(args.flags, 'resource-group'),
277
296
  endpointName: flagStr(args.flags, 'endpoint-name'),
278
297
  authCommand: flagStr(args.flags, 'auth-command'),
298
+ profile: flagStr(args.flags, 'profile'),
279
299
  proxyMode,
280
300
  proxyPort: flagNum(args.flags, 'proxy-port'),
281
301
  adapter: adapter,
@@ -296,6 +316,7 @@ export async function launchCommand(client, args) {
296
316
  model: flagStr(args.flags, 'model'),
297
317
  apiKey: flagStr(args.flags, 'api-key'),
298
318
  authCommand: flagStr(args.flags, 'auth-command'),
319
+ profile: flagStr(args.flags, 'profile'),
299
320
  });
300
321
  if (resolvedConfig.auth.type === 'api_key' && !resolvedConfig.auth.apiKey) {
301
322
  const defaults = (await import('@a5c-ai/agent-mux-core')).PROVIDER_DEFAULTS;
@@ -361,6 +382,45 @@ export async function launchCommand(client, args) {
361
382
  }
362
383
  launchCwd = resolveWorkspaceDefaultCwd(workspace);
363
384
  }
385
+ // Resolve interactive mode (default: true)
386
+ const interactiveFlag = flagBool(args.flags, 'interactive');
387
+ const isInteractive = interactiveFlag !== false;
388
+ // Bridge flags: --bridge-interactive and --bridge-hooks
389
+ const bridgeInteractive = flagBool(args.flags, 'bridge-interactive') === true;
390
+ const bridgeHooks = flagBool(args.flags, 'bridge-hooks') === true;
391
+ if (bridgeInteractive && isInteractive) {
392
+ const msg = '--bridge-interactive requires --no-interactive';
393
+ if (jsonMode)
394
+ printJsonError('VALIDATION_ERROR', msg);
395
+ else
396
+ printError(msg);
397
+ return ExitCode.USAGE_ERROR;
398
+ }
399
+ if (bridgeHooks && isInteractive) {
400
+ const msg = '--bridge-hooks requires --no-interactive';
401
+ if (jsonMode)
402
+ printJsonError('VALIDATION_ERROR', msg);
403
+ else
404
+ printError(msg);
405
+ return ExitCode.USAGE_ERROR;
406
+ }
407
+ if (bridgeInteractive) {
408
+ try {
409
+ const { getBridgeCapabilities } = await import('@a5c-ai/agent-catalog');
410
+ const caps = getBridgeCapabilities(plan.harness);
411
+ if (!caps?.interactiveBridge) {
412
+ const msg = `${plan.harness} does not support interactive bridging`;
413
+ if (jsonMode)
414
+ printJsonError('CAPABILITY_ERROR', msg);
415
+ else
416
+ printError(msg);
417
+ return ExitCode.USAGE_ERROR;
418
+ }
419
+ }
420
+ catch {
421
+ // agent-catalog not available — skip capability check
422
+ }
423
+ }
364
424
  // Append session/prompt args
365
425
  const prompt = flagStr(args.flags, 'prompt');
366
426
  appendHarnessSessionArgs(plan, {
@@ -368,23 +428,104 @@ export async function launchCommand(client, args) {
368
428
  sessionId: flagStr(args.flags, 'session-id'),
369
429
  prompt,
370
430
  maxTurns: flagNum(args.flags, 'max-turns'),
431
+ interactive: isInteractive || bridgeInteractive,
432
+ bridgeInteractive,
371
433
  });
434
+ // Add --model for harnesses that accept it as a CLI arg
435
+ const modelFlag = flagStr(args.flags, 'model');
436
+ if (modelFlag && ['pi', 'gemini', 'opencode'].includes(plan.harness)) {
437
+ plan.args.push('--model', modelFlag);
438
+ }
439
+ // --yolo: add harness-specific auto-approve flags resolved through
440
+ // agent-catalog → atlas graph (LaunchConfig records with commArgs)
441
+ if (flagBool(args.flags, 'yolo')) {
442
+ try {
443
+ const { getYoloLaunchArgs } = await import('@a5c-ai/agent-catalog');
444
+ const yoloArgs = getYoloLaunchArgs(plan.harness);
445
+ if (yoloArgs.length > 0) {
446
+ plan.args.push(...yoloArgs);
447
+ }
448
+ }
449
+ catch {
450
+ // agent-catalog not available
451
+ }
452
+ }
372
453
  // Passthrough args after --
373
454
  const dashDashIdx = process.argv.indexOf('--');
374
455
  if (dashDashIdx >= 0) {
375
456
  plan.args.push(...process.argv.slice(dashDashIdx + 1));
376
457
  }
458
+ // Also check parsed positionals for -- separator (handles spawn() without shell)
459
+ const argsDashIdx = args.positionals.indexOf('--');
460
+ if (argsDashIdx >= 0) {
461
+ plan.args.push(...args.positionals.slice(argsDashIdx + 1));
462
+ }
377
463
  // Launch runtime if needed
378
464
  let proxyRuntime;
379
465
  if (plan.proxyNeeded && plan.proxy) {
380
466
  try {
467
+ // When exposed transport differs from target (e.g., anthropic→foundry),
468
+ // the proxy needs a completion engine to translate request/response formats.
469
+ let completionEngine;
470
+ if ((plan.proxy.targetProvider === 'google' || plan.proxy.targetProvider === 'vertex') && plan.proxy.apiKey) {
471
+ const { createGoogleCompletionEngine } = await import('./launch-completion-engine.js');
472
+ completionEngine = createGoogleCompletionEngine({
473
+ apiBase: plan.proxy.useVertexAi ? undefined : plan.proxy.apiBase,
474
+ apiKey: plan.proxy.apiKey,
475
+ targetModel: plan.proxy.targetModel,
476
+ provider: plan.proxy.targetProvider,
477
+ project: plan.proxy.project,
478
+ location: plan.proxy.location,
479
+ useVertexAi: plan.proxy.useVertexAi,
480
+ });
481
+ }
482
+ else if (plan.proxy.apiBase && plan.proxy.apiKey) {
483
+ const { createOpenAICompletionEngine } = await import('./launch-completion-engine.js');
484
+ completionEngine = createOpenAICompletionEngine({
485
+ apiBase: plan.proxy.apiBase,
486
+ apiKey: plan.proxy.apiKey,
487
+ targetModel: plan.proxy.targetModel,
488
+ });
489
+ }
381
490
  proxyRuntime = await startTransportMuxRuntime({
382
491
  targetProvider: plan.proxy.targetProvider,
383
492
  targetModel: `${plan.proxy.targetProvider}/${plan.proxy.targetModel}`,
384
493
  exposedTransport: plan.proxy.exposedTransport,
385
494
  port: plan.proxy.port,
495
+ apiBase: plan.proxy.apiBase,
496
+ completionEngine,
386
497
  });
387
498
  proxyRuntime.applyHarnessEnv(plan.env);
499
+ // Pi ignores OPENAI_BASE_URL — write a models.json config that registers
500
+ // a custom provider pointing to the local proxy.
501
+ if (plan.harness === 'pi') {
502
+ const { writeFileSync, mkdirSync } = await import('node:fs');
503
+ const { join } = await import('node:path');
504
+ const piConfigDir = process.env['PI_CODING_AGENT_DIR']
505
+ ?? join(process.env['HOME'] ?? process.env['USERPROFILE'] ?? '/tmp', '.pi', 'agent');
506
+ mkdirSync(piConfigDir, { recursive: true });
507
+ const modelsConfig = {
508
+ providers: {
509
+ 'amux-proxy': {
510
+ baseUrl: `${proxyRuntime.url}/v1`,
511
+ api: 'openai-completions',
512
+ apiKey: proxyRuntime.authToken ?? 'proxy-token',
513
+ models: [{
514
+ id: plan.model,
515
+ reasoning: false,
516
+ input: ['text'],
517
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
518
+ contextWindow: 128000,
519
+ maxTokens: 16384,
520
+ }],
521
+ },
522
+ },
523
+ };
524
+ const modelsPath = join(piConfigDir, 'models.json');
525
+ writeFileSync(modelsPath, JSON.stringify(modelsConfig, null, 2));
526
+ plan.args.push('--provider', 'amux-proxy');
527
+ console.error(`[amux launch] Pi proxy config written to ${modelsPath}, proxy at ${proxyRuntime.url}`);
528
+ }
388
529
  }
389
530
  catch (err) {
390
531
  const msg = err instanceof Error ? err.message : String(err);
@@ -395,14 +536,28 @@ export async function launchCommand(client, args) {
395
536
  return ExitCode.GENERAL_ERROR;
396
537
  }
397
538
  }
539
+ // Bridge hooks: emulate lifecycle hooks when --bridge-hooks is set
540
+ let bridgeHookEmulator;
541
+ if (bridgeHooks) {
542
+ const { BridgeHookEmulator } = await import('./launch-bridge-hooks.js');
543
+ bridgeHookEmulator = new BridgeHookEmulator({
544
+ harness: plan.harness,
545
+ cwd: launchCwd,
546
+ env: plan.env,
547
+ sessionId: flagStr(args.flags, 'session-id'),
548
+ runsDir: plan.env['BABYSITTER_RUNS_DIR'] || undefined,
549
+ verbose: flagBool(args.flags, 'debug') === true,
550
+ });
551
+ await bridgeHookEmulator.emulateSessionStart();
552
+ }
398
553
  // Spawn harness
399
- const isInteractive = !prompt;
400
- let child;
554
+ let child = null;
401
555
  let ptyProcess = null;
402
556
  if (isInteractive) {
403
- // Try to use node-pty for TUI harnesses
557
+ // Interactive mode: full TTY passthrough. If a prompt is provided, it's
558
+ // injected as initial stdin after the harness starts (like typing it in).
404
559
  try {
405
- const nodePty = require('node-pty'); // dynamic require — node-pty is optional
560
+ const nodePty = await import('node-pty');
406
561
  ptyProcess = nodePty.spawn(plan.command, plan.args, {
407
562
  name: 'xterm-256color',
408
563
  cols: process.stdout.columns || 80,
@@ -410,8 +565,61 @@ export async function launchCommand(client, args) {
410
565
  cwd: launchCwd,
411
566
  env: { ...process.env, ...plan.env },
412
567
  });
413
- // Pipe PTY to stdout and stdin to PTY
414
- ptyProcess.onData((data) => process.stdout.write(data));
568
+ // End-of-turn detection: parse PTY output through adapter's event system
569
+ let turnDetected = false;
570
+ let lineBuf = '';
571
+ let assembler = null;
572
+ let adapter = null;
573
+ try {
574
+ const core = await import('@a5c-ai/agent-mux-core');
575
+ assembler = new core.StreamAssembler();
576
+ // Resolve the adapter for this harness to use its parseEvent
577
+ const adaptersModule = await import('@a5c-ai/agent-mux-adapters');
578
+ const factory = adaptersModule.getAdapterFactory?.(plan.harness);
579
+ adapter = factory ? factory() : null;
580
+ }
581
+ catch { /* core/adapters not available */ }
582
+ // Pipe PTY to stdout + feed through event parser for turn detection
583
+ ptyProcess.onData((data) => {
584
+ process.stdout.write(data);
585
+ if (!assembler || !adapter || turnDetected)
586
+ return;
587
+ // Strip ANSI escapes, then feed lines to the event parser
588
+ const clean = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/\x1b\][^\x07]*\x07/g, '');
589
+ lineBuf += clean;
590
+ let idx;
591
+ while ((idx = lineBuf.indexOf('\n')) !== -1) {
592
+ const line = lineBuf.slice(0, idx).replace(/\r$/, '');
593
+ lineBuf = lineBuf.slice(idx + 1);
594
+ if (line.length === 0)
595
+ continue;
596
+ const assembled = assembler.feed(line);
597
+ if (assembled === null)
598
+ continue;
599
+ try {
600
+ const ctx = { runId: 'launch', agent: plan.harness, sessionId: undefined, turnIndex: 0, debug: false, outputFormat: 'text', source: 'stdout', assembler, eventCount: 0, lastEventType: null, adapterState: {} };
601
+ const result = adapter.parseEvent(assembled, ctx);
602
+ if (result === null)
603
+ continue;
604
+ const events = Array.isArray(result) ? result : [result];
605
+ for (const ev of events) {
606
+ // Detect turn completion events
607
+ if (ev.type === 'message_stop' || ev.type === 'turn_end' || ev.type === 'session_end') {
608
+ turnDetected = true;
609
+ // Give the harness a moment to flush output, then kill
610
+ setTimeout(() => {
611
+ try {
612
+ ptyProcess.kill('SIGTERM');
613
+ }
614
+ catch { /* */ }
615
+ }, 1000);
616
+ return;
617
+ }
618
+ }
619
+ }
620
+ catch { /* parse error — ignore */ }
621
+ }
622
+ });
415
623
  if (process.stdin.isTTY) {
416
624
  process.stdin.setRawMode(true);
417
625
  }
@@ -421,27 +629,170 @@ export async function launchCommand(client, args) {
421
629
  process.stdout.on('resize', () => {
422
630
  ptyProcess.resize(process.stdout.columns || 80, process.stdout.rows || 24);
423
631
  });
632
+ // Inject prompt as initial input after a short delay for the harness to start
633
+ if (prompt && !plan.args.some(a => a === prompt)) {
634
+ setTimeout(() => ptyProcess.write(prompt + '\n'), 500);
635
+ }
424
636
  // Create a fake ChildProcess-like for signal handling
425
637
  child = { pid: ptyProcess.pid, kill: (sig) => ptyProcess.kill(sig) };
426
638
  }
427
639
  catch {
428
- // node-pty not available, fall back to stdio inherit
640
+ // node-pty not available, fall back to stdio inherit with stdin pipe for prompt injection
429
641
  const { spawn } = await import('node:child_process');
430
642
  child = spawn(plan.command, plan.args, {
431
- stdio: 'inherit',
643
+ stdio: prompt ? ['pipe', 'inherit', 'inherit'] : 'inherit',
432
644
  env: { ...process.env, ...plan.env },
433
645
  cwd: launchCwd,
434
- shell: false,
646
+ shell: process.platform === 'win32',
435
647
  });
436
648
  }
437
649
  }
650
+ else if (bridgeInteractive) {
651
+ // Bridge-interactive: spawn via PTY like interactive mode, but:
652
+ // - No human stdin forwarding
653
+ // - Parse PTY output via adapter for structured events
654
+ // - Emit events as NDJSON to stdout
655
+ // - Auto-kill on turn completion
656
+ // - Buffer PTY output to avoid pipe deadlock (stdout is piped)
657
+ let nodePty;
658
+ try {
659
+ nodePty = await import('node-pty');
660
+ }
661
+ catch {
662
+ const msg = '--bridge-interactive requires node-pty but it is not available. Install it with: npm install node-pty';
663
+ if (jsonMode)
664
+ printJsonError('SPAWN_ERROR', msg);
665
+ else
666
+ printError(msg);
667
+ return ExitCode.GENERAL_ERROR;
668
+ }
669
+ ptyProcess = nodePty.spawn(plan.command, plan.args, {
670
+ name: 'xterm-256color',
671
+ cols: 120,
672
+ rows: 40,
673
+ cwd: launchCwd,
674
+ env: { ...process.env, ...plan.env },
675
+ });
676
+ // Set up adapter + assembler for parsing PTY output into structured events
677
+ let assembler = null;
678
+ let adapter = null;
679
+ try {
680
+ const core = await import('@a5c-ai/agent-mux-core');
681
+ assembler = new core.StreamAssembler();
682
+ const adaptersModule = await import('@a5c-ai/agent-mux-adapters');
683
+ const factory = adaptersModule.getAdapterFactory?.(plan.harness);
684
+ adapter = factory ? factory() : null;
685
+ }
686
+ catch { /* core/adapters not available — raw output only */ }
687
+ /** Emit a bridge event as NDJSON, deferred to avoid blocking PTY callback. */
688
+ function emitBridgeEvent(event) {
689
+ const line = JSON.stringify(event) + '\n';
690
+ setImmediate(() => {
691
+ try {
692
+ process.stdout.write(line);
693
+ }
694
+ catch { /* stdout closed */ }
695
+ });
696
+ }
697
+ let turnComplete = false;
698
+ let lineBuf = '';
699
+ let outputBuf = '';
700
+ let eventCount = 0;
701
+ const parseCtx = {
702
+ runId: 'bridge',
703
+ agent: plan.harness,
704
+ sessionId: undefined,
705
+ turnIndex: 0,
706
+ debug: false,
707
+ outputFormat: 'text',
708
+ source: 'stdout',
709
+ assembler: assembler,
710
+ eventCount: 0,
711
+ lastEventType: null,
712
+ adapterState: {},
713
+ };
714
+ ptyProcess.onData((data) => {
715
+ // Buffer all PTY output — never write synchronously to stdout (pipe deadlock)
716
+ outputBuf += data;
717
+ if (!assembler || !adapter || turnComplete)
718
+ return;
719
+ // Strip ANSI escapes, then feed lines to the event parser
720
+ const clean = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/\x1b\][^\x07]*\x07/g, '');
721
+ lineBuf += clean;
722
+ let idx;
723
+ while ((idx = lineBuf.indexOf('\n')) !== -1) {
724
+ const line = lineBuf.slice(0, idx).replace(/\r$/, '');
725
+ lineBuf = lineBuf.slice(idx + 1);
726
+ if (line.length === 0)
727
+ continue;
728
+ const assembled = assembler.feed(line);
729
+ if (assembled === null)
730
+ continue;
731
+ try {
732
+ parseCtx.eventCount = eventCount;
733
+ const result = adapter.parseEvent(assembled, parseCtx);
734
+ if (result === null)
735
+ continue;
736
+ const events = Array.isArray(result) ? result : [result];
737
+ for (const ev of events) {
738
+ eventCount++;
739
+ parseCtx.lastEventType = ev.type;
740
+ // Emit as NDJSON bridge event
741
+ emitBridgeEvent({
742
+ type: ev.type,
743
+ timestamp: new Date().toISOString(),
744
+ data: ev,
745
+ });
746
+ // Detect turn completion events — schedule PTY termination
747
+ if (ev.type === 'message_stop' || ev.type === 'turn_end' || ev.type === 'session_end') {
748
+ turnComplete = true;
749
+ setTimeout(() => {
750
+ try {
751
+ ptyProcess.kill('SIGTERM');
752
+ }
753
+ catch { /* */ }
754
+ }, 1000);
755
+ return;
756
+ }
757
+ }
758
+ }
759
+ catch { /* parse error — ignore */ }
760
+ }
761
+ });
762
+ // Inject prompt as initial input after harness starts (like interactive mode)
763
+ if (prompt) {
764
+ setTimeout(() => ptyProcess.write(prompt + '\n'), 500);
765
+ }
766
+ // Create a fake ChildProcess-like for signal handling
767
+ child = { pid: ptyProcess.pid, kill: (sig) => ptyProcess.kill(sig) };
768
+ // On PTY exit, flush remaining buffered text as a final output event
769
+ const origOnExit = ptyProcess.onExit.bind(ptyProcess);
770
+ const exitPromise = new Promise((resolve) => {
771
+ origOnExit(({ exitCode: code }) => {
772
+ // Flush any remaining output as a final bridge event
773
+ if (outputBuf.length > 0) {
774
+ emitBridgeEvent({
775
+ type: 'output',
776
+ timestamp: new Date().toISOString(),
777
+ data: { text: outputBuf },
778
+ });
779
+ outputBuf = '';
780
+ }
781
+ resolve(code);
782
+ });
783
+ });
784
+ // Store the exit promise so main exit handler can use it
785
+ child.__bridgeExitPromise = exitPromise;
786
+ }
438
787
  else {
788
+ // Non-interactive: plain spawn. Each harness handles non-interactive mode
789
+ // internally (claude -p, codex exec, gemini --prompt, pi stdin).
439
790
  const { spawn } = await import('node:child_process');
440
791
  child = spawn(plan.command, plan.args, {
441
792
  stdio: ['pipe', 'inherit', 'inherit'],
442
793
  env: { ...process.env, ...plan.env },
443
794
  cwd: launchCwd,
444
- shell: false,
795
+ shell: process.platform === 'win32',
445
796
  });
446
797
  }
447
798
  if (flagBool(args.flags, 'observe')) {
@@ -472,32 +823,89 @@ export async function launchCommand(client, args) {
472
823
  }
473
824
  catch { /* process may already be dead */ }
474
825
  }
826
+ else if (ptyProcess) {
827
+ // PTY child runs in its own session — kill the process group to avoid orphans
828
+ try {
829
+ process.kill(-ptyProcess.pid, sig);
830
+ }
831
+ catch { /* */ }
832
+ try {
833
+ ptyProcess.kill(sig);
834
+ }
835
+ catch { /* */ }
836
+ }
475
837
  else {
476
838
  child.kill(sig);
477
839
  }
478
840
  };
479
841
  process.on('SIGINT', forwardSignal);
480
842
  process.on('SIGTERM', forwardSignal);
481
- if (!isInteractive && prompt && child.stdin) {
482
- child.stdin.write(prompt);
483
- child.stdin.end();
484
- }
485
- const exitCode = await new Promise((resolve) => {
486
- if (ptyProcess) {
487
- ptyProcess.onExit(({ exitCode: code }) => {
488
- if (process.stdin.isTTY)
489
- process.stdin.setRawMode(false);
490
- resolve(code);
491
- });
843
+ // Ensure PTY cleanup on exit
844
+ if (ptyProcess) {
845
+ process.on('exit', () => { try {
846
+ ptyProcess.kill('SIGKILL');
847
+ }
848
+ catch { /* */ } });
849
+ }
850
+ const promptPassedAsPiFlag = plan.harness === 'pi' && !isInteractive && plan.args.includes('--prompt');
851
+ if (prompt && child.stdin && !ptyProcess && !promptPassedAsPiFlag) {
852
+ child.stdin.write(prompt + '\n');
853
+ if (!isInteractive) {
854
+ child.stdin.end();
492
855
  }
493
856
  else {
494
- child.on('exit', (code, signal) => {
495
- resolve(signal ? 128 + (signal === 'SIGINT' ? 2 : 15) : (code ?? 1));
496
- });
857
+ // Interactive with stdin pipe (no PTY): reconnect terminal stdin after prompt injection
858
+ process.stdin.resume();
859
+ process.stdin.pipe(child.stdin);
497
860
  }
498
- });
861
+ }
862
+ const exitCode = await (child.__bridgeExitPromise
863
+ ? child.__bridgeExitPromise
864
+ : new Promise((resolve) => {
865
+ if (ptyProcess) {
866
+ ptyProcess.onExit(({ exitCode: code }) => {
867
+ if (process.stdin.isTTY)
868
+ process.stdin.setRawMode(false);
869
+ resolve(code);
870
+ });
871
+ }
872
+ else {
873
+ child.on('exit', (code, signal) => {
874
+ resolve(signal ? 128 + (signal === 'SIGINT' ? 2 : 15) : (code ?? 1));
875
+ });
876
+ }
877
+ }));
499
878
  process.off('SIGINT', forwardSignal);
500
879
  process.off('SIGTERM', forwardSignal);
880
+ // Bridge hooks: emulate stop hook and re-spawn if shouldContinue
881
+ if (bridgeHookEmulator) {
882
+ let stopResult = await bridgeHookEmulator.emulateStop();
883
+ while (stopResult.shouldContinue && stopResult.resumeId) {
884
+ // Re-spawn with --resume to continue the session
885
+ const resumePlan = { ...plan, args: [...plan.args] };
886
+ appendHarnessSessionArgs(resumePlan, {
887
+ resumeId: stopResult.resumeId,
888
+ interactive: false,
889
+ });
890
+ const { spawn: resumeSpawn } = await import('node:child_process');
891
+ const resumeChild = resumeSpawn(resumePlan.command, resumePlan.args, {
892
+ stdio: ['pipe', 'inherit', 'inherit'],
893
+ env: { ...process.env, ...resumePlan.env },
894
+ cwd: launchCwd,
895
+ shell: process.platform === 'win32',
896
+ });
897
+ if (resumeChild.stdin) {
898
+ resumeChild.stdin.end();
899
+ }
900
+ await new Promise((resolve) => {
901
+ resumeChild.on('exit', (code, signal) => {
902
+ resolve(signal ? 128 + (signal === 'SIGINT' ? 2 : 15) : (code ?? 1));
903
+ });
904
+ });
905
+ stopResult = await bridgeHookEmulator.emulateStop();
906
+ }
907
+ await bridgeHookEmulator.emulateSessionEnd();
908
+ }
501
909
  if (proxyRuntime) {
502
910
  await proxyRuntime.stop();
503
911
  }