@a5c-ai/agent-mux-cli 0.4.10-staging.ff407b73 → 5.0.1-staging.00fa5317c

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.
@@ -11,9 +11,11 @@ import { startTransportMuxRuntime } from '@a5c-ai/transport-mux';
11
11
  import { flagStr, flagNum, flagBool, flagArr } from '../parse-args.js';
12
12
  import { ExitCode } from '../exit-codes.js';
13
13
  import { printError, printJsonError } from '../output.js';
14
+ import { resolve as resolvePath } from 'node:path';
14
15
  /** Launch-specific flag definitions (global flags like model/json/debug are excluded). */
15
16
  export const LAUNCH_FLAGS = {
16
17
  'api-key': { type: 'string' },
18
+ 'profile': { type: 'string' },
17
19
  'api-base': { type: 'string' },
18
20
  'region': { type: 'string' },
19
21
  'project': { type: 'string' },
@@ -29,6 +31,7 @@ export const LAUNCH_FLAGS = {
29
31
  'resume': { short: 'r', type: 'string' },
30
32
  'session-id': { short: 's', type: 'string' },
31
33
  'prompt': { short: 'p', type: 'string' },
34
+ 'interactive': { short: 'i', type: 'boolean' },
32
35
  'max-turns': { type: 'number' },
33
36
  'max-budget-usd': { type: 'number' },
34
37
  'dry-run': { type: 'boolean' },
@@ -39,14 +42,16 @@ export const LAUNCH_FLAGS = {
39
42
  'workspace-mode': { type: 'string' },
40
43
  'workspace-repo': { type: 'string', repeatable: true },
41
44
  'workspace-name': { type: 'string' },
45
+ 'yolo': { type: 'boolean' },
46
+ 'bridge-interactive': { type: 'boolean' },
47
+ 'bridge-hooks': { type: 'boolean' },
42
48
  };
43
49
  // ---------------------------------------------------------------------------
44
50
  // Plan resolution
45
51
  // ---------------------------------------------------------------------------
46
52
  export function resolveLaunchPlan(input) {
47
- const providerId = (input.provider ?? 'anthropic');
48
53
  const providerConfig = resolveProvider({
49
- provider: providerId,
54
+ provider: input.provider,
50
55
  model: input.model,
51
56
  transport: input.transport,
52
57
  apiKey: input.apiKey,
@@ -56,6 +61,7 @@ export function resolveLaunchPlan(input) {
56
61
  resourceGroup: input.resourceGroup,
57
62
  endpointName: input.endpointName,
58
63
  authCommand: input.authCommand,
64
+ profile: input.profile,
59
65
  });
60
66
  // Merge extra provider args into params
61
67
  if (input.providerArgs) {
@@ -71,29 +77,34 @@ export function resolveLaunchPlan(input) {
71
77
  }
72
78
  else {
73
79
  proxyNeeded = false;
74
- proxyReason = `${input.harness} supports ${providerId} natively`;
80
+ proxyReason = `${input.harness} supports ${providerConfig.provider} natively`;
75
81
  }
76
82
  }
77
83
  else {
78
84
  if (input.proxyMode === 'never') {
79
- throw new Error(`${input.harness} does not support ${providerId} natively. ` +
85
+ throw new Error(`${input.harness} does not support ${providerConfig.provider} natively. ` +
80
86
  `Use --with-proxy-if-needed to auto-launch the proxy.`);
81
87
  }
82
88
  proxyReason =
83
- `${input.harness} does not support ${providerId} natively; ` +
84
- `proxy bridges ${providerId} → ${translation.proxyExposedTransport}`;
89
+ `${input.harness} does not support ${providerConfig.provider} natively; ` +
90
+ `proxy bridges ${providerConfig.provider} → ${translation.proxyExposedTransport}`;
85
91
  }
86
92
  const proxy = proxyNeeded
87
93
  ? {
88
- targetProvider: providerId,
94
+ targetProvider: providerConfig.provider,
89
95
  targetModel: providerConfig.model,
90
96
  exposedTransport: translation.proxyExposedTransport ?? 'openai-chat',
91
97
  port: input.proxyPort ?? 0,
98
+ apiBase: providerConfig.params['apiBase'] ? String(providerConfig.params['apiBase']) : undefined,
99
+ apiKey: providerConfig.auth.apiKey,
100
+ project: providerConfig.params['project'] ? String(providerConfig.params['project']) : undefined,
101
+ location: providerConfig.params['region'] ? String(providerConfig.params['region']) : undefined,
102
+ useVertexAi: providerConfig.provider === 'vertex' || Boolean(providerConfig.params['useVertexAi']),
92
103
  }
93
104
  : undefined;
94
105
  return {
95
106
  harness: input.harness,
96
- provider: providerId,
107
+ provider: providerConfig.provider,
97
108
  transport: providerConfig.transport,
98
109
  model: providerConfig.model,
99
110
  proxyNeeded,
@@ -105,14 +116,22 @@ export function resolveLaunchPlan(input) {
105
116
  };
106
117
  }
107
118
  function appendHarnessSessionArgs(plan, session) {
119
+ const interactive = session.interactive !== false;
108
120
  switch (plan.harness) {
109
121
  case 'claude':
122
+ if (session.bridgeInteractive) {
123
+ plan.args.push('--bare');
124
+ }
110
125
  if (session.resumeId)
111
126
  plan.args.push('--resume', session.resumeId);
112
127
  if (session.sessionId)
113
128
  plan.args.push('--session-id', session.sessionId);
114
- if (session.prompt)
115
- plan.args.push('--print', session.prompt);
129
+ if (session.prompt && !interactive) {
130
+ plan.args.push('-p', session.prompt);
131
+ }
132
+ if (session.prompt && interactive && !session.bridgeInteractive) {
133
+ plan.args.push(session.prompt);
134
+ }
116
135
  if (session.maxTurns)
117
136
  plan.args.push('--max-turns', String(session.maxTurns));
118
137
  break;
@@ -120,7 +139,7 @@ function appendHarnessSessionArgs(plan, session) {
120
139
  if (session.resumeId) {
121
140
  plan.args.unshift('resume', session.resumeId);
122
141
  }
123
- else if (session.prompt) {
142
+ else if (session.prompt && !interactive) {
124
143
  plan.args.unshift('exec', session.prompt);
125
144
  }
126
145
  break;
@@ -128,16 +147,186 @@ function appendHarnessSessionArgs(plan, session) {
128
147
  if (session.prompt)
129
148
  plan.args.push('--prompt', session.prompt);
130
149
  break;
150
+ case 'pi':
151
+ if (session.prompt && !interactive && !session.bridgeInteractive) {
152
+ plan.args.push('--print', session.prompt);
153
+ }
154
+ break;
131
155
  case 'opencode':
132
156
  if (session.resumeId)
133
157
  plan.args.push('--session', session.resumeId);
134
- // OpenCode has no non-interactive prompt flag; prompt delivered via stdin after launch
135
158
  break;
136
159
  }
137
160
  }
138
161
  // ---------------------------------------------------------------------------
139
162
  // Provider auth validation helper
140
163
  // ---------------------------------------------------------------------------
164
+ async function prepareHarnessAutomationState(harness, cwd, env) {
165
+ if (!isAutomationPreseedEnabled(env))
166
+ return;
167
+ if (harness === 'claude')
168
+ await prepareClaudeAutomationState(cwd, env);
169
+ if (harness === 'codex')
170
+ await prepareCodexAutomationState(cwd);
171
+ }
172
+ function isAutomationPreseedEnabled(env) {
173
+ return env['AMUX_PRESEED_HARNESS_ONBOARDING'] === '1' || env['CI'] === 'true' || env['GITHUB_ACTIONS'] === 'true' || process.env['CI'] === 'true' || process.env['GITHUB_ACTIONS'] === 'true';
174
+ }
175
+ function automationHome() {
176
+ return process.env['HOME'] || process.env['USERPROFILE'];
177
+ }
178
+ async function readJsonObject(filePath) {
179
+ try {
180
+ const fs = await import('node:fs/promises');
181
+ const value = JSON.parse(await fs.readFile(filePath, 'utf8'));
182
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
183
+ }
184
+ catch {
185
+ return {};
186
+ }
187
+ }
188
+ async function writeJsonObject(filePath, value) {
189
+ const { dirname } = await import('node:path');
190
+ const fs = await import('node:fs/promises');
191
+ await fs.mkdir(dirname(filePath), { recursive: true });
192
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
193
+ }
194
+ function recordObject(value) {
195
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
196
+ }
197
+ function numberAtLeast(value, minimum) {
198
+ const numeric = Number(value);
199
+ return Number.isFinite(numeric) ? Math.max(numeric, minimum) : minimum;
200
+ }
201
+ function approveClaudeCustomApiKey(config, env) {
202
+ const apiKey = env['ANTHROPIC_API_KEY'] || process.env['ANTHROPIC_API_KEY'];
203
+ if (!apiKey)
204
+ return;
205
+ const fingerprint = apiKey.slice(-20);
206
+ const responses = recordObject(config['customApiKeyResponses']);
207
+ const approved = Array.isArray(responses['approved']) ? responses['approved'].filter((value) => typeof value === 'string') : [];
208
+ const rejected = Array.isArray(responses['rejected']) ? responses['rejected'].filter((value) => typeof value === 'string' && value !== fingerprint) : [];
209
+ if (!approved.includes(fingerprint))
210
+ approved.push(fingerprint);
211
+ config['customApiKeyResponses'] = { ...responses, approved, rejected };
212
+ }
213
+ const AUTOMATION_CLAUDE_ONBOARDING_VERSION = '999.999.999';
214
+ async function prepareClaudeAutomationState(cwd, env) {
215
+ const home = automationHome();
216
+ if (!home)
217
+ return;
218
+ const { join, resolve } = await import('node:path');
219
+ const settingsPath = join(home, '.claude', 'settings.json');
220
+ const settings = await readJsonObject(settingsPath);
221
+ await writeJsonObject(settingsPath, {
222
+ ...settings,
223
+ theme: typeof settings['theme'] === 'string' ? settings['theme'] : 'dark',
224
+ skipDangerousModePermissionPrompt: true,
225
+ permissions: {
226
+ allow: [
227
+ 'Bash(*)', 'Read(*)', 'Write(*)', 'Edit(*)', 'Glob(*)', 'Grep(*)',
228
+ 'WebFetch(*)', 'WebSearch(*)', 'Agent(*)', 'Skill(*)',
229
+ 'TodoRead', 'TodoWrite',
230
+ ],
231
+ deny: [],
232
+ ...recordObject(settings['permissions']),
233
+ },
234
+ });
235
+ const configPath = join(home, '.claude.json');
236
+ const config = await readJsonObject(configPath);
237
+ approveClaudeCustomApiKey(config, env);
238
+ const projects = recordObject(config['projects']);
239
+ const projectPath = resolve(cwd).replace(/\\/g, '/');
240
+ const project = recordObject(projects[projectPath]);
241
+ projects[projectPath] = {
242
+ allowedTools: [],
243
+ mcpContextUris: [],
244
+ mcpServers: {},
245
+ enabledMcpjsonServers: [],
246
+ disabledMcpjsonServers: [],
247
+ hasClaudeMdExternalIncludesApproved: false,
248
+ hasClaudeMdExternalIncludesWarningShown: false,
249
+ ...project,
250
+ projectOnboardingSeenCount: numberAtLeast(project['projectOnboardingSeenCount'], 1),
251
+ hasTrustDialogAccepted: true,
252
+ hasCompletedProjectOnboarding: true,
253
+ lastVersionBase: AUTOMATION_CLAUDE_ONBOARDING_VERSION,
254
+ };
255
+ await writeJsonObject(configPath, {
256
+ ...config,
257
+ numStartups: numberAtLeast(config['numStartups'], 1),
258
+ hasCompletedOnboarding: true,
259
+ lastOnboardingVersion: AUTOMATION_CLAUDE_ONBOARDING_VERSION,
260
+ lastReleaseNotesSeen: AUTOMATION_CLAUDE_ONBOARDING_VERSION,
261
+ hasIdeOnboardingBeenShown: { vscode: true, ...recordObject(config['hasIdeOnboardingBeenShown']) },
262
+ officialMarketplaceAutoInstallAttempted: true,
263
+ officialMarketplaceAutoInstalled: true,
264
+ projects,
265
+ });
266
+ }
267
+ function extractPromptArtifactPaths(prompt, cwd) {
268
+ if (!prompt)
269
+ return [];
270
+ const matches = prompt.matchAll(/(?:^|[\s`"'])((?:\.\/)?\.a5c-live-test\/[^\s`"')]+)/g);
271
+ const paths = new Set();
272
+ for (const match of matches) {
273
+ const cleaned = match[1]?.replace(/[.,;:!?]+$/, '');
274
+ if (!cleaned)
275
+ continue;
276
+ paths.add(resolvePath(cwd, cleaned.replace(/^\.\//, '')));
277
+ }
278
+ return [...paths];
279
+ }
280
+ function startPromptArtifactCompletionMonitor(input) {
281
+ const expectedPaths = extractPromptArtifactPaths(input.prompt, input.cwd);
282
+ if (expectedPaths.length === 0)
283
+ return undefined;
284
+ const lastSizes = new Map();
285
+ return setInterval(() => {
286
+ void (async () => {
287
+ const fs = await import('node:fs/promises');
288
+ for (const expectedPath of expectedPaths) {
289
+ try {
290
+ const stat = await fs.stat(expectedPath);
291
+ if (!stat.isFile() || stat.size <= 0)
292
+ continue;
293
+ if (lastSizes.get(expectedPath) === stat.size) {
294
+ input.onComplete();
295
+ return;
296
+ }
297
+ lastSizes.set(expectedPath, stat.size);
298
+ }
299
+ catch {
300
+ // expected artifact not written yet
301
+ }
302
+ }
303
+ })();
304
+ }, 1000);
305
+ }
306
+ async function prepareCodexAutomationState(cwd) {
307
+ const home = automationHome();
308
+ if (!home)
309
+ return;
310
+ const { join, resolve, dirname } = await import('node:path');
311
+ const fs = await import('node:fs/promises');
312
+ const configPath = join(home, '.codex', 'config.toml');
313
+ await fs.mkdir(dirname(configPath), { recursive: true });
314
+ let config = '';
315
+ try {
316
+ config = await fs.readFile(configPath, 'utf8');
317
+ }
318
+ catch {
319
+ config = '';
320
+ }
321
+ const projectPath = resolve(cwd);
322
+ const basicKey = JSON.stringify(projectPath);
323
+ const literalKey = `'${projectPath}'`;
324
+ if (config.includes(`[projects.${basicKey}]`) || config.includes(`[projects.${literalKey}]`))
325
+ return;
326
+ const prefix = config.trimEnd();
327
+ const addition = `[projects.${basicKey}]\ntrust_level = "trusted"\n`;
328
+ await fs.writeFile(configPath, `${prefix}${prefix ? '\n\n' : ''}${addition}`);
329
+ }
141
330
  async function validateProviderAuth(plan) {
142
331
  const { execSync } = await import('node:child_process');
143
332
  try {
@@ -276,6 +465,7 @@ export async function launchCommand(client, args) {
276
465
  resourceGroup: flagStr(args.flags, 'resource-group'),
277
466
  endpointName: flagStr(args.flags, 'endpoint-name'),
278
467
  authCommand: flagStr(args.flags, 'auth-command'),
468
+ profile: flagStr(args.flags, 'profile'),
279
469
  proxyMode,
280
470
  proxyPort: flagNum(args.flags, 'proxy-port'),
281
471
  adapter: adapter,
@@ -296,6 +486,7 @@ export async function launchCommand(client, args) {
296
486
  model: flagStr(args.flags, 'model'),
297
487
  apiKey: flagStr(args.flags, 'api-key'),
298
488
  authCommand: flagStr(args.flags, 'auth-command'),
489
+ profile: flagStr(args.flags, 'profile'),
299
490
  });
300
491
  if (resolvedConfig.auth.type === 'api_key' && !resolvedConfig.auth.apiKey) {
301
492
  const defaults = (await import('@a5c-ai/agent-mux-core')).PROVIDER_DEFAULTS;
@@ -361,6 +552,45 @@ export async function launchCommand(client, args) {
361
552
  }
362
553
  launchCwd = resolveWorkspaceDefaultCwd(workspace);
363
554
  }
555
+ // Resolve interactive mode (default: true)
556
+ const interactiveFlag = flagBool(args.flags, 'interactive');
557
+ const isInteractive = interactiveFlag !== false;
558
+ // Bridge flags: --bridge-interactive and --bridge-hooks
559
+ const bridgeInteractive = flagBool(args.flags, 'bridge-interactive') === true;
560
+ const bridgeHooks = flagBool(args.flags, 'bridge-hooks') === true;
561
+ if (bridgeInteractive && isInteractive) {
562
+ const msg = '--bridge-interactive requires --no-interactive';
563
+ if (jsonMode)
564
+ printJsonError('VALIDATION_ERROR', msg);
565
+ else
566
+ printError(msg);
567
+ return ExitCode.USAGE_ERROR;
568
+ }
569
+ if (bridgeHooks && isInteractive) {
570
+ const msg = '--bridge-hooks requires --no-interactive';
571
+ if (jsonMode)
572
+ printJsonError('VALIDATION_ERROR', msg);
573
+ else
574
+ printError(msg);
575
+ return ExitCode.USAGE_ERROR;
576
+ }
577
+ if (bridgeInteractive) {
578
+ try {
579
+ const { getBridgeCapabilities } = await import('@a5c-ai/agent-catalog');
580
+ const caps = getBridgeCapabilities(plan.harness);
581
+ if (!caps?.interactiveBridge) {
582
+ const msg = `${plan.harness} does not support interactive bridging`;
583
+ if (jsonMode)
584
+ printJsonError('CAPABILITY_ERROR', msg);
585
+ else
586
+ printError(msg);
587
+ return ExitCode.USAGE_ERROR;
588
+ }
589
+ }
590
+ catch {
591
+ // agent-catalog not available — skip capability check
592
+ }
593
+ }
364
594
  // Append session/prompt args
365
595
  const prompt = flagStr(args.flags, 'prompt');
366
596
  appendHarnessSessionArgs(plan, {
@@ -368,23 +598,107 @@ export async function launchCommand(client, args) {
368
598
  sessionId: flagStr(args.flags, 'session-id'),
369
599
  prompt,
370
600
  maxTurns: flagNum(args.flags, 'max-turns'),
601
+ interactive: isInteractive || bridgeInteractive,
602
+ bridgeInteractive,
371
603
  });
604
+ // Add --model for harnesses that accept it as a CLI arg
605
+ const modelFlag = flagStr(args.flags, 'model');
606
+ if (modelFlag && ['pi', 'gemini', 'opencode'].includes(plan.harness)) {
607
+ plan.args.push('--model', modelFlag);
608
+ }
609
+ // --yolo: add harness-specific auto-approve flags resolved through
610
+ // agent-catalog → atlas graph (LaunchConfig records with commArgs)
611
+ if (flagBool(args.flags, 'yolo')) {
612
+ try {
613
+ const { getYoloLaunchArgs } = await import('@a5c-ai/agent-catalog');
614
+ const yoloArgs = getYoloLaunchArgs(plan.harness);
615
+ if (yoloArgs.length > 0) {
616
+ plan.args.push(...yoloArgs);
617
+ }
618
+ }
619
+ catch {
620
+ // agent-catalog not available
621
+ }
622
+ }
372
623
  // Passthrough args after --
373
624
  const dashDashIdx = process.argv.indexOf('--');
374
625
  if (dashDashIdx >= 0) {
375
626
  plan.args.push(...process.argv.slice(dashDashIdx + 1));
376
627
  }
628
+ // Also check parsed positionals for -- separator (handles spawn() without shell)
629
+ const argsDashIdx = args.positionals.indexOf('--');
630
+ if (argsDashIdx >= 0) {
631
+ plan.args.push(...args.positionals.slice(argsDashIdx + 1));
632
+ }
377
633
  // Launch runtime if needed
378
634
  let proxyRuntime;
379
635
  if (plan.proxyNeeded && plan.proxy) {
380
636
  try {
637
+ // When exposed transport differs from target (e.g., anthropic→foundry),
638
+ // the proxy needs a completion engine to translate request/response formats.
639
+ let completionEngine;
640
+ if ((plan.proxy.targetProvider === 'google' || plan.proxy.targetProvider === 'vertex') && plan.proxy.apiKey) {
641
+ const { createGoogleCompletionEngine } = await import('./launch-completion-engine.js');
642
+ completionEngine = createGoogleCompletionEngine({
643
+ apiBase: plan.proxy.useVertexAi ? undefined : plan.proxy.apiBase,
644
+ apiKey: plan.proxy.apiKey,
645
+ targetModel: plan.proxy.targetModel,
646
+ provider: plan.proxy.targetProvider,
647
+ project: plan.proxy.project,
648
+ location: plan.proxy.location,
649
+ useVertexAi: plan.proxy.useVertexAi,
650
+ });
651
+ }
652
+ else if (plan.proxy.apiBase && plan.proxy.apiKey) {
653
+ const { createOpenAICompletionEngine } = await import('./launch-completion-engine.js');
654
+ completionEngine = createOpenAICompletionEngine({
655
+ apiBase: plan.proxy.apiBase,
656
+ apiKey: plan.proxy.apiKey,
657
+ targetModel: plan.proxy.targetModel,
658
+ });
659
+ }
381
660
  proxyRuntime = await startTransportMuxRuntime({
382
661
  targetProvider: plan.proxy.targetProvider,
383
662
  targetModel: `${plan.proxy.targetProvider}/${plan.proxy.targetModel}`,
384
663
  exposedTransport: plan.proxy.exposedTransport,
385
664
  port: plan.proxy.port,
665
+ apiBase: plan.proxy.apiBase,
666
+ completionEngine,
386
667
  });
387
668
  proxyRuntime.applyHarnessEnv(plan.env);
669
+ if (plan.env['ANTHROPIC_API_KEY']) {
670
+ plan.env['ANTHROPIC_AUTH_TOKEN'] = '';
671
+ }
672
+ // Pi ignores OPENAI_BASE_URL — write a models.json config that registers
673
+ // a custom provider pointing to the local proxy.
674
+ if (plan.harness === 'pi') {
675
+ const { writeFileSync, mkdirSync } = await import('node:fs');
676
+ const { join } = await import('node:path');
677
+ const piConfigDir = process.env['PI_CODING_AGENT_DIR']
678
+ ?? join(process.env['HOME'] ?? process.env['USERPROFILE'] ?? '/tmp', '.pi', 'agent');
679
+ mkdirSync(piConfigDir, { recursive: true });
680
+ const modelsConfig = {
681
+ providers: {
682
+ 'amux-proxy': {
683
+ baseUrl: `${proxyRuntime.url}/v1`,
684
+ api: 'openai-completions',
685
+ apiKey: proxyRuntime.authToken ?? 'proxy-token',
686
+ models: [{
687
+ id: plan.model,
688
+ reasoning: false,
689
+ input: ['text'],
690
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
691
+ contextWindow: 128000,
692
+ maxTokens: 16384,
693
+ }],
694
+ },
695
+ },
696
+ };
697
+ const modelsPath = join(piConfigDir, 'models.json');
698
+ writeFileSync(modelsPath, JSON.stringify(modelsConfig, null, 2));
699
+ plan.args.push('--provider', 'amux-proxy');
700
+ console.error(`[amux launch] Pi proxy config written to ${modelsPath}, proxy at ${proxyRuntime.url}`);
701
+ }
388
702
  }
389
703
  catch (err) {
390
704
  const msg = err instanceof Error ? err.message : String(err);
@@ -395,14 +709,40 @@ export async function launchCommand(client, args) {
395
709
  return ExitCode.GENERAL_ERROR;
396
710
  }
397
711
  }
712
+ // Bridge hooks: emulate lifecycle hooks when --bridge-hooks is set
713
+ let bridgeHookEmulator;
714
+ if (bridgeHooks) {
715
+ const { BridgeHookEmulator } = await import('./launch-bridge-hooks.js');
716
+ bridgeHookEmulator = new BridgeHookEmulator({
717
+ harness: plan.harness,
718
+ cwd: launchCwd,
719
+ env: plan.env,
720
+ sessionId: flagStr(args.flags, 'session-id'),
721
+ runsDir: plan.env['BABYSITTER_RUNS_DIR'] || undefined,
722
+ verbose: flagBool(args.flags, 'debug') === true,
723
+ });
724
+ await bridgeHookEmulator.emulateSessionStart();
725
+ }
726
+ await prepareHarnessAutomationState(plan.harness, launchCwd, plan.env);
398
727
  // Spawn harness
399
- const isInteractive = !prompt;
400
- let child;
728
+ let child = null;
401
729
  let ptyProcess = null;
730
+ let ptyTerminationExpected = false;
731
+ const ptyCleanup = [];
732
+ const completePtyPrompt = () => {
733
+ if (!ptyProcess || ptyTerminationExpected)
734
+ return;
735
+ ptyTerminationExpected = true;
736
+ try {
737
+ ptyProcess.kill('SIGTERM');
738
+ }
739
+ catch { /* */ }
740
+ };
402
741
  if (isInteractive) {
403
- // Try to use node-pty for TUI harnesses
742
+ // Interactive mode: full TTY passthrough. If a prompt is provided, it's
743
+ // injected as initial stdin after the harness starts (like typing it in).
404
744
  try {
405
- const nodePty = require('node-pty'); // dynamic require — node-pty is optional
745
+ const nodePty = await import('node-pty');
406
746
  ptyProcess = nodePty.spawn(plan.command, plan.args, {
407
747
  name: 'xterm-256color',
408
748
  cols: process.stdout.columns || 80,
@@ -410,8 +750,70 @@ export async function launchCommand(client, args) {
410
750
  cwd: launchCwd,
411
751
  env: { ...process.env, ...plan.env },
412
752
  });
413
- // Pipe PTY to stdout and stdin to PTY
414
- ptyProcess.onData((data) => process.stdout.write(data));
753
+ // End-of-turn detection: parse PTY output through adapter's event system
754
+ let turnDetected = false;
755
+ let lineBuf = '';
756
+ let assembler = null;
757
+ let adapter = null;
758
+ try {
759
+ const core = await import('@a5c-ai/agent-mux-core');
760
+ assembler = new core.StreamAssembler();
761
+ // Resolve the adapter for this harness to use its parseEvent
762
+ const adaptersModule = await import('@a5c-ai/agent-mux-adapters');
763
+ const factory = adaptersModule.getAdapterFactory?.(plan.harness);
764
+ adapter = factory ? factory() : null;
765
+ }
766
+ catch { /* core/adapters not available */ }
767
+ // Pipe PTY to stdout + feed through event parser for turn detection
768
+ let interactiveOutputBuf = '';
769
+ let interactiveApiKeyHandled = false;
770
+ let interactiveBypassHandled = false;
771
+ ptyProcess.onData((data) => {
772
+ process.stdout.write(data);
773
+ interactiveOutputBuf += data;
774
+ // Auto-respond to Claude Code onboarding prompts
775
+ const stripped = interactiveOutputBuf.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
776
+ if (!interactiveApiKeyHandled && stripped.includes('usethisAPIkey')) {
777
+ interactiveApiKeyHandled = true;
778
+ setTimeout(() => ptyProcess.write('\x1b[A\r'), 200);
779
+ }
780
+ if (!interactiveBypassHandled && stripped.includes('BypassPermissionsmode')) {
781
+ interactiveBypassHandled = true;
782
+ setTimeout(() => ptyProcess.write('\x1b[B\r'), 200);
783
+ }
784
+ if (!assembler || !adapter || turnDetected)
785
+ return;
786
+ // Strip ANSI escapes, then feed lines to the event parser
787
+ const clean = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/\x1b\][^\x07]*\x07/g, '');
788
+ lineBuf += clean;
789
+ let idx;
790
+ while ((idx = lineBuf.indexOf('\n')) !== -1) {
791
+ const line = lineBuf.slice(0, idx).replace(/\r$/, '');
792
+ lineBuf = lineBuf.slice(idx + 1);
793
+ if (line.length === 0)
794
+ continue;
795
+ const assembled = assembler.feed(line);
796
+ if (assembled === null)
797
+ continue;
798
+ try {
799
+ const ctx = { runId: 'launch', agent: plan.harness, sessionId: undefined, turnIndex: 0, debug: false, outputFormat: 'text', source: 'stdout', assembler, eventCount: 0, lastEventType: null, adapterState: {} };
800
+ const result = adapter.parseEvent(assembled, ctx);
801
+ if (result === null)
802
+ continue;
803
+ const events = Array.isArray(result) ? result : [result];
804
+ for (const ev of events) {
805
+ // Detect turn completion events
806
+ if (ev.type === 'message_stop' || ev.type === 'turn_end' || ev.type === 'session_end') {
807
+ turnDetected = true;
808
+ // Give the harness a moment to flush output, then end the PTY.
809
+ setTimeout(completePtyPrompt, 1000);
810
+ return;
811
+ }
812
+ }
813
+ }
814
+ catch { /* parse error — ignore */ }
815
+ }
816
+ });
415
817
  if (process.stdin.isTTY) {
416
818
  process.stdin.setRawMode(true);
417
819
  }
@@ -421,27 +823,324 @@ export async function launchCommand(client, args) {
421
823
  process.stdout.on('resize', () => {
422
824
  ptyProcess.resize(process.stdout.columns || 80, process.stdout.rows || 24);
423
825
  });
826
+ if (prompt && plan.args.some(a => a === prompt)) {
827
+ let artifactMonitor;
828
+ artifactMonitor = startPromptArtifactCompletionMonitor({
829
+ prompt,
830
+ cwd: launchCwd,
831
+ onComplete: () => {
832
+ if (artifactMonitor)
833
+ clearInterval(artifactMonitor);
834
+ completePtyPrompt();
835
+ },
836
+ });
837
+ ptyCleanup.push(() => { if (artifactMonitor)
838
+ clearInterval(artifactMonitor); });
839
+ }
840
+ // Inject prompt after observed onboarding prompts are dismissed.
841
+ if (prompt && !plan.args.some(a => a === prompt)) {
842
+ const startedAt = Date.now();
843
+ let promptInjected = false;
844
+ let artifactMonitor;
845
+ const injectPrompt = () => {
846
+ if (promptInjected)
847
+ return;
848
+ promptInjected = true;
849
+ ptyProcess.write(prompt);
850
+ setTimeout(() => ptyProcess.write('\r'), 500);
851
+ artifactMonitor = startPromptArtifactCompletionMonitor({
852
+ prompt,
853
+ cwd: launchCwd,
854
+ onComplete: () => {
855
+ if (artifactMonitor)
856
+ clearInterval(artifactMonitor);
857
+ completePtyPrompt();
858
+ },
859
+ });
860
+ ptyCleanup.push(() => { if (artifactMonitor)
861
+ clearInterval(artifactMonitor); });
862
+ };
863
+ const checkAndInject = () => {
864
+ if (promptInjected)
865
+ return;
866
+ if (interactiveOutputBuf.length === 0) {
867
+ if (Date.now() - startedAt >= 1000)
868
+ injectPrompt();
869
+ else
870
+ setTimeout(checkAndInject, 100);
871
+ return;
872
+ }
873
+ const s = interactiveOutputBuf.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
874
+ if (interactiveApiKeyHandled || interactiveBypassHandled) {
875
+ setTimeout(injectPrompt, 2000);
876
+ }
877
+ else if (s.includes('APIkey') || s.includes('Bypass')) {
878
+ setTimeout(checkAndInject, 500);
879
+ }
880
+ else {
881
+ setTimeout(injectPrompt, 3000);
882
+ }
883
+ };
884
+ checkAndInject();
885
+ }
424
886
  // Create a fake ChildProcess-like for signal handling
425
887
  child = { pid: ptyProcess.pid, kill: (sig) => ptyProcess.kill(sig) };
426
888
  }
427
889
  catch {
428
- // node-pty not available, fall back to stdio inherit
890
+ // node-pty not available, fall back to stdio inherit with stdin pipe for prompt injection
429
891
  const { spawn } = await import('node:child_process');
430
892
  child = spawn(plan.command, plan.args, {
431
- stdio: 'inherit',
893
+ stdio: prompt ? ['pipe', 'inherit', 'inherit'] : 'inherit',
432
894
  env: { ...process.env, ...plan.env },
433
895
  cwd: launchCwd,
434
- shell: false,
896
+ shell: process.platform === 'win32',
435
897
  });
436
898
  }
437
899
  }
900
+ else if (bridgeInteractive) {
901
+ // Bridge-interactive: spawn via PTY like interactive mode, but:
902
+ // - No human stdin forwarding
903
+ // - Parse PTY output via adapter for structured events
904
+ // - Emit events as NDJSON to stdout
905
+ // - Auto-kill on turn completion
906
+ // - Buffer PTY output to avoid pipe deadlock (stdout is piped)
907
+ // Pre-create full Claude Code automation state to skip all onboarding prompts
908
+ if (plan.harness === 'claude') {
909
+ await prepareClaudeAutomationState(launchCwd, plan.env);
910
+ }
911
+ let nodePty;
912
+ try {
913
+ nodePty = await import('node-pty');
914
+ }
915
+ catch {
916
+ const msg = '--bridge-interactive requires node-pty but it is not available. Install it with: npm install node-pty';
917
+ if (jsonMode)
918
+ printJsonError('SPAWN_ERROR', msg);
919
+ else
920
+ printError(msg);
921
+ return ExitCode.GENERAL_ERROR;
922
+ }
923
+ ptyProcess = nodePty.spawn(plan.command, plan.args, {
924
+ name: 'xterm-256color',
925
+ cols: 120,
926
+ rows: 40,
927
+ cwd: launchCwd,
928
+ env: { ...process.env, ...plan.env },
929
+ });
930
+ // Set up adapter + assembler for parsing PTY output into structured events
931
+ let assembler = null;
932
+ let adapter = null;
933
+ try {
934
+ const core = await import('@a5c-ai/agent-mux-core');
935
+ assembler = new core.StreamAssembler();
936
+ const adaptersModule = await import('@a5c-ai/agent-mux-adapters');
937
+ const factory = adaptersModule.getAdapterFactory?.(plan.harness);
938
+ adapter = factory ? factory() : null;
939
+ }
940
+ catch { /* core/adapters not available — raw output only */ }
941
+ /** Emit a bridge event as NDJSON, deferred to avoid blocking PTY callback. */
942
+ function emitBridgeEvent(event) {
943
+ const line = JSON.stringify(event) + '\n';
944
+ setImmediate(() => {
945
+ try {
946
+ process.stdout.write(line);
947
+ }
948
+ catch { /* stdout closed */ }
949
+ });
950
+ }
951
+ let turnComplete = false;
952
+ let lineBuf = '';
953
+ let outputBuf = '';
954
+ let eventCount = 0;
955
+ let apiKeyPromptHandled = false;
956
+ let bypassPromptHandled = false;
957
+ let idleTimer = null;
958
+ const IDLE_TIMEOUT_MS = 30_000;
959
+ const harnessesWithEndEvents = new Set(['claude', 'codex', 'gemini', 'opencode']);
960
+ const useIdleTimeout = !harnessesWithEndEvents.has(plan.harness);
961
+ const parseCtx = {
962
+ runId: 'bridge',
963
+ agent: plan.harness,
964
+ sessionId: undefined,
965
+ turnIndex: 0,
966
+ debug: false,
967
+ outputFormat: 'text',
968
+ source: 'stdout',
969
+ assembler: assembler,
970
+ eventCount: 0,
971
+ lastEventType: null,
972
+ adapterState: {},
973
+ };
974
+ ptyProcess.onData((data) => {
975
+ // Buffer all PTY output — never write synchronously to stdout (pipe deadlock)
976
+ outputBuf += data;
977
+ // Auto-respond to Claude Code interactive prompts that block automation.
978
+ // ANSI cursor-move codes replace spaces, so stripped text is concatenated.
979
+ const stripped = outputBuf.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
980
+ if (!apiKeyPromptHandled && stripped.includes('usethisAPIkey')) {
981
+ apiKeyPromptHandled = true;
982
+ // Default is "No (recommended)". Send Up arrow + Enter to select "Yes".
983
+ setTimeout(() => ptyProcess.write('\x1b[A\r'), 200);
984
+ }
985
+ if (!bypassPromptHandled && stripped.includes('BypassPermissionsmode')) {
986
+ bypassPromptHandled = true;
987
+ // Default is "No, exit". Send Down arrow + Enter to select "Yes, I accept".
988
+ setTimeout(() => ptyProcess.write('\x1b[B\r'), 200);
989
+ }
990
+ if (!assembler || !adapter || turnComplete)
991
+ return;
992
+ // Strip ANSI escapes, then feed lines to the event parser
993
+ const clean = data.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/\x1b\][^\x07]*\x07/g, '');
994
+ lineBuf += clean;
995
+ let idx;
996
+ while ((idx = lineBuf.indexOf('\n')) !== -1) {
997
+ const line = lineBuf.slice(0, idx).replace(/\r$/, '');
998
+ lineBuf = lineBuf.slice(idx + 1);
999
+ if (line.length === 0)
1000
+ continue;
1001
+ const assembled = assembler.feed(line);
1002
+ if (assembled === null)
1003
+ continue;
1004
+ try {
1005
+ parseCtx.eventCount = eventCount;
1006
+ const result = adapter.parseEvent(assembled, parseCtx);
1007
+ if (result === null)
1008
+ continue;
1009
+ const events = Array.isArray(result) ? result : [result];
1010
+ for (const ev of events) {
1011
+ eventCount++;
1012
+ parseCtx.lastEventType = ev.type;
1013
+ // Emit as NDJSON bridge event
1014
+ emitBridgeEvent({
1015
+ type: ev.type,
1016
+ timestamp: new Date().toISOString(),
1017
+ data: ev,
1018
+ });
1019
+ // Detect turn completion events — schedule PTY termination
1020
+ if (ev.type === 'message_stop' || ev.type === 'turn_end' || ev.type === 'session_end') {
1021
+ turnComplete = true;
1022
+ if (idleTimer)
1023
+ clearTimeout(idleTimer);
1024
+ setTimeout(completePtyPrompt, 1000);
1025
+ return;
1026
+ }
1027
+ // Idle timeout fallback for harnesses without structured end events (Pi).
1028
+ if (useIdleTimeout) {
1029
+ if (idleTimer)
1030
+ clearTimeout(idleTimer);
1031
+ idleTimer = setTimeout(() => {
1032
+ if (!turnComplete && eventCount > 0) {
1033
+ turnComplete = true;
1034
+ completePtyPrompt();
1035
+ }
1036
+ }, IDLE_TIMEOUT_MS);
1037
+ }
1038
+ }
1039
+ }
1040
+ catch { /* parse error — ignore */ }
1041
+ }
1042
+ });
1043
+ // Inject prompt after observed onboarding prompts are dismissed.
1044
+ // If the PTY stays silent, inject after a short startup grace period because
1045
+ // some harnesses wait for input without rendering an initial prompt.
1046
+ if (prompt) {
1047
+ const startedAt = Date.now();
1048
+ let promptInjected = false;
1049
+ let artifactMonitor;
1050
+ const injectPrompt = () => {
1051
+ if (promptInjected)
1052
+ return;
1053
+ promptInjected = true;
1054
+ ptyProcess.write(prompt);
1055
+ setTimeout(() => ptyProcess.write('\r'), 500);
1056
+ artifactMonitor = startPromptArtifactCompletionMonitor({
1057
+ prompt,
1058
+ cwd: launchCwd,
1059
+ onComplete: () => {
1060
+ if (artifactMonitor)
1061
+ clearInterval(artifactMonitor);
1062
+ completePtyPrompt();
1063
+ },
1064
+ });
1065
+ ptyCleanup.push(() => { if (artifactMonitor)
1066
+ clearInterval(artifactMonitor); });
1067
+ };
1068
+ const checkAndInject = () => {
1069
+ if (promptInjected)
1070
+ return;
1071
+ if (outputBuf.length === 0) {
1072
+ if (Date.now() - startedAt >= 1000)
1073
+ injectPrompt();
1074
+ else
1075
+ setTimeout(checkAndInject, 100);
1076
+ return;
1077
+ }
1078
+ const stripped = outputBuf.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
1079
+ if (apiKeyPromptHandled || bypassPromptHandled) {
1080
+ setTimeout(injectPrompt, 2000);
1081
+ }
1082
+ else if (stripped.includes('APIkey') || stripped.includes('Bypass')) {
1083
+ setTimeout(checkAndInject, 500);
1084
+ }
1085
+ else {
1086
+ setTimeout(injectPrompt, 3000);
1087
+ }
1088
+ };
1089
+ checkAndInject();
1090
+ }
1091
+ // Create a fake ChildProcess-like for signal handling
1092
+ child = { pid: ptyProcess.pid, kill: (sig) => ptyProcess.kill(sig) };
1093
+ // On PTY exit, flush remaining buffered text as a final output event
1094
+ const origOnExit = ptyProcess.onExit.bind(ptyProcess);
1095
+ const exitPromise = new Promise((resolve) => {
1096
+ origOnExit(({ exitCode: code }) => {
1097
+ // Flush any remaining output as a final bridge event
1098
+ if (outputBuf.length > 0) {
1099
+ emitBridgeEvent({
1100
+ type: 'output',
1101
+ timestamp: new Date().toISOString(),
1102
+ data: { text: outputBuf },
1103
+ });
1104
+ outputBuf = '';
1105
+ }
1106
+ resolve(code);
1107
+ });
1108
+ });
1109
+ // Store the exit promise so main exit handler can use it
1110
+ child.__bridgeExitPromise = exitPromise;
1111
+ }
438
1112
  else {
1113
+ // Non-interactive: plain spawn. Each harness handles non-interactive mode
1114
+ // internally (claude -p, codex exec, gemini --prompt, pi stdin).
439
1115
  const { spawn } = await import('node:child_process');
440
1116
  child = spawn(plan.command, plan.args, {
441
- stdio: ['pipe', 'inherit', 'inherit'],
1117
+ stdio: ['pipe', 'pipe', 'inherit'],
442
1118
  env: { ...process.env, ...plan.env },
443
1119
  cwd: launchCwd,
444
- shell: false,
1120
+ shell: process.platform === 'win32',
1121
+ });
1122
+ // Pipe stdout through + idle-timeout kill for harnesses that don't exit
1123
+ // after completing a non-interactive task (e.g., Pi doesn't exit on its own).
1124
+ // Harnesses with proper exit behavior (claude -p, codex exec) don't need this.
1125
+ const niUseIdleKill = !new Set(['claude', 'codex', 'gemini', 'opencode']).has(plan.harness);
1126
+ let niIdleTimer = null;
1127
+ let niHasOutput = false;
1128
+ const NI_IDLE_TIMEOUT_MS = 30_000;
1129
+ child.stdout?.on('data', (chunk) => {
1130
+ process.stdout.write(chunk);
1131
+ niHasOutput = true;
1132
+ if (niUseIdleKill) {
1133
+ if (niIdleTimer)
1134
+ clearTimeout(niIdleTimer);
1135
+ niIdleTimer = setTimeout(() => {
1136
+ if (niHasOutput) {
1137
+ try {
1138
+ child.kill('SIGTERM');
1139
+ }
1140
+ catch { /* */ }
1141
+ }
1142
+ }, NI_IDLE_TIMEOUT_MS);
1143
+ }
445
1144
  });
446
1145
  }
447
1146
  if (flagBool(args.flags, 'observe')) {
@@ -472,32 +1171,98 @@ export async function launchCommand(client, args) {
472
1171
  }
473
1172
  catch { /* process may already be dead */ }
474
1173
  }
1174
+ else if (ptyProcess) {
1175
+ // PTY child runs in its own session — kill the process group to avoid orphans
1176
+ try {
1177
+ process.kill(-ptyProcess.pid, sig);
1178
+ }
1179
+ catch { /* */ }
1180
+ try {
1181
+ ptyProcess.kill(sig);
1182
+ }
1183
+ catch { /* */ }
1184
+ }
475
1185
  else {
476
1186
  child.kill(sig);
477
1187
  }
478
1188
  };
479
1189
  process.on('SIGINT', forwardSignal);
480
1190
  process.on('SIGTERM', forwardSignal);
481
- if (!isInteractive && prompt && child.stdin) {
482
- child.stdin.write(prompt);
483
- child.stdin.end();
1191
+ // Ensure PTY cleanup on exit
1192
+ if (ptyProcess) {
1193
+ process.on('exit', () => { try {
1194
+ ptyProcess.kill('SIGKILL');
1195
+ }
1196
+ catch { /* */ } });
484
1197
  }
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
- });
1198
+ const promptPassedAsFlag = (plan.harness === 'pi' && !isInteractive && plan.args.includes('--print'));
1199
+ if (prompt && child.stdin && !ptyProcess && !promptPassedAsFlag) {
1200
+ child.stdin.write(prompt + '\n');
1201
+ if (!isInteractive) {
1202
+ child.stdin.end();
492
1203
  }
493
1204
  else {
494
- child.on('exit', (code, signal) => {
495
- resolve(signal ? 128 + (signal === 'SIGINT' ? 2 : 15) : (code ?? 1));
496
- });
1205
+ // Interactive with stdin pipe (no PTY): reconnect terminal stdin after prompt injection
1206
+ process.stdin.resume();
1207
+ process.stdin.pipe(child.stdin);
497
1208
  }
498
- });
1209
+ }
1210
+ // Close stdin for harnesses where prompt was passed as a CLI flag (not via stdin)
1211
+ // to prevent the process from hanging waiting for interactive input.
1212
+ if (promptPassedAsFlag && child.stdin && !ptyProcess) {
1213
+ child.stdin.end();
1214
+ }
1215
+ let exitCode = await (child.__bridgeExitPromise
1216
+ ? child.__bridgeExitPromise
1217
+ : new Promise((resolve) => {
1218
+ if (ptyProcess) {
1219
+ ptyProcess.onExit(({ exitCode: code }) => {
1220
+ if (process.stdin.isTTY)
1221
+ process.stdin.setRawMode(false);
1222
+ resolve(code);
1223
+ });
1224
+ }
1225
+ else {
1226
+ child.on('exit', (code, signal) => {
1227
+ resolve(signal ? 128 + (signal === 'SIGINT' ? 2 : 15) : (code ?? 1));
1228
+ });
1229
+ }
1230
+ }));
1231
+ for (const cleanup of ptyCleanup.splice(0))
1232
+ cleanup();
1233
+ if (ptyTerminationExpected && exitCode !== 0)
1234
+ exitCode = 0;
499
1235
  process.off('SIGINT', forwardSignal);
500
1236
  process.off('SIGTERM', forwardSignal);
1237
+ // Bridge hooks: emulate stop hook and re-spawn if shouldContinue
1238
+ if (bridgeHookEmulator) {
1239
+ let stopResult = await bridgeHookEmulator.emulateStop();
1240
+ while (stopResult.shouldContinue && stopResult.resumeId) {
1241
+ // Re-spawn with --resume to continue the session
1242
+ const resumePlan = { ...plan, args: [...plan.args] };
1243
+ appendHarnessSessionArgs(resumePlan, {
1244
+ resumeId: stopResult.resumeId,
1245
+ interactive: false,
1246
+ });
1247
+ const { spawn: resumeSpawn } = await import('node:child_process');
1248
+ const resumeChild = resumeSpawn(resumePlan.command, resumePlan.args, {
1249
+ stdio: ['pipe', 'inherit', 'inherit'],
1250
+ env: { ...process.env, ...resumePlan.env },
1251
+ cwd: launchCwd,
1252
+ shell: process.platform === 'win32',
1253
+ });
1254
+ if (resumeChild.stdin) {
1255
+ resumeChild.stdin.end();
1256
+ }
1257
+ await new Promise((resolve) => {
1258
+ resumeChild.on('exit', (code, signal) => {
1259
+ resolve(signal ? 128 + (signal === 'SIGINT' ? 2 : 15) : (code ?? 1));
1260
+ });
1261
+ });
1262
+ stopResult = await bridgeHookEmulator.emulateStop();
1263
+ }
1264
+ await bridgeHookEmulator.emulateSessionEnd();
1265
+ }
501
1266
  if (proxyRuntime) {
502
1267
  await proxyRuntime.stop();
503
1268
  }