agentxchain 2.148.0 → 2.149.1

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.
@@ -5,6 +5,17 @@ import { DEFAULT_VALIDATE_TIMEOUT_MS, validateConfiguredConnector } from '../lib
5
5
  import { DEFAULT_TIMEOUT_MS, probeConfiguredConnectors } from '../lib/connector-probe.js';
6
6
  import { buildRuntimeCapabilityReport } from '../lib/runtime-capabilities.js';
7
7
 
8
+ function warningDetail(warning) {
9
+ if (typeof warning === 'string') {
10
+ return warning;
11
+ }
12
+ return warning?.detail || JSON.stringify(warning);
13
+ }
14
+
15
+ function warningFix(warning) {
16
+ return typeof warning === 'object' && warning?.fix ? warning.fix : null;
17
+ }
18
+
8
19
  function printJson(result, exitCode) {
9
20
  console.log(JSON.stringify(result, null, 2));
10
21
  process.exit(exitCode);
@@ -49,11 +60,15 @@ function printText(result, exitCode) {
49
60
  console.log(` ${chalk.dim('Time:')} ${connector.latency_ms}ms`);
50
61
  }
51
62
  console.log(` ${chalk.dim('Detail:')} ${connector.detail}`);
63
+ if (connector.fix) {
64
+ console.log(` ${chalk.dim('Fix:')} ${connector.fix}`);
65
+ }
52
66
  if (Array.isArray(connector.authority_warnings) && connector.authority_warnings.length > 0) {
53
67
  for (const warning of connector.authority_warnings) {
54
- console.log(` ${chalk.yellow('⚠')} ${warning.detail}`);
55
- if (warning.fix) {
56
- console.log(` ${chalk.dim('Fix:')} ${warning.fix}`);
68
+ console.log(` ${chalk.yellow('⚠')} ${warningDetail(warning)}`);
69
+ const fix = warningFix(warning);
70
+ if (fix) {
71
+ console.log(` ${chalk.dim('Fix:')} ${fix}`);
57
72
  }
58
73
  }
59
74
  }
@@ -161,7 +176,11 @@ function printValidateText(result, exitCode) {
161
176
  if (Array.isArray(result.warnings) && result.warnings.length > 0) {
162
177
  console.log('');
163
178
  for (const warning of result.warnings) {
164
- console.log(` ${chalk.yellow('!')} ${warning}`);
179
+ console.log(` ${chalk.yellow('!')} ${warningDetail(warning)}`);
180
+ const fix = warningFix(warning);
181
+ if (fix) {
182
+ console.log(` ${chalk.dim('Fix:')} ${fix}`);
183
+ }
165
184
  }
166
185
  }
167
186
 
@@ -21,6 +21,7 @@ import { detectActiveTurnBindingDrift, detectStateBundleDesync } from '../lib/go
21
21
  import { findPendingApprovedIntents } from '../lib/intake.js';
22
22
  import { checkCleanBaseline } from '../lib/repo-observer.js';
23
23
  import { probeRuntimeSpawnContext } from '../lib/runtime-spawn-context.js';
24
+ import { getClaudeSubprocessAuthIssue } from '../lib/claude-local-auth.js';
24
25
 
25
26
  export async function doctorCommand(opts = {}) {
26
27
  const root = findProjectRoot(process.cwd());
@@ -500,6 +501,16 @@ function checkRuntimeReachable(root, rtId, rt, boundRoleEntries = []) {
500
501
 
501
502
  case 'local_cli': {
502
503
  const probe = probeRuntimeSpawnContext(root, rt, { runtimeId: rtId });
504
+ if (probe.ok) {
505
+ const claudeAuthIssue = getClaudeSubprocessAuthIssue(rt);
506
+ if (claudeAuthIssue) {
507
+ return attachRuntimeContract({
508
+ ...base,
509
+ level: 'warn',
510
+ detail: `${probe.detail} ${claudeAuthIssue.detail} ${claudeAuthIssue.fix}`,
511
+ }, rtId, rt, boundRoleEntries);
512
+ }
513
+ }
503
514
  return attachRuntimeContract({ ...base, level: probe.ok ? 'pass' : 'fail', detail: probe.detail }, rtId, rt, boundRoleEntries);
504
515
  }
505
516
 
@@ -25,6 +25,7 @@ import { validateParentRun } from '../lib/run-history.js';
25
25
  import { dispatchApiProxy } from '../lib/adapters/api-proxy-adapter.js';
26
26
  import {
27
27
  dispatchLocalCli,
28
+ resolveStartupWatchdogMs,
28
29
  saveDispatchLogs,
29
30
  resolvePromptTransport,
30
31
  } from '../lib/adapters/local-cli-adapter.js';
@@ -52,6 +53,7 @@ import { emitRunEvent } from '../lib/run-events.js';
52
53
  import { checkpointAcceptedTurn } from '../lib/turn-checkpoint.js';
53
54
  import { failTurnStartup } from '../lib/stale-turn-watchdog.js';
54
55
  import { hasMinimumTurnResultShape } from '../lib/turn-result-shape.js';
56
+ import { isKnownTurnRunningProofStream } from '../lib/dispatch-streams.js';
55
57
 
56
58
  export async function runCommand(opts) {
57
59
  const context = loadProjectContext();
@@ -343,7 +345,10 @@ export async function executeGovernedRun(context, opts = {}) {
343
345
  });
344
346
  };
345
347
 
346
- const ensureRunningState = (stream = 'stdout', at = new Date().toISOString()) => {
348
+ const ensureRunningState = (stream = null, at = new Date().toISOString()) => {
349
+ if (stream != null && !isKnownTurnRunningProofStream(stream)) {
350
+ return;
351
+ }
347
352
  if (runningMarked) return;
348
353
  runningMarked = true;
349
354
  transitionActiveTurnLifecycle(projectRoot, turn.turn_id, 'running', { stream, at });
@@ -359,7 +364,16 @@ export async function executeGovernedRun(context, opts = {}) {
359
364
  };
360
365
 
361
366
  const recordOutputActivity = (stream, text) => {
362
- ensureRunningState(stream);
367
+ // DEC-BUG54-STDERR-IS-NOT-STARTUP-PROOF-002 (Turn 88) extended to the
368
+ // run-command lifecycle in Turn 89: stderr activity must NOT promote a
369
+ // turn from `starting` to `running`. stdout (or the adapter's
370
+ // onFirstOutput callback, which is stdout/staged_result only post-Turn
371
+ // 88) is the only signal that satisfies the lifecycle transition.
372
+ // stderr is still tracked by the progress tracker for silence detection
373
+ // and operator diagnostics.
374
+ if (stream != null && isKnownTurnRunningProofStream(stream)) {
375
+ ensureRunningState(stream);
376
+ }
363
377
  const lines = text.split('\n').length - 1 || 1;
364
378
  const wasSilent = tracker.onOutput(stream, lines);
365
379
  if (wasSilent) {
@@ -473,9 +487,10 @@ export async function executeGovernedRun(context, opts = {}) {
473
487
 
474
488
  if (adapterResult.startupFailure) {
475
489
  const freshState = loadProjectState(projectRoot, cfg) || state;
490
+ const startupThresholdMs = resolveStartupWatchdogMs(cfg, runtime);
476
491
  failTurnStartup(projectRoot, freshState, cfg, turn.turn_id, {
477
492
  failure_type: adapterResult.startupFailureType || 'no_subprocess_output',
478
- threshold_ms: cfg?.run_loop?.startup_watchdog_ms ?? 30_000,
493
+ threshold_ms: startupThresholdMs,
479
494
  running_ms: freshState?.active_turns?.[turn.turn_id]?.started_at
480
495
  ? Math.max(0, Date.now() - new Date(freshState.active_turns[turn.turn_id].started_at).getTime())
481
496
  : 0,
@@ -383,7 +383,7 @@ function renderGovernedStatus(context, opts) {
383
383
  console.log(` ${chalk.dim(' or:')} ${chalk.cyan(`agentxchain accept-turn --turn ${turn.turn_id}`)} — re-attempt acceptance`);
384
384
  }
385
385
  if (turn.status === 'failed_start') {
386
- console.log(` ${chalk.dim('Reason:')} ${turn.failed_start_reason || 'no_subprocess_output'}`);
386
+ console.log(` ${chalk.dim('Reason:')} ${normalizeStartupFailureReasonForDisplay(turn.failed_start_reason)}`);
387
387
  const recover = turn.recovery_command || `agentxchain reissue-turn --turn ${turn.turn_id} --reason ghost`;
388
388
  console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
389
389
  }
@@ -443,7 +443,7 @@ function renderGovernedStatus(context, opts) {
443
443
  console.log(` ${chalk.dim(' or:')} ${chalk.cyan(mergeAction.command)}`);
444
444
  }
445
445
  if (singleActiveTurn.status === 'failed_start') {
446
- console.log(` ${chalk.dim('Reason:')} ${singleActiveTurn.failed_start_reason || 'no_subprocess_output'}`);
446
+ console.log(` ${chalk.dim('Reason:')} ${normalizeStartupFailureReasonForDisplay(singleActiveTurn.failed_start_reason)}`);
447
447
  const recover = singleActiveTurn.recovery_command || `agentxchain reissue-turn --turn ${singleActiveTurn.turn_id} --reason ghost`;
448
448
  console.log(` ${chalk.dim('Recover:')} ${chalk.cyan(recover)}`);
449
449
  }
@@ -883,6 +883,24 @@ function pluralizeRepoDecisionCount(count, singular, plural) {
883
883
  return `${count} ${count === 1 ? singular : plural}`;
884
884
  }
885
885
 
886
+ // BUG-54 vocabulary discipline (`DEC-BUG54-OPERATOR-SUBTYPE-DISPLAY-001`).
887
+ // Operator-facing status surfaces must render a typed startup-failure subtype,
888
+ // not the raw adapter signal `no_subprocess_output`. Public docs
889
+ // (website-v2/docs/cli.mdx) only document `runtime_spawn_failed` and
890
+ // `stdout_attach_failed` as the operator-visible subtypes; the `no_subprocess_output`
891
+ // label is an internal adapter/classification fallback and must not leak to the
892
+ // CLI status display. The adapter semantics for `no_subprocess_output` ("we
893
+ // watched for stdout and saw none inside the startup watchdog window") are
894
+ // identical to the operator subtype `stdout_attach_failed`, so that is the
895
+ // correct display normalization.
896
+ const TYPED_STARTUP_FAILURE_SUBTYPES = new Set(['runtime_spawn_failed', 'stdout_attach_failed']);
897
+ function normalizeStartupFailureReasonForDisplay(rawReason) {
898
+ if (typeof rawReason === 'string' && TYPED_STARTUP_FAILURE_SUBTYPES.has(rawReason)) {
899
+ return rawReason;
900
+ }
901
+ return 'stdout_attach_failed';
902
+ }
903
+
886
904
  function filterDispatchProgressForActiveTurns(progressByTurn, activeTurns) {
887
905
  const filtered = {};
888
906
  if (!progressByTurn || typeof progressByTurn !== 'object') {
@@ -897,7 +915,7 @@ function filterDispatchProgressForActiveTurns(progressByTurn, activeTurns) {
897
915
  return filtered;
898
916
  }
899
917
 
900
- function formatDispatchActivityLine(progress) {
918
+ export function formatDispatchActivityLine(progress) {
901
919
  if (!progress || typeof progress !== 'object') return null;
902
920
  const lastAct = progress.last_activity_at ? new Date(progress.last_activity_at) : null;
903
921
  const agoSec = lastAct && !Number.isNaN(lastAct.getTime())
@@ -918,6 +936,15 @@ function formatDispatchActivityLine(progress) {
918
936
  if (progress.activity_type === 'response') {
919
937
  return chalk.green('API response received');
920
938
  }
939
+ // DEC-BUG54-DIAGNOSTIC-ACTIVITY-TYPE-001 (Turn 91): stderr-only activity
940
+ // must be rendered as yellow "Diagnostic output only" — never as the green
941
+ // "Producing output" signal that previously leaked onto the operator
942
+ // surface for failing-startup subprocesses whose stdout never attached.
943
+ if (progress.activity_type === 'diagnostic_only') {
944
+ const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
945
+ return chalk.yellow('Diagnostic output only') +
946
+ ` (${progress.stderr_lines || 0} stderr lines, no stdout yet${agoLabel})`;
947
+ }
921
948
  const agoLabel = agoSec != null && agoSec > 0 ? `, last ${agoSec}s ago` : '';
922
949
  return chalk.green('Producing output') + ` (${progress.output_lines || 0} lines${agoLabel})`;
923
950
  }
@@ -49,6 +49,7 @@ import {
49
49
  } from '../lib/adapters/manual-adapter.js';
50
50
  import {
51
51
  dispatchLocalCli,
52
+ resolveStartupWatchdogMs,
52
53
  saveDispatchLogs,
53
54
  resolvePromptTransport,
54
55
  } from '../lib/adapters/local-cli-adapter.js';
@@ -73,6 +74,7 @@ import { shouldSuggestManualQaFallback } from '../lib/manual-qa-fallback.js';
73
74
  import { evaluateApprovalSlaReminders } from '../lib/notification-runner.js';
74
75
  import { consumeNextApprovedIntent } from '../lib/intake.js';
75
76
  import { failTurnStartup, reconcileStaleTurns } from '../lib/stale-turn-watchdog.js';
77
+ import { isKnownTurnRunningProofStream } from '../lib/dispatch-streams.js';
76
78
 
77
79
  export async function stepCommand(opts) {
78
80
  const context = loadProjectContext();
@@ -697,7 +699,10 @@ export async function stepCommand(opts) {
697
699
  state = starting.state;
698
700
  }
699
701
  };
700
- const ensureRunningState = (stream = 'stdout', at = new Date().toISOString()) => {
702
+ const ensureRunningState = (stream = null, at = new Date().toISOString()) => {
703
+ if (stream != null && !isKnownTurnRunningProofStream(stream)) {
704
+ return;
705
+ }
701
706
  if (runningMarked) return;
702
707
  runningMarked = true;
703
708
  const running = transitionActiveTurnLifecycle(root, turn.turn_id, 'running', { stream, at });
@@ -756,9 +761,10 @@ export async function stepCommand(opts) {
756
761
 
757
762
  if (cliResult.startupFailure) {
758
763
  const freshState = loadProjectState(root, config) || state;
764
+ const startupThresholdMs = resolveStartupWatchdogMs(config, runtime);
759
765
  const failed = failTurnStartup(root, freshState, config, turn.turn_id, {
760
766
  failure_type: cliResult.startupFailureType || 'no_subprocess_output',
761
- threshold_ms: config?.run_loop?.startup_watchdog_ms ?? 30_000,
767
+ threshold_ms: startupThresholdMs,
762
768
  running_ms: freshState?.active_turns?.[turn.turn_id]?.started_at
763
769
  ? Math.max(0, Date.now() - new Date(freshState.active_turns[turn.turn_id].started_at).getTime())
764
770
  : 0,
@@ -30,6 +30,7 @@ import {
30
30
  } from '../turn-paths.js';
31
31
  import { verifyDispatchManifestForAdapter } from '../dispatch-manifest.js';
32
32
  import { hasMeaningfulStagedResult } from '../staged-result-proof.js';
33
+ import { getClaudeSubprocessAuthIssue } from '../claude-local-auth.js';
33
34
 
34
35
  const DIAGNOSTIC_ENV_KEYS = [
35
36
  'PATH',
@@ -39,6 +40,7 @@ const DIAGNOSTIC_ENV_KEYS = [
39
40
  'TMPDIR',
40
41
  'AGENTXCHAIN_TURN_ID',
41
42
  ];
43
+ const DIAGNOSTIC_STDERR_EXCERPT_LIMIT = 800;
42
44
 
43
45
  /**
44
46
  * Launch a local CLI subprocess for a governed turn.
@@ -64,7 +66,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
64
66
  onStderr,
65
67
  onSpawnAttached,
66
68
  onFirstOutput,
67
- startupWatchdogMs = config?.run_loop?.startup_watchdog_ms ?? 30_000,
69
+ startupWatchdogMs: startupWatchdogOverrideMs,
68
70
  turnId,
69
71
  } = options;
70
72
 
@@ -85,6 +87,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
85
87
  if (!runtime) {
86
88
  return { ok: false, error: `Runtime "${runtimeId}" not found in config` };
87
89
  }
90
+ const startupWatchdogMs = startupWatchdogOverrideMs ?? resolveStartupWatchdogMs(config, runtime);
88
91
 
89
92
  // Read the dispatch bundle prompt
90
93
  const promptPath = join(root, getDispatchPromptPath(turn.turn_id));
@@ -125,6 +128,21 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
125
128
  const spawnEnv = { ...process.env, AGENTXCHAIN_TURN_ID: turn.turn_id };
126
129
  const stdinBytes = transport === 'stdin' ? Buffer.byteLength(fullPrompt, 'utf8') : 0;
127
130
  const diagnosticArgs = redactPromptArgs(args, fullPrompt, transport);
131
+ const claudeAuthIssue = getClaudeSubprocessAuthIssue(runtime, spawnEnv);
132
+
133
+ if (claudeAuthIssue) {
134
+ appendDiagnostic(logs, 'claude_auth_preflight_failed', {
135
+ runtime_id: runtimeId,
136
+ turn_id: turn.turn_id,
137
+ auth_env_present: claudeAuthIssue.auth_env_present,
138
+ recommendation: claudeAuthIssue.fix,
139
+ });
140
+ return {
141
+ ok: false,
142
+ error: `${claudeAuthIssue.detail} ${claudeAuthIssue.fix}`,
143
+ logs,
144
+ };
145
+ }
128
146
 
129
147
  return new Promise((resolve) => {
130
148
  if (signal?.aborted) {
@@ -163,6 +181,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
163
181
 
164
182
  let settled = false;
165
183
  let firstOutputAt = null;
184
+ let firstOutputStream = null;
166
185
  let spawnConfirmedAt = null;
167
186
  let spawnConfirmedAtMs = null;
168
187
  let firstOutputLatencyMs = null;
@@ -171,6 +190,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
171
190
  let startupFailureType = null;
172
191
  let stdoutBytes = 0;
173
192
  let stderrBytes = 0;
193
+ let stderrExcerpt = '';
174
194
 
175
195
  const settle = (result) => {
176
196
  if (settled) return;
@@ -211,6 +231,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
211
231
  const recordFirstOutput = (stream) => {
212
232
  if (firstOutputAt) return;
213
233
  firstOutputAt = new Date().toISOString();
234
+ firstOutputStream = stream;
214
235
  firstOutputLatencyMs = spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs);
215
236
  clearStartupWatchdog();
216
237
  appendDiagnostic(logs, 'first_output', {
@@ -280,7 +301,7 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
280
301
  child.stderr.on('data', (chunk) => {
281
302
  const text = chunk.toString();
282
303
  stderrBytes += Buffer.byteLength(text);
283
- recordFirstOutput('stderr');
304
+ stderrExcerpt = appendDiagnosticExcerpt(stderrExcerpt, text, DIAGNOSTIC_STDERR_EXCERPT_LIMIT);
284
305
  logs.push('[stderr] ' + text);
285
306
  if (onStderr) onStderr(text);
286
307
  });
@@ -349,14 +370,20 @@ export async function dispatchLocalCli(root, state, config, options = {}) {
349
370
  pid: child.pid ?? null,
350
371
  exit_code: exitCode,
351
372
  signal: killSignal,
373
+ exit_signal: killSignal,
352
374
  spawn_confirmed_at: spawnConfirmedAt,
353
375
  elapsed_since_spawn_ms: spawnConfirmedAtMs == null ? null : Math.max(0, Date.now() - spawnConfirmedAtMs),
354
376
  first_output_at: firstOutputAt,
377
+ first_output_stream: firstOutputStream,
355
378
  startup_latency_ms: firstOutputLatencyMs,
356
379
  stdout_bytes: stdoutBytes,
357
380
  stderr_bytes: stderrBytes,
358
381
  staged_result_ready: hasResult,
382
+ watchdog_fired: startupTimedOut,
359
383
  };
384
+ if (stderrExcerpt) {
385
+ exitDiagnostic.stderr_excerpt = stderrExcerpt;
386
+ }
360
387
  if (startupTimedOut) {
361
388
  exitDiagnostic.startup_failure_type = startupFailureType || 'no_subprocess_output';
362
389
  } else if (!spawnConfirmedAt) {
@@ -543,6 +570,16 @@ function resolvePromptTransport(runtime) {
543
570
  return hasPlaceholder ? 'argv' : 'dispatch_bundle_only';
544
571
  }
545
572
 
573
+ function resolveStartupWatchdogMs(config, runtime) {
574
+ if (runtime?.type === 'local_cli' && Number.isInteger(runtime?.startup_watchdog_ms) && runtime.startup_watchdog_ms > 0) {
575
+ return runtime.startup_watchdog_ms;
576
+ }
577
+ if (Number.isInteger(config?.run_loop?.startup_watchdog_ms) && config.run_loop.startup_watchdog_ms > 0) {
578
+ return config.run_loop.startup_watchdog_ms;
579
+ }
580
+ return 30_000;
581
+ }
582
+
546
583
  /**
547
584
  * Check if the staged result file exists and has meaningful content.
548
585
  * Delegates to the shared `hasMeaningfulStagedResult` helper so watchdog,
@@ -595,4 +632,14 @@ function normalizeDiagnosticError(err) {
595
632
  };
596
633
  }
597
634
 
635
+ function appendDiagnosticExcerpt(existing, chunk, limit) {
636
+ const combined = `${existing}${chunk}`;
637
+ if (combined.length <= limit) {
638
+ return combined;
639
+ }
640
+ return combined.slice(combined.length - limit);
641
+ }
642
+
643
+ export { resolveCommand };
598
644
  export { resolvePromptTransport };
645
+ export { resolveStartupWatchdogMs };
@@ -0,0 +1,61 @@
1
+ const CLAUDE_ENV_AUTH_KEYS = [
2
+ 'ANTHROPIC_API_KEY',
3
+ 'CLAUDE_API_KEY',
4
+ 'CLAUDE_CODE_OAUTH_TOKEN',
5
+ 'CLAUDE_CODE_USE_VERTEX',
6
+ 'CLAUDE_CODE_USE_BEDROCK',
7
+ ];
8
+
9
+ function normalizeCommandTokens(runtime) {
10
+ if (Array.isArray(runtime?.command)) {
11
+ return runtime.command.flatMap((element) =>
12
+ typeof element === 'string' ? element.trim().split(/\s+/).filter(Boolean) : []
13
+ );
14
+ }
15
+ if (typeof runtime?.command === 'string' && runtime.command.trim()) {
16
+ return runtime.command.trim().split(/\s+/).filter(Boolean);
17
+ }
18
+ return [];
19
+ }
20
+
21
+ export function isClaudeLocalCliRuntime(runtime) {
22
+ const tokens = normalizeCommandTokens(runtime);
23
+ if (tokens.length === 0) {
24
+ return false;
25
+ }
26
+ const head = tokens[0].toLowerCase();
27
+ return head === 'claude' || head.endsWith('/claude');
28
+ }
29
+
30
+ export function hasClaudeBareFlag(runtime) {
31
+ return normalizeCommandTokens(runtime).includes('--bare');
32
+ }
33
+
34
+ export function getClaudeEnvAuthPresence(env = process.env) {
35
+ return Object.fromEntries(
36
+ CLAUDE_ENV_AUTH_KEYS.map((key) => [key, Boolean(env?.[key])]),
37
+ );
38
+ }
39
+
40
+ export function hasClaudeEnvAuth(env = process.env) {
41
+ return Object.values(getClaudeEnvAuthPresence(env)).some(Boolean);
42
+ }
43
+
44
+ export function getClaudeSubprocessAuthIssue(runtime, env = process.env) {
45
+ if (!isClaudeLocalCliRuntime(runtime)) {
46
+ return null;
47
+ }
48
+
49
+ if (hasClaudeBareFlag(runtime) || hasClaudeEnvAuth(env)) {
50
+ return null;
51
+ }
52
+
53
+ const auth_env_present = getClaudeEnvAuthPresence(env);
54
+ return {
55
+ auth_env_present,
56
+ detail: 'Claude local_cli runtime has no env-based auth and is missing "--bare"; non-interactive subprocesses can hang on macOS keychain reads.',
57
+ fix: 'Export ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN before running AgentXchain, or add "--bare" to the Claude command if you intentionally want env-only auth.',
58
+ };
59
+ }
60
+
61
+ export { CLAUDE_ENV_AUTH_KEYS, normalizeCommandTokens };
@@ -4,6 +4,7 @@ import {
4
4
  PROVIDER_ENDPOINTS,
5
5
  } from './adapters/api-proxy-adapter.js';
6
6
  import { probeRuntimeSpawnContext } from './runtime-spawn-context.js';
7
+ import { getClaudeSubprocessAuthIssue, normalizeCommandTokens } from './claude-local-auth.js';
7
8
 
8
9
  const PROBEABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
9
10
  const DEFAULT_TIMEOUT_MS = 8_000;
@@ -165,6 +166,38 @@ async function probeLocalCommand(runtimeId, runtime, probeKindLabel, options = {
165
166
  }
166
167
 
167
168
  const spawnProbe = probeRuntimeSpawnContext(options.root || process.cwd(), runtime, { runtimeId });
169
+ const claudeAuthIssue = getClaudeSubprocessAuthIssue(runtime);
170
+
171
+ // DEC-BUG54-CLAUDE-AUTH-PREFLIGHT-001 / DEC-BUG54-VALIDATE-AUTH-PREFLIGHT-001
172
+ // Auth-preflight is a config-shape defect that must fire regardless of whether
173
+ // the binary currently resolves on PATH. Matches connector-validate.js:108-138
174
+ // ordering: a Claude local_cli runtime with no env auth and no --bare is a
175
+ // deterministic hang-on-spawn shape the operator must fix before anything
176
+ // else. If they fix auth (or add --bare) but still do not have claude
177
+ // installed, the next connector check surfaces command_presence after they
178
+ // fix the config — that is the correct operator progression.
179
+ if (claudeAuthIssue) {
180
+ return {
181
+ ...base,
182
+ level: 'fail',
183
+ probe_kind: 'auth_preflight',
184
+ command: spawnProbe.command || head,
185
+ error_code: 'claude_auth_preflight_failed',
186
+ detail: claudeAuthIssue.detail,
187
+ fix: claudeAuthIssue.fix,
188
+ auth_env_present: claudeAuthIssue.auth_env_present,
189
+ };
190
+ }
191
+
192
+ if (!spawnProbe.ok) {
193
+ return {
194
+ ...base,
195
+ level: 'fail',
196
+ command: spawnProbe.command || head,
197
+ detail: spawnProbe.detail,
198
+ };
199
+ }
200
+
168
201
  if (spawnProbe.ok) {
169
202
  return {
170
203
  ...base,
@@ -173,13 +206,6 @@ async function probeLocalCommand(runtimeId, runtime, probeKindLabel, options = {
173
206
  detail: spawnProbe.detail,
174
207
  };
175
208
  }
176
-
177
- return {
178
- ...base,
179
- level: 'fail',
180
- command: spawnProbe.command || head,
181
- detail: spawnProbe.detail,
182
- };
183
209
  }
184
210
 
185
211
  async function probeApiProxy(runtimeId, runtime, timeoutMs) {
@@ -375,6 +401,7 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
375
401
  // Prompt transport validation
376
402
  const transport = runtime.prompt_transport || 'dispatch_bundle_only';
377
403
  const knownTransports = KNOWN_CLI_TRANSPORTS[binaryName];
404
+ const claudeAuthIssue = getClaudeSubprocessAuthIssue(runtime);
378
405
 
379
406
  if (transport === 'argv' && !commandTokens.some((token) => token.includes('{prompt}'))) {
380
407
  warnings.push({
@@ -395,24 +422,21 @@ function analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles) {
395
422
  });
396
423
  }
397
424
 
425
+ if (claudeAuthIssue) {
426
+ warnings.push({
427
+ probe_kind: 'auth_preflight',
428
+ level: 'warn',
429
+ detail: claudeAuthIssue.detail,
430
+ fix: claudeAuthIssue.fix,
431
+ });
432
+ }
433
+
398
434
  return { warnings };
399
435
  }
400
436
 
401
437
  /**
402
438
  * Normalize a runtime's command field into an array of tokens.
403
439
  */
404
- function normalizeCommandTokens(runtime) {
405
- if (Array.isArray(runtime?.command)) {
406
- return runtime.command.flatMap((element) =>
407
- typeof element === 'string' ? element.trim().split(/\s+/).filter(Boolean) : []
408
- );
409
- }
410
- if (typeof runtime?.command === 'string' && runtime.command.trim()) {
411
- return runtime.command.trim().split(/\s+/).filter(Boolean);
412
- }
413
- return [];
414
- }
415
-
416
440
  export async function probeConnectorRuntime(runtimeId, runtime, options = {}) {
417
441
  const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
418
442
  const roles = options.roles || null;
@@ -433,8 +457,11 @@ export async function probeConnectorRuntime(runtimeId, runtime, options = {}) {
433
457
  // Add authority-intent and transport analysis when roles are available
434
458
  if (roles) {
435
459
  const { warnings } = analyzeLocalCliAuthorityIntent(runtimeId, runtime, roles);
436
- if (warnings.length > 0) {
437
- result.authority_warnings = warnings;
460
+ const visibleWarnings = result.error_code === 'claude_auth_preflight_failed'
461
+ ? warnings.filter((warning) => warning.probe_kind !== 'auth_preflight')
462
+ : warnings;
463
+ if (visibleWarnings.length > 0) {
464
+ result.authority_warnings = visibleWarnings;
438
465
  // Promote result level to 'warn' if binary is present but authority intent is wrong
439
466
  if (result.level === 'pass') {
440
467
  result.level = 'warn';
@@ -24,6 +24,7 @@ import { getDispatchPromptPath, getTurnStagingResultPath } from './turn-paths.js
24
24
  import { validateStagedTurnResult } from './turn-result-validator.js';
25
25
  import { probeRuntimeSpawnContext } from './runtime-spawn-context.js';
26
26
  import { buildConnectorSchemaContract } from './connector-schema-contract.js';
27
+ import { getClaudeSubprocessAuthIssue } from './claude-local-auth.js';
27
28
 
28
29
  const VALIDATABLE_RUNTIME_TYPES = new Set(['local_cli', 'api_proxy', 'mcp', 'remote_agent']);
29
30
  const DEFAULT_VALIDATE_TIMEOUT_MS = 120_000;
@@ -104,6 +105,39 @@ export async function validateConfiguredConnector(sourceRoot, options = {}) {
104
105
  };
105
106
  }
106
107
 
108
+ // DEC-BUG54-CLAUDE-AUTH-PREFLIGHT-001 — refuse the known-hanging Claude
109
+ // local_cli shape before burning the scratch-workspace + synthetic-dispatch
110
+ // ceremony. The adapter also refuses this shape via `claude_auth_preflight_failed`,
111
+ // but the operator gets a faster, identical-fix message if we catch it here.
112
+ const claudeAuthIssue = getClaudeSubprocessAuthIssue(runtime);
113
+ if (claudeAuthIssue) {
114
+ return {
115
+ ok: false,
116
+ exitCode: 1,
117
+ overall: 'fail',
118
+ runtime_id: runtimeId,
119
+ runtime_type: runtime.type,
120
+ role_id: roleSelection.roleId,
121
+ timeout_ms: timeoutMs,
122
+ warnings: [
123
+ ...roleSelection.warnings,
124
+ {
125
+ probe_kind: 'auth_preflight',
126
+ level: 'fail',
127
+ detail: claudeAuthIssue.detail,
128
+ fix: claudeAuthIssue.fix,
129
+ },
130
+ ],
131
+ error_code: 'claude_auth_preflight_failed',
132
+ error: claudeAuthIssue.detail,
133
+ auth_env_present: claudeAuthIssue.auth_env_present,
134
+ fix: claudeAuthIssue.fix,
135
+ dispatch: null,
136
+ validation: null,
137
+ scratch_root: null,
138
+ };
139
+ }
140
+
107
141
  const tempBase = mkdtempSync(join(tmpdir(), 'axc-connector-validate-'));
108
142
  const scratchRoot = join(tempBase, 'workspace');
109
143
  const warnings = [...roleSelection.warnings];
@@ -11,6 +11,10 @@
11
11
 
12
12
  import { writeFileSync, unlinkSync, readFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
13
13
  import { join, dirname, basename } from 'node:path';
14
+ import {
15
+ isDispatchProgressDiagnosticStream,
16
+ isDispatchProgressProofOutputStream,
17
+ } from './dispatch-streams.js';
14
18
 
15
19
  export const LEGACY_DISPATCH_PROGRESS_PATH = '.agentxchain/dispatch-progress.json';
16
20
  export const DISPATCH_PROGRESS_FILE_PREFIX = '.agentxchain/dispatch-progress-';
@@ -138,15 +142,37 @@ export function createDispatchProgressTracker(root, turn, options = {}) {
138
142
  const now = new Date().toISOString();
139
143
  const wasSilent = state.activity_type === 'silent';
140
144
  state.last_activity_at = now;
141
- state.first_output_at = state.first_output_at || now;
142
- state.activity_type = 'output';
143
- state.silent_since = null;
144
- if (stream === 'stderr') {
145
+ // DEC-BUG54-STDERR-IS-NOT-STARTUP-PROOF-002 (Turn 88) extended to the
146
+ // progress tracker in Turn 89: stderr is diagnostic evidence, not usable
147
+ // startup proof. Only stdout may set `first_output_at`. stderr still
148
+ // increments `stderr_lines` for silence detection and diagnostics.
149
+ let recognizedActivity = false;
150
+ if (isDispatchProgressDiagnosticStream(stream)) {
145
151
  state.stderr_lines += lineCount;
146
- } else {
152
+ recognizedActivity = true;
153
+ } else if (isDispatchProgressProofOutputStream(stream)) {
154
+ state.first_output_at = state.first_output_at || now;
147
155
  state.output_lines += lineCount;
156
+ recognizedActivity = true;
157
+ }
158
+ // DEC-BUG54-DIAGNOSTIC-ACTIVITY-TYPE-001 (Turn 91): activity_type and
159
+ // activity_summary must reflect whether operator-usable stdout proof has
160
+ // arrived. A stderr-only subprocess that never attached stdout must NOT
161
+ // be rendered as "Producing output" on the operator status surface —
162
+ // that is a false live-progress signal for a failing startup. Only when
163
+ // `output_lines > 0` may we claim 'output'; otherwise recognized stderr
164
+ // activity is surfaced as 'diagnostic_only'. Unknown stream labels do
165
+ // not mutate activity_type (Turn 90 closed-vocabulary contract).
166
+ if (recognizedActivity) {
167
+ if (state.output_lines > 0) {
168
+ state.activity_type = 'output';
169
+ state.activity_summary = `Producing output (${state.output_lines} lines)`;
170
+ } else {
171
+ state.activity_type = 'diagnostic_only';
172
+ state.activity_summary = `Diagnostic output only (${state.stderr_lines} stderr lines)`;
173
+ }
174
+ state.silent_since = null;
148
175
  }
149
- state.activity_summary = `Producing output (${state.output_lines} lines)`;
150
176
  dirty = true;
151
177
  maybeWrite();
152
178
  if (adapter_type === 'local_cli') {