diff --git a/.trajectories/completed/traj_1775820542976_5e67a482.json b/.trajectories/completed/traj_1775820542976_5e67a482.json new file mode 100644 index 000000000..f0f48da56 --- /dev/null +++ b/.trajectories/completed/traj_1775820542976_5e67a482.json @@ -0,0 +1,262 @@ +{ + "id": "traj_1775820542976_5e67a482", + "version": 1, + "task": { + "title": "fix-agent-relay-local-bootstrap-and-messaging-workflow", + "source": { + "system": "workflow-runner", + "id": "cbe6ef808363e802cb33aa82" + } + }, + "status": "abandoned", + "startedAt": "2026-04-10T11:29:02.976Z", + "agents": [ + { + "name": "orchestrator", + "role": "workflow-runner", + "joinedAt": "2026-04-10T11:29:02.976Z" + }, + { + "name": "lead", + "role": "specialist", + "joinedAt": "2026-04-10T11:29:38.340Z" + } + ], + "chapters": [ + { + "id": "ch_a62d6404", + "title": "Planning", + "agentName": "orchestrator", + "startedAt": "2026-04-10T11:29:02.976Z", + "events": [ + { + "ts": 1775820542976, + "type": "note", + "content": "Purpose: Diagnose and fix local agent-relay install/bootstrap/messaging failures on macOS, then verify broker startup, worker spawn, and CLI communication in a real repo." + }, + { + "ts": 1775820542976, + "type": "note", + "content": "Approach: 9-step dag workflow — Parsed 9 steps, 8 dependent steps, DAG validated, no cycles" + } + ], + "endedAt": "2026-04-10T11:29:38.340Z" + }, + { + "id": "ch_6f9f82cf", + "title": "Execution: plan-fixes", + "agentName": "lead", + "startedAt": "2026-04-10T11:29:38.340Z", + "events": [ + { + "ts": 1775820578340, + "type": "note", + "content": "\"plan-fixes\": Analyze the current local agent-relay failures and produce a concrete repair plan", + "raw": { + "agent": "lead" + } + }, + { + "ts": 1775820651630, + "type": "note", + "content": "\"plan-fixes\" retrying (attempt 1/3)" + } + ], + "endedAt": "2026-04-10T11:31:01.637Z" + }, + { + "id": "ch_cd17ad99", + "title": "Execution: plan-fixes", + "agentName": "lead", + "startedAt": "2026-04-10T11:31:01.637Z", + "events": [ + { + "ts": 1775820661638, + "type": "note", + "content": "\"plan-fixes\": Analyze the current local agent-relay failures and produce a concrete repair plan", + "raw": { + "agent": "lead" + } + }, + { + "ts": 1775820661650, + "type": "note", + "content": "\"plan-fixes\" retrying (attempt 2/3)" + } + ], + "endedAt": "2026-04-10T11:31:11.654Z" + }, + { + "id": "ch_06046401", + "title": "Execution: plan-fixes", + "agentName": "lead", + "startedAt": "2026-04-10T11:31:11.654Z", + "events": [ + { + "ts": 1775820671654, + "type": "note", + "content": "\"plan-fixes\": Analyze the current local agent-relay failures and produce a concrete repair plan", + "raw": { + "agent": "lead" + } + }, + { + "ts": 1775820671693, + "type": "error", + "content": "\"plan-fixes\" failed [unknown]: Unexpected failure. Review the error and step definition.", + "significance": "high", + "raw": { + "cause": "unknown", + "rawError": "agent 'plan-fixes-cbe6ef80' already exists", + "attempt": 3, + "maxRetries": 2 + } + }, + { + "ts": 1775820672304, + "type": "note", + "content": "\"fix-installer-and-launcher\" skipped — Upstream dependency \"plan-fixes\" failed" + }, + { + "ts": 1775820672305, + "type": "decision", + "content": "Whether to skip fix-installer-and-launcher → skip: Upstream dependency \"plan-fixes\" failed", + "significance": "medium", + "raw": { + "question": "Whether to skip fix-installer-and-launcher", + "chosen": "skip", + "reasoning": "Upstream dependency \"plan-fixes\" failed" + } + }, + { + "ts": 1775820672305, + "type": "note", + "content": "\"fix-cli-messaging-local-mode\" skipped — Upstream dependency \"plan-fixes\" failed" + }, + { + "ts": 1775820672306, + "type": "decision", + "content": "Whether to skip fix-cli-messaging-local-mode → skip: Upstream dependency \"plan-fixes\" failed", + "significance": "medium", + "raw": { + "question": "Whether to skip fix-cli-messaging-local-mode", + "chosen": "skip", + "reasoning": "Upstream dependency \"plan-fixes\" failed" + } + }, + { + "ts": 1775820672307, + "type": "note", + "content": "\"review-results\" skipped — Upstream dependency \"plan-fixes\" failed" + }, + { + "ts": 1775820672307, + "type": "decision", + "content": "Whether to skip review-results → skip: Upstream dependency \"plan-fixes\" failed", + "significance": "medium", + "raw": { + "question": "Whether to skip review-results", + "chosen": "skip", + "reasoning": "Upstream dependency \"plan-fixes\" failed" + } + }, + { + "ts": 1775820672307, + "type": "note", + "content": "\"verify-files-changed\" skipped — Upstream dependency \"fix-installer-and-launcher\" failed" + }, + { + "ts": 1775820672308, + "type": "decision", + "content": "Whether to skip verify-files-changed → skip: Upstream dependency \"fix-installer-and-launcher\" failed", + "significance": "medium", + "raw": { + "question": "Whether to skip verify-files-changed", + "chosen": "skip", + "reasoning": "Upstream dependency \"fix-installer-and-launcher\" failed" + } + }, + { + "ts": 1775820672308, + "type": "note", + "content": "\"rebuild-relay\" skipped — Upstream dependency \"verify-files-changed\" failed" + }, + { + "ts": 1775820672308, + "type": "decision", + "content": "Whether to skip rebuild-relay → skip: Upstream dependency \"verify-files-changed\" failed", + "significance": "medium", + "raw": { + "question": "Whether to skip rebuild-relay", + "chosen": "skip", + "reasoning": "Upstream dependency \"verify-files-changed\" failed" + } + }, + { + "ts": 1775820672308, + "type": "note", + "content": "\"smoke-test-local-launcher\" skipped — Upstream dependency \"rebuild-relay\" failed" + }, + { + "ts": 1775820672309, + "type": "decision", + "content": "Whether to skip smoke-test-local-launcher → skip: Upstream dependency \"rebuild-relay\" failed", + "significance": "medium", + "raw": { + "question": "Whether to skip smoke-test-local-launcher", + "chosen": "skip", + "reasoning": "Upstream dependency \"rebuild-relay\" failed" + } + }, + { + "ts": 1775820672309, + "type": "note", + "content": "\"integration-test-sage\" skipped — Upstream dependency \"smoke-test-local-launcher\" failed" + }, + { + "ts": 1775820672309, + "type": "decision", + "content": "Whether to skip integration-test-sage → skip: Upstream dependency \"smoke-test-local-launcher\" failed", + "significance": "medium", + "raw": { + "question": "Whether to skip integration-test-sage", + "chosen": "skip", + "reasoning": "Upstream dependency \"smoke-test-local-launcher\" failed" + } + } + ], + "endedAt": "2026-04-10T11:31:12.310Z" + }, + { + "id": "ch_4c0c1524", + "title": "Retrospective", + "agentName": "orchestrator", + "startedAt": "2026-04-10T11:31:12.310Z", + "events": [ + { + "ts": 1775820672310, + "type": "reflection", + "content": "Failed at \"plan-fixes\" [unknown] after 2min. Caused 7 downstream step(s) to be skipped: fix-installer-and-launcher, fix-cli-messaging-local-mode, verify-files-changed, rebuild-relay, smoke-test-local-launcher, integration-test-sage, review-results. 1/9 steps completed before failure. (abandoned after 2 minutes)", + "significance": "high" + }, + { + "ts": 1775820672310, + "type": "error", + "content": "Workflow abandoned: Step \"plan-fixes\" failed: Step \"plan-fixes\" failed after 2 retries: agent 'plan-fixes-cbe6ef80' already exists", + "significance": "high" + } + ], + "endedAt": "2026-04-10T11:31:12.310Z" + } + ], + "completedAt": "2026-04-10T11:31:12.310Z", + "retrospective": { + "summary": "Failed at \"plan-fixes\" [unknown] after 2min. Caused 7 downstream step(s) to be skipped: fix-installer-and-launcher, fix-cli-messaging-local-mode, verify-files-changed, rebuild-relay, smoke-test-local-launcher, integration-test-sage, review-results. 1/9 steps completed before failure.", + "approach": "dag workflow (1 agents)", + "confidence": 0.08333333333333333, + "learnings": [], + "challenges": [ + "Unexpected failure. Review the error and step definition." + ] + } +} \ No newline at end of file diff --git a/install.sh b/install.sh index f32050bf9..524c41c43 100755 --- a/install.sh +++ b/install.sh @@ -16,6 +16,8 @@ REPO_DASHBOARD="AgentWorkforce/relay-dashboard" VERSION="${AGENT_RELAY_VERSION:-latest}" INSTALL_DIR="${AGENT_RELAY_INSTALL_DIR:-$HOME/.agent-relay}" BIN_DIR="${AGENT_RELAY_BIN_DIR:-$HOME/.local/bin}" +LAST_VERIFY_FAILURE_REASON="" +STANDALONE_BINARY_FAILURE_REASON="" # Colors RED='\033[0;31m' @@ -168,14 +170,13 @@ download_broker_binary() { chmod +x "$target_path" strip_quarantine "$target_path" # Verify binary works (Rust clap binary supports --help) - if "$target_path" --help &>/dev/null; then + if verify_downloaded_executable "$target_path" "--help" "Downloaded broker binary"; then # Also install to BIN_DIR so it's discoverable on PATH cp "$target_path" "$BIN_DIR/agent-relay-broker" chmod +x "$BIN_DIR/agent-relay-broker" success "Downloaded broker binary (workflow agent spawning)" return 0 else - warn "broker binary failed verification" rm -f "$target_path" return 1 fi @@ -224,12 +225,12 @@ download_dashboard_binary() { chmod +x "$target_path" strip_quarantine "$target_path" - if "$target_path" --version &>/dev/null; then + if verify_downloaded_executable "$target_path" "--version" "Downloaded dashboard binary"; then success "Downloaded standalone dashboard-server binary" trap - EXIT return 0 else - warn "Dashboard binary failed verification, trying uncompressed..." + warn "Dashboard binary failed verification after compressed download, trying uncompressed..." rm -f "$target_path" fi else @@ -252,14 +253,12 @@ download_dashboard_binary() { chmod +x "$target_path" strip_quarantine "$target_path" - if "$target_path" --version &>/dev/null; then + if verify_downloaded_executable "$target_path" "--version" "Downloaded dashboard binary"; then success "Downloaded standalone dashboard-server binary" trap - EXIT return 0 - else - warn "Dashboard binary failed verification" - rm -f "$target_path" fi + rm -f "$target_path" else rm -f "$target_path" fi @@ -332,6 +331,227 @@ has_command() { command -v "$1" &> /dev/null } +is_broken_symlink() { + [ -L "$1" ] && [ ! -e "$1" ] +} + +is_runtime_manager_shim() { + case "$1" in + *"/shims/"*|*"/.asdf/"*|*"/.local/share/mise/"*|*"/.volta/bin/"*|*"/runtime-manager/"*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +launcher_has_marker() { + [ -f "$1" ] && grep -q "agent-relay-managed-launcher" "$1" 2>/dev/null +} + +is_replaceable_agent_relay_launcher() { + local existing_path="$1" + + if [ ! -e "$existing_path" ] && [ ! -L "$existing_path" ]; then + return 0 + fi + + if is_runtime_manager_shim "$existing_path"; then + return 1 + fi + + if is_broken_symlink "$existing_path" || [ -L "$existing_path" ]; then + return 0 + fi + + if launcher_has_marker "$existing_path"; then + return 0 + fi + + if [ -f "$existing_path" ] && grep -Iq . "$existing_path" 2>/dev/null; then + if grep -Eq 'agent-relay launcher target missing|node_modules/.*/agent-relay|dist/src/cli/index\.js' "$existing_path" 2>/dev/null; then + return 0 + fi + fi + + return 1 +} + +get_npm_global_bin_dir() { + if ! has_command node || ! has_command npm; then + return 1 + fi + + local node_major + node_major=$(node -v 2>/dev/null | cut -d'v' -f2 | cut -d'.' -f1) + if [ -z "$node_major" ] || [ "$node_major" -lt 18 ]; then + return 1 + fi + + local npm_prefix + npm_prefix=$(npm config get prefix 2>/dev/null | tr -d '\r') + if [ -z "$npm_prefix" ] || [ "$npm_prefix" = "undefined" ]; then + return 1 + fi + + echo "${npm_prefix}/bin" +} + +write_launcher() { + local launcher_path="$1" + local target_path="$2" + local launcher_kind="$3" + local temp_path="${launcher_path}.tmp.$$" + + mkdir -p "$(dirname "$launcher_path")" + + case "$launcher_kind" in + node) + cat > "$temp_path" <&2 + exit 1 +fi +exec node "$target_path" "\$@" +EOF + ;; + binary) + cat > "$temp_path" <&2 + exit 1 +fi +exec "$target_path" "\$@" +EOF + ;; + *) + rm -f "$temp_path" + error "Unknown launcher kind: $launcher_kind" + ;; + esac + + if [ -d "$launcher_path" ] && [ ! -L "$launcher_path" ]; then + rm -f "$temp_path" + error "Cannot install launcher because $launcher_path is a directory" + fi + + if is_broken_symlink "$launcher_path"; then + info "Removing stale launcher symlink at $launcher_path" + fi + rm -f "$launcher_path" + + chmod +x "$temp_path" + mv -f "$temp_path" "$launcher_path" +} + +install_managed_binary_with_launcher() { + local staged_path="$1" + local binary_name="$2" + local launcher_name="$3" + local managed_dir="$INSTALL_DIR/bin" + local managed_path="$managed_dir/$binary_name" + + mkdir -p "$managed_dir" + + if [ -d "$managed_path" ] && [ ! -L "$managed_path" ]; then + error "Cannot install managed binary because $managed_path is a directory" + fi + + if is_broken_symlink "$managed_path"; then + info "Removing stale managed binary symlink at $managed_path" + fi + rm -f "$managed_path" + + mv -f "$staged_path" "$managed_path" + chmod +x "$managed_path" + write_launcher "$BIN_DIR/$launcher_name" "$managed_path" "binary" +} + +install_npm_launchers() { + local package_root="$1" + local cli_path="$package_root/dist/src/cli/index.js" + + if [ ! -f "$cli_path" ]; then + warn "npm install completed but CLI entrypoint is missing at $cli_path" + return 1 + fi + + write_launcher "$BIN_DIR/agent-relay" "$cli_path" "node" + success "Installed agent-relay launcher to $BIN_DIR" + + local npm_bin_dir + npm_bin_dir=$(get_npm_global_bin_dir 2>/dev/null || true) + case "$npm_bin_dir" in + "") + return 0 + ;; + /*) + ;; + *) + warn "Ignoring unexpected npm global bin dir output: $(printf '%s' "$npm_bin_dir" | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//')" + return 0 + ;; + esac + + if [ -n "$npm_bin_dir" ] && [ "$npm_bin_dir" != "$BIN_DIR" ]; then + local npm_launcher_path="$npm_bin_dir/agent-relay" + + if [ ! -e "$npm_launcher_path" ] && [ ! -L "$npm_launcher_path" ]; then + return 0 + fi + + if is_replaceable_agent_relay_launcher "$npm_launcher_path"; then + if mkdir -p "$npm_bin_dir" 2>/dev/null && [ -w "$npm_bin_dir" ]; then + write_launcher "$npm_launcher_path" "$cli_path" "node" + info "Replaced npm shim with a stable launcher at $npm_launcher_path" + else + warn "Unable to update npm bin launcher at $npm_launcher_path" + fi + else + warn "Leaving existing agent-relay command at $npm_launcher_path untouched; using managed launcher at $BIN_DIR/agent-relay" + fi + fi +} + +verify_downloaded_executable() { + local target_path="$1" + local verify_arg="$2" + local label="$3" + local verify_log="/tmp/agent-relay-verify-$$.log" + local status=0 + LAST_VERIFY_FAILURE_REASON="" + + "$target_path" "$verify_arg" >"$verify_log" 2>&1 || status=$? + if [ "$status" -eq 0 ]; then + rm -f "$verify_log" + return 0 + fi + + local tail_output="" + tail_output=$(tail -n 3 "$verify_log" 2>/dev/null | tr '\n' ' ' | sed 's/[[:space:]]\+/ /g; s/^ //; s/ $//') + rm -f "$verify_log" + + if [ "$OS" = "darwin" ] && [ "$status" -eq 137 ]; then + LAST_VERIFY_FAILURE_REASON="macos_killed" + warn "$label was killed by macOS during verification (exit 137). macOS likely rejected the downloaded binary on first launch." + elif [ -n "$tail_output" ]; then + LAST_VERIFY_FAILURE_REASON="command_failed" + warn "$label failed verification: $tail_output" + else + LAST_VERIFY_FAILURE_REASON="command_failed" + warn "$label failed verification" + fi + + return 1 +} + # Prepare a downloaded macOS binary so Gatekeeper does not kill it during # first execution. strip_quarantine() { @@ -376,12 +596,12 @@ download_relay_acp() { chmod +x "$target_path" strip_quarantine "$target_path" - if "$target_path" --help &>/dev/null; then + if verify_downloaded_executable "$target_path" "--help" "Downloaded relay-acp binary"; then success "Downloaded relay-acp binary (Zed ACP bridge)" trap - EXIT return 0 else - warn "relay-acp binary failed verification, trying uncompressed..." + warn "relay-acp binary failed verification after compressed download, trying uncompressed..." rm -f "$target_path" fi else @@ -402,13 +622,12 @@ download_relay_acp() { chmod +x "$target_path" strip_quarantine "$target_path" - if "$target_path" --help &>/dev/null; then + if verify_downloaded_executable "$target_path" "--help" "Downloaded relay-acp binary"; then success "Downloaded relay-acp binary (Zed ACP bridge)" trap - EXIT return 0 - else - rm -f "$target_path" fi + rm -f "$target_path" else rm -f "$target_path" fi @@ -460,13 +679,13 @@ download_standalone_binary() { local binary_name="agent-relay-${PLATFORM}" local compressed_url="https://github.com/$REPO_RELAY/releases/download/v${VERSION}/${binary_name}.gz" local uncompressed_url="https://github.com/$REPO_RELAY/releases/download/v${VERSION}/${binary_name}" - local target_path="$BIN_DIR/agent-relay" local temp_file="/tmp/agent-relay-download-$$" + local target_path="${temp_file}.bin" - mkdir -p "$BIN_DIR" + mkdir -p "$BIN_DIR" "$INSTALL_DIR/bin" # Setup cleanup trap for temp files - trap 'rm -f "${temp_file}.gz" "${temp_file}"' EXIT + trap 'rm -f "${temp_file}.gz" "${temp_file}" "${target_path}"' EXIT # Try compressed binary first (faster download, ~60-70% smaller) # Only if gunzip is available @@ -490,12 +709,16 @@ download_standalone_binary() { strip_quarantine "$target_path" # Verify the binary works - if "$target_path" --version &>/dev/null; then + if verify_downloaded_executable "$target_path" "--version" "Downloaded standalone agent-relay binary"; then + install_managed_binary_with_launcher "$target_path" "agent-relay" "agent-relay" success "Downloaded standalone agent-relay binary" trap - EXIT # Clear trap return 0 else - warn "Downloaded binary failed verification, trying uncompressed..." + if [ "$LAST_VERIFY_FAILURE_REASON" = "macos_killed" ]; then + STANDALONE_BINARY_FAILURE_REASON="macos_killed" + fi + warn "Standalone binary failed verification after compressed download, trying uncompressed..." rm -f "$target_path" fi else @@ -527,14 +750,16 @@ download_standalone_binary() { strip_quarantine "$target_path" # Verify the binary works - if "$target_path" --version &>/dev/null; then + if verify_downloaded_executable "$target_path" "--version" "Downloaded standalone agent-relay binary"; then + install_managed_binary_with_launcher "$target_path" "agent-relay" "agent-relay" success "Downloaded standalone agent-relay binary (no Node.js required!)" trap - EXIT # Clear trap return 0 - else - warn "Downloaded binary failed verification" - rm -f "$target_path" fi + if [ "$LAST_VERIFY_FAILURE_REASON" = "macos_killed" ]; then + STANDALONE_BINARY_FAILURE_REASON="macos_killed" + fi + rm -f "$target_path" else info "Uncompressed binary not available (file too small: ${file_size} bytes)" rm -f "$target_path" @@ -542,6 +767,9 @@ download_standalone_binary() { fi trap - EXIT # Clear trap + if [ "$STANDALONE_BINARY_FAILURE_REASON" = "macos_killed" ]; then + warn "Standalone binary verification failed on macOS. Falling back to npm/source so ~/.local/bin/agent-relay still points at a managed install." + fi info "No standalone binary available for $PLATFORM, falling back to npm" return 1 } @@ -629,6 +857,10 @@ Or use nvm: curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/instal rm -f "$npm_log" fi + local npm_package_root + npm_package_root="$(npm root -g 2>/dev/null | tr -d '\r')/agent-relay" + install_npm_launchers "$npm_package_root" || warn "Failed to install agent-relay launchers after npm install" + # Install dashboard if not skipped if [ "${AGENT_RELAY_NO_DASHBOARD}" != "true" ]; then # Try binary first, fall back to npm @@ -694,14 +926,7 @@ install_from_source() { npm run build # Create wrapper script - mkdir -p "$BIN_DIR" - rm -f "$BIN_DIR/agent-relay" - - cat > "$BIN_DIR/agent-relay" << WRAPPER -#!/usr/bin/env bash -cd "$INSTALL_DIR" && exec node dist/src/cli/index.js "\$@" -WRAPPER - chmod +x "$BIN_DIR/agent-relay" + write_launcher "$BIN_DIR/agent-relay" "$INSTALL_DIR/dist/src/cli/index.js" "node" success "Installed from source" } @@ -711,11 +936,11 @@ setup_path() { if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then warn "Add to your PATH by running:" echo "" - echo " export PATH=\"\$PATH:$BIN_DIR\"" + echo " export PATH=\"$BIN_DIR:\$PATH\"" echo "" echo " # Or add to your shell profile:" - echo " echo 'export PATH=\"\$PATH:$BIN_DIR\"' >> ~/.bashrc # for bash" - echo " echo 'export PATH=\"\$PATH:$BIN_DIR\"' >> ~/.zshrc # for zsh" + echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.bashrc # for bash" + echo " echo 'export PATH=\"$BIN_DIR:\$PATH\"' >> ~/.zshrc # for zsh" echo "" fi } @@ -724,29 +949,43 @@ setup_path() { verify_installation() { step "Verifying installation..." - # Check if agent-relay is available - if command -v agent-relay &> /dev/null; then - local installed_version=$(agent-relay --version 2>/dev/null || echo "unknown") - success "agent-relay $installed_version installed successfully!" - - # Warn if another version shadows the one we just installed - local which_path=$(command -v agent-relay) - if [ -x "$BIN_DIR/agent-relay" ] && [ "$which_path" != "$BIN_DIR/agent-relay" ]; then - local other_version=$("$BIN_DIR/agent-relay" --version 2>/dev/null || echo "unknown") - if [ "$installed_version" != "$other_version" ]; then - warn "Another agent-relay ($installed_version) at $which_path shadows the newly installed $other_version at $BIN_DIR/agent-relay" - echo " To fix, either:" - echo " 1. Uninstall the old version: npm uninstall -g agent-relay" - echo " 2. Or ensure $BIN_DIR is earlier in your PATH" - fi - fi - elif [ -x "$BIN_DIR/agent-relay" ]; then - local installed_version=$("$BIN_DIR/agent-relay" --version 2>/dev/null || echo "unknown") + local launcher_path="$BIN_DIR/agent-relay" + local installed_version="" + + if [ -x "$launcher_path" ] && installed_version=$("$launcher_path" --version 2>/dev/null); then success "agent-relay $installed_version installed to $BIN_DIR" - setup_path else error "Installation verification failed" fi + + if ! command -v agent-relay &> /dev/null; then + setup_path + return 0 + fi + + local which_path + which_path=$(command -v agent-relay) + if [ "$which_path" = "$launcher_path" ]; then + return 0 + fi + + local active_version="" + if active_version=$(agent-relay --version 2>/dev/null); then + if [ "$active_version" != "$installed_version" ]; then + warn "Another agent-relay ($active_version) at $which_path shadows the newly installed $installed_version at $launcher_path" + setup_path + fi + return 0 + fi + + if is_broken_symlink "$which_path"; then + warn "A stale agent-relay symlink at $which_path is shadowing the installed launcher at $launcher_path" + elif is_runtime_manager_shim "$which_path"; then + warn "A runtime-manager shim at $which_path is shadowing the installed launcher at $launcher_path" + else + warn "A broken agent-relay command at $which_path is shadowing the installed launcher at $launcher_path" + fi + setup_path } # Print usage instructions @@ -813,6 +1052,10 @@ main() { install_from_source && verify_installation && print_usage && track_event "install_completed" && exit 0 else echo "" + if [ "$STANDALONE_BINARY_FAILURE_REASON" = "macos_killed" ]; then + warn "macOS rejected the downloaded standalone binary, and Node.js is not available for the fallback install path." + echo "" + fi warn "No standalone binary available and Node.js not found." echo "" echo -e "${BOLD}Options:${NC}" diff --git a/package-lock.json b/package-lock.json index 1e3964d2f..df0955463 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-relay", - "version": "4.0.5", + "version": "4.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-relay", - "version": "4.0.5", + "version": "4.0.9", "bundleDependencies": [ "@agent-relay/cloud", "@agent-relay/config", @@ -24,14 +24,14 @@ "web" ], "dependencies": { - "@agent-relay/cloud": "4.0.5", - "@agent-relay/config": "4.0.5", - "@agent-relay/hooks": "4.0.5", - "@agent-relay/sdk": "4.0.5", - "@agent-relay/telemetry": "4.0.5", - "@agent-relay/trajectory": "4.0.5", - "@agent-relay/user-directory": "4.0.5", - "@agent-relay/utils": "4.0.5", + "@agent-relay/cloud": "4.0.9", + "@agent-relay/config": "4.0.9", + "@agent-relay/hooks": "4.0.9", + "@agent-relay/sdk": "4.0.9", + "@agent-relay/telemetry": "4.0.9", + "@agent-relay/trajectory": "4.0.9", + "@agent-relay/user-directory": "4.0.9", + "@agent-relay/utils": "4.0.9", "@aws-sdk/client-s3": "3.1020.0", "@modelcontextprotocol/sdk": "^1.0.0", "@relayauth/core": "^0.1.2", @@ -1180,7 +1180,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -15248,10 +15248,10 @@ }, "packages/acp-bridge": { "name": "@agent-relay/acp-bridge", - "version": "4.0.5", + "version": "4.0.9", "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "4.0.5", + "@agent-relay/sdk": "4.0.9", "@agentclientprotocol/sdk": "^0.12.0" }, "bin": { @@ -15268,13 +15268,13 @@ }, "packages/brand": { "name": "@agent-relay/brand", - "version": "4.0.5" + "version": "4.0.9" }, "packages/cloud": { "name": "@agent-relay/cloud", - "version": "4.0.5", + "version": "4.0.9", "dependencies": { - "@agent-relay/config": "4.0.5", + "@agent-relay/config": "4.0.9", "@aws-sdk/client-s3": "3.1020.0", "ignore": "^7.0.5", "tar": "^7.5.10" @@ -15287,7 +15287,7 @@ }, "packages/config": { "name": "@agent-relay/config", - "version": "4.0.5", + "version": "4.0.9", "dependencies": { "zod": "^3.23.8", "zod-to-json-schema": "^3.23.1" @@ -15300,9 +15300,9 @@ }, "packages/gateway": { "name": "@agent-relay/gateway", - "version": "4.0.5", + "version": "4.0.9", "dependencies": { - "@agent-relay/sdk": "4.0.5" + "@agent-relay/sdk": "4.0.9" }, "devDependencies": { "@types/node": "^22.19.3", @@ -15312,11 +15312,11 @@ }, "packages/hooks": { "name": "@agent-relay/hooks", - "version": "4.0.5", + "version": "4.0.9", "dependencies": { - "@agent-relay/config": "4.0.5", - "@agent-relay/sdk": "4.0.5", - "@agent-relay/trajectory": "4.0.5" + "@agent-relay/config": "4.0.9", + "@agent-relay/sdk": "4.0.9", + "@agent-relay/trajectory": "4.0.9" }, "devDependencies": { "@types/node": "^22.19.3", @@ -15326,9 +15326,9 @@ }, "packages/memory": { "name": "@agent-relay/memory", - "version": "4.0.5", + "version": "4.0.9", "dependencies": { - "@agent-relay/hooks": "4.0.5" + "@agent-relay/hooks": "4.0.9" }, "devDependencies": { "@types/node": "^22.19.3", @@ -15338,11 +15338,11 @@ }, "packages/openclaw": { "name": "@agent-relay/openclaw", - "version": "4.0.5", + "version": "4.0.9", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@agent-relay/sdk": "4.0.5", + "@agent-relay/sdk": "4.0.9", "@relaycast/sdk": "^1.0.0", "ws": "^8.0.0" }, @@ -16166,9 +16166,9 @@ }, "packages/policy": { "name": "@agent-relay/policy", - "version": "4.0.5", + "version": "4.0.9", "dependencies": { - "@agent-relay/config": "4.0.5" + "@agent-relay/config": "4.0.9" }, "devDependencies": { "@types/node": "^22.19.3", @@ -16178,9 +16178,9 @@ }, "packages/sdk": { "name": "@agent-relay/sdk", - "version": "4.0.5", + "version": "4.0.9", "dependencies": { - "@agent-relay/config": "4.0.5", + "@agent-relay/config": "4.0.9", "@relaycast/sdk": "^1.1.0", "@relayfile/sdk": "^0.1.2", "@sinclair/typebox": "^0.34.48", @@ -16268,7 +16268,7 @@ }, "packages/telemetry": { "name": "@agent-relay/telemetry", - "version": "4.0.5", + "version": "4.0.9", "dependencies": { "posthog-node": "^4.0.1" }, @@ -16280,9 +16280,9 @@ }, "packages/trajectory": { "name": "@agent-relay/trajectory", - "version": "4.0.5", + "version": "4.0.9", "dependencies": { - "@agent-relay/config": "4.0.5" + "@agent-relay/config": "4.0.9" }, "devDependencies": { "@types/node": "^22.19.3", @@ -16292,9 +16292,9 @@ }, "packages/user-directory": { "name": "@agent-relay/user-directory", - "version": "4.0.5", + "version": "4.0.9", "dependencies": { - "@agent-relay/utils": "4.0.5" + "@agent-relay/utils": "4.0.9" }, "devDependencies": { "@types/node": "^22.19.3", @@ -16304,9 +16304,9 @@ }, "packages/utils": { "name": "@agent-relay/utils", - "version": "4.0.5", + "version": "4.0.9", "dependencies": { - "@agent-relay/config": "4.0.5", + "@agent-relay/config": "4.0.9", "compare-versions": "^6.1.1" }, "devDependencies": { diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 538a0336d..481869e2b 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -508,6 +508,13 @@ export class AgentRelayClient { return this.transport.request('/api/status'); } + async getMessageHistory(): Promise>> { + const response = await this.transport.request<{ messages?: Array> }>( + '/api/history/messages' + ); + return Array.isArray(response.messages) ? response.messages : []; + } + async getCrashInsights(): Promise { return this.transport.request('/api/crash-insights'); } diff --git a/src/cli/commands/messaging.test.ts b/src/cli/commands/messaging.test.ts index 8bd0d007a..2fe85c7f7 100644 --- a/src/cli/commands/messaging.test.ts +++ b/src/cli/commands/messaging.test.ts @@ -19,6 +19,7 @@ function createBrokerClientMock( ): MessagingBrokerClient { return { sendMessage: vi.fn(async () => ({ event_id: 'evt_1', targets: [] })), + getMessageHistory: vi.fn(async () => []), shutdown: vi.fn(async () => undefined), ...overrides, }; @@ -48,11 +49,13 @@ function createHarness(options?: { brokerClient?: MessagingBrokerClient; relaycastClient?: MessagingRelaycastClient; projectRoot?: string; + createConnectError?: Error; createRelaycastError?: Error; }) { const brokerClient = options?.brokerClient ?? createBrokerClientMock(); const relaycastClient = options?.relaycastClient ?? createRelaycastClientMock(); const projectRoot = options?.projectRoot ?? '/tmp/project'; + const createConnectError = options?.createConnectError; const createRelaycastError = options?.createRelaycastError; const exit = vi.fn((code: number) => { @@ -61,6 +64,12 @@ function createHarness(options?: { const deps: MessagingDependencies = { getProjectRoot: vi.fn(() => projectRoot), + connectClient: vi.fn(async () => { + if (createConnectError) { + throw createConnectError; + } + return brokerClient; + }), createClient: vi.fn(() => brokerClient), createRelaycastClient: vi.fn(async () => { if (createRelaycastError) { @@ -129,6 +138,66 @@ describe('registerMessagingCommands', () => { expect(deps.log).toHaveBeenCalledWith('Message sent to WorkerA'); }); + it('lets the broker choose the default sender when --from is omitted', async () => { + const brokerClient = createBrokerClientMock(); + const { program } = createHarness({ brokerClient }); + + const exitCode = await runCommand(program, ['send', 'WorkerA', 'Ship this today']); + + expect(exitCode).toBeUndefined(); + expect(brokerClient.sendMessage).toHaveBeenCalledWith({ + to: 'WorkerA', + text: 'Ship this today', + }); + }); + + it('uses AGENT_RELAY_SENDER when configured and --from is omitted', async () => { + const brokerClient = createBrokerClientMock(); + const originalSender = process.env.AGENT_RELAY_SENDER; + process.env.AGENT_RELAY_SENDER = 'relay-operator'; + const { program } = createHarness({ brokerClient }); + + try { + const exitCode = await runCommand(program, ['send', 'WorkerA', 'Ship this today']); + + expect(exitCode).toBeUndefined(); + expect(brokerClient.sendMessage).toHaveBeenCalledWith({ + to: 'WorkerA', + text: 'Ship this today', + from: 'relay-operator', + }); + } finally { + if (originalSender === undefined) { + delete process.env.AGENT_RELAY_SENDER; + } else { + process.env.AGENT_RELAY_SENDER = originalSender; + } + } + }); + + it('treats a blank --from value as omitted so local broker defaults still work', async () => { + const brokerClient = createBrokerClientMock(); + const originalSender = process.env.AGENT_RELAY_SENDER; + delete process.env.AGENT_RELAY_SENDER; + const { program } = createHarness({ brokerClient }); + + try { + const exitCode = await runCommand(program, ['send', 'WorkerA', 'Ship this today', '--from', ' ']); + + expect(exitCode).toBeUndefined(); + expect(brokerClient.sendMessage).toHaveBeenCalledWith({ + to: 'WorkerA', + text: 'Ship this today', + }); + } finally { + if (originalSender === undefined) { + delete process.env.AGENT_RELAY_SENDER; + } else { + process.env.AGENT_RELAY_SENDER = originalSender; + } + } + }); + it('reads a message by ID', async () => { const relaycastClient = createRelaycastClientMock({ message: vi.fn(async () => ({ @@ -143,7 +212,10 @@ describe('registerMessagingCommands', () => { const exitCode = await runCommand(program, ['read', 'msg_123']); expect(exitCode).toBeUndefined(); - expect(deps.createRelaycastClient).toHaveBeenCalledWith({ agentName: '__cli_read__' }); + expect(deps.createRelaycastClient).toHaveBeenCalledWith({ + agentName: '__cli_read__', + cwd: '/tmp/project', + }); expect(relaycastClient.message).toHaveBeenCalledWith('msg_123'); expect(deps.log).toHaveBeenNthCalledWith(1, 'From: Lead'); expect(deps.log).toHaveBeenNthCalledWith(2, 'To: #channel'); @@ -153,58 +225,62 @@ describe('registerMessagingCommands', () => { }); it('shows message history with limit option', async () => { - const relaycastClient = createRelaycastClientMock({ - messages: vi.fn(async () => [ + const brokerClient = createBrokerClientMock({ + getMessageHistory: vi.fn(async () => [ { - id: 'm3', - agent_name: 'Three', + event_id: 'm3', + from: 'Three', + target: '#general', text: 'third', - created_at: '2026-02-20T11:00:03.000Z', + timestamp: '2026-02-20T11:00:03.000Z', }, { - id: 'm2', - agent_name: 'Two', + event_id: 'm2', + from: 'Two', + target: '#general', text: 'second', - created_at: '2026-02-20T11:00:02.000Z', + timestamp: '2026-02-20T11:00:02.000Z', }, { - id: 'm1', - agent_name: 'One', + event_id: 'm1', + from: 'One', + target: '#general', text: 'first', - created_at: '2026-02-20T11:00:01.000Z', + timestamp: '2026-02-20T11:00:01.000Z', }, ]), }); - const { program, deps } = createHarness({ relaycastClient }); + const { program, deps } = createHarness({ brokerClient }); const exitCode = await runCommand(program, ['history', '--limit', '2']); expect(exitCode).toBeUndefined(); - expect(deps.createRelaycastClient).toHaveBeenCalledWith({ agentName: '__cli_history__' }); - expect(relaycastClient.messages).toHaveBeenCalledWith('general', { limit: 100 }); + expect(deps.connectClient).toHaveBeenCalledWith('/tmp/project'); + expect(brokerClient.getMessageHistory).toHaveBeenCalledTimes(1); expect(deps.log).toHaveBeenCalledWith('[2026-02-20T11:00:03.000Z] Three -> #general: third'); expect(deps.log).toHaveBeenCalledWith('[2026-02-20T11:00:02.000Z] Two -> #general: second'); expect(deps.log).not.toHaveBeenCalledWith(expect.stringContaining('One -> #general')); }); it('shows message history as JSON with stable payload fields', async () => { - const relaycastClient = createRelaycastClientMock({ - messages: vi.fn(async () => [ + const brokerClient = createBrokerClientMock({ + getMessageHistory: vi.fn(async () => [ { - id: 'm1', - agent_name: 'One', + event_id: 'm1', + from: 'One', + target: 'WorkerA', text: 'first', - created_at: '2026-02-20T11:00:01.000Z', + thread_id: 'thread-1', + timestamp: '2026-02-20T11:00:01.000Z', }, ]), }); - const { program, deps } = createHarness({ relaycastClient }); + const { program, deps } = createHarness({ brokerClient }); const exitCode = await runCommand(program, ['history', '--json', '--limit', '1']); expect(exitCode).toBeUndefined(); - expect(deps.createRelaycastClient).toHaveBeenCalledWith({ agentName: '__cli_history__' }); - expect(relaycastClient.messages).toHaveBeenCalledWith('general', { limit: 100 }); + expect(deps.connectClient).toHaveBeenCalledWith('/tmp/project'); expect(deps.log).toHaveBeenCalledTimes(1); expect(JSON.parse((deps.log as ReturnType).mock.calls[0][0] as string)).toEqual([ { @@ -212,14 +288,152 @@ describe('registerMessagingCommands', () => { ts: Date.parse('2026-02-20T11:00:01.000Z'), timestamp: '2026-02-20T11:00:01.000Z', from: 'One', - to: '#general', - thread: null, + to: 'WorkerA', + thread: 'thread-1', kind: 'message', body: 'first', }, ]); }); + it('falls back to relaycast history when no local broker is available', async () => { + const relaycastClient = createRelaycastClientMock({ + messages: vi.fn(async () => [ + { + id: 'm1', + agent_name: 'One', + text: 'first', + created_at: '2026-02-20T11:00:01.000Z', + }, + ]), + }); + const { program, deps } = createHarness({ + relaycastClient, + createConnectError: new Error('No running broker found'), + }); + const originalApiKey = process.env.RELAY_API_KEY; + process.env.RELAY_API_KEY = 'rk_test_123'; + + try { + const exitCode = await runCommand(program, ['history', '--limit', '1']); + + expect(exitCode).toBeUndefined(); + expect(deps.createRelaycastClient).toHaveBeenCalledWith({ + agentName: '__cli_history__', + cwd: '/tmp/project', + }); + expect(relaycastClient.messages).toHaveBeenCalledWith('general', { limit: 100 }); + expect(deps.log).toHaveBeenCalledWith('[2026-02-20T11:00:01.000Z] One -> #general: first'); + } finally { + if (originalApiKey === undefined) { + delete process.env.RELAY_API_KEY; + } else { + process.env.RELAY_API_KEY = originalApiKey; + } + } + }); + + it('falls back to relaycast history without RELAY_API_KEY when the broker session is still reachable', async () => { + const brokerClient = createBrokerClientMock({ + getMessageHistory: vi.fn(async () => { + throw new Error('history backend unavailable'); + }), + }); + const relaycastClient = createRelaycastClientMock({ + messages: vi.fn(async () => [ + { + id: 'm1', + agent_name: 'One', + text: 'first', + created_at: '2026-02-20T11:00:01.000Z', + }, + ]), + }); + const { program, deps } = createHarness({ + brokerClient, + relaycastClient, + }); + const originalApiKey = process.env.RELAY_API_KEY; + delete process.env.RELAY_API_KEY; + + try { + const exitCode = await runCommand(program, ['history', '--limit', '1']); + + expect(exitCode).toBeUndefined(); + expect(deps.connectClient).toHaveBeenCalledWith('/tmp/project'); + expect(deps.createRelaycastClient).toHaveBeenCalledWith({ + agentName: '__cli_history__', + cwd: '/tmp/project', + }); + expect(relaycastClient.messages).toHaveBeenCalledWith('general', { limit: 100 }); + expect(deps.log).toHaveBeenCalledWith('[2026-02-20T11:00:01.000Z] One -> #general: first'); + } finally { + if (originalApiKey === undefined) { + delete process.env.RELAY_API_KEY; + } else { + process.env.RELAY_API_KEY = originalApiKey; + } + } + }); + + it('explains how to fix history when local broker and relaycast key are both unavailable', async () => { + const { program, deps } = createHarness({ + createConnectError: new Error('No running broker found'), + }); + const originalApiKey = process.env.RELAY_API_KEY; + delete process.env.RELAY_API_KEY; + + try { + const exitCode = await runCommand(program, ['history']); + + expect(exitCode).toBe(1); + expect(deps.error).toHaveBeenCalledWith('Failed to read local broker history: No running broker found'); + expect(deps.error).toHaveBeenCalledWith( + 'No Relaycast API key found in RELAY_API_KEY. Start the local broker with `agent-relay up` and retry, or set RELAY_API_KEY to read Relaycast history.' + ); + } finally { + if (originalApiKey === undefined) { + delete process.env.RELAY_API_KEY; + } else { + process.env.RELAY_API_KEY = originalApiKey; + } + } + }); + + it('clarifies local-only mode when relaycast fallback has no workspace key to use', async () => { + const brokerClient = createBrokerClientMock({ + getMessageHistory: vi.fn(async () => { + throw new Error('history backend unavailable'); + }), + }); + const { program, deps } = createHarness({ + brokerClient, + createRelaycastError: new Error( + 'Relaycast API key not found in RELAY_API_KEY or the running broker session' + ), + }); + const originalApiKey = process.env.RELAY_API_KEY; + delete process.env.RELAY_API_KEY; + + try { + const exitCode = await runCommand(program, ['history']); + + expect(exitCode).toBe(1); + expect(deps.error).toHaveBeenCalledWith( + 'Relaycast history is unavailable because this broker is running in local-only mode and no RELAY_API_KEY is configured.' + ); + expect(deps.error).toHaveBeenCalledWith( + 'Local broker history was unavailable: history backend unavailable' + ); + } finally { + if (originalApiKey === undefined) { + delete process.env.RELAY_API_KEY; + } else { + process.env.RELAY_API_KEY = originalApiKey; + } + } + }); + it('shows unread inbox summary', async () => { const relaycastClient = createRelaycastClientMock({ inbox: vi.fn(async () => ({ @@ -241,7 +455,10 @@ describe('registerMessagingCommands', () => { const exitCode = await runCommand(program, ['inbox']); expect(exitCode).toBeUndefined(); - expect(deps.createRelaycastClient).toHaveBeenCalledWith({ agentName: '__cli_inbox__' }); + expect(deps.createRelaycastClient).toHaveBeenCalledWith({ + agentName: '__cli_inbox__', + cwd: '/tmp/project', + }); expect(deps.log).toHaveBeenCalledWith('Unread Channels:'); expect(deps.log).toHaveBeenCalledWith(' #general: 2'); expect(deps.log).toHaveBeenCalledWith('Mentions:'); diff --git a/src/cli/commands/messaging.ts b/src/cli/commands/messaging.ts index e865c569c..98bfaf336 100644 --- a/src/cli/commands/messaging.ts +++ b/src/cli/commands/messaging.ts @@ -39,6 +39,34 @@ interface RelaycastInbox { unread_dms: RelaycastUnreadDm[]; } +interface BrokerHistoryMessage { + id?: string; + event_id?: string; + from?: string; + target?: string; + to?: string; + text?: string; + body?: string; + thread_id?: string; + threadId?: string; + timestamp?: string; + created_at?: string; +} + +interface HistoryMessage { + id: string; + timestamp: string; + from: string; + to: string; + thread: string | null; + body: string; +} + +interface RelaycastClientOptions { + agentName: string; + cwd: string; +} + export interface MessagingRelaycastClient { message(id: string): Promise; messages( @@ -50,13 +78,15 @@ export interface MessagingRelaycastClient { export interface MessagingBrokerClient { sendMessage(input: { to: string; text: string; from?: string; threadId?: string }): Promise; + getMessageHistory(): Promise; shutdown(): Promise; } export interface MessagingDependencies { getProjectRoot: () => string; + connectClient: (cwd: string) => MessagingBrokerClient | Promise; createClient: (cwd: string) => MessagingBrokerClient | Promise; - createRelaycastClient: (options: { agentName: string }) => Promise; + createRelaycastClient: (options: RelaycastClientOptions) => Promise; log: (...args: unknown[]) => void; error: (...args: unknown[]) => void; exit: ExitFn; @@ -77,13 +107,45 @@ async function createDefaultClient(cwd: string): Promise } } -async function createDefaultRelaycastClient(options: { - agentName: string; -}): Promise { - const apiKey = process.env.RELAY_API_KEY; - if (!apiKey) { - throw new Error('Relaycast API key not found in RELAY_API_KEY'); +async function connectDefaultClient(cwd: string): Promise { + const client = AgentRelayClient.connect({ cwd }); + return client as unknown as MessagingBrokerClient; +} + +function resolveConfiguredSender(env: NodeJS.ProcessEnv = process.env): string | undefined { + const explicit = env.AGENT_RELAY_SENDER?.trim(); + if (explicit) { + return explicit; } + return undefined; +} + +function hasExplicitRelaycastApiKey(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.RELAY_API_KEY?.trim()); +} + +async function resolveRelaycastApiKey(cwd: string): Promise { + const envKey = process.env.RELAY_API_KEY?.trim(); + if (envKey) { + return envKey; + } + + const client = AgentRelayClient.connect({ cwd }); + try { + const session = await client.getSession(); + const workspaceKey = session.workspace_key?.trim(); + if (workspaceKey) { + return workspaceKey; + } + } finally { + await client.shutdown().catch(() => undefined); + } + + throw new Error('Relaycast API key not found in RELAY_API_KEY or the running broker session'); +} + +async function createDefaultRelaycastClient(options: RelaycastClientOptions): Promise { + const apiKey = await resolveRelaycastApiKey(options.cwd); const baseUrl = process.env.RELAYCAST_BASE_URL ?? 'https://api.relaycast.dev'; const relaycast = new RelayCast({ apiKey, baseUrl }); @@ -97,6 +159,7 @@ async function createDefaultRelaycastClient(options: { function withDefaults(overrides: Partial = {}): MessagingDependencies { return { getProjectRoot: () => getProjectPaths().projectRoot, + connectClient: connectDefaultClient, createClient: createDefaultClient, createRelaycastClient: createDefaultRelaycastClient, log: (...args: unknown[]) => console.log(...args), @@ -106,6 +169,78 @@ function withDefaults(overrides: Partial = {}): Messaging }; } +function normalizeBrokerHistoryMessage(message: BrokerHistoryMessage): HistoryMessage | null { + const timestamp = message.timestamp ?? message.created_at; + const from = message.from?.trim(); + const to = (message.target ?? message.to)?.trim(); + const body = (message.text ?? message.body)?.trim(); + if (!timestamp || !from || !to || !body) { + return null; + } + const parsedTimestamp = Date.parse(timestamp); + if (Number.isNaN(parsedTimestamp)) { + return null; + } + + return { + id: message.id ?? message.event_id ?? `${timestamp}:${from}:${to}`, + timestamp: new Date(parsedTimestamp).toISOString(), + from, + to, + thread: message.thread_id ?? message.threadId ?? null, + body, + }; +} + +function filterHistoryMessages( + messages: HistoryMessage[], + options: { from?: string; to?: string; thread?: string }, + sinceTs: number | null +): HistoryMessage[] { + return messages.filter((message) => { + if (options.from && message.from !== options.from) return false; + if (options.to && message.to !== options.to) return false; + if (options.thread && message.thread !== options.thread) return false; + if (sinceTs && Date.parse(message.timestamp) < sinceTs) return false; + return true; + }); +} + +function formatHistoryBody(body: string): string { + return body.length > 200 ? `${body.slice(0, 197)}...` : body; +} + +function renderHistoryMessages( + deps: MessagingDependencies, + messages: HistoryMessage[], + jsonOutput: boolean +): void { + if (jsonOutput) { + const payload = messages.map((message) => ({ + id: message.id, + ts: Date.parse(message.timestamp), + timestamp: message.timestamp, + from: message.from, + to: message.to, + thread: message.thread, + kind: 'message', + body: message.body, + status: undefined, + })); + deps.log(JSON.stringify(payload, null, 2)); + return; + } + + if (!messages.length) { + deps.log('No messages found.'); + return; + } + + messages.forEach((message) => { + deps.log(`[${message.timestamp}] ${message.from} -> ${message.to}: ${formatHistoryBody(message.body)}`); + }); +} + export function registerMessagingCommands( program: Command, overrides: Partial = {} @@ -117,9 +252,9 @@ export function registerMessagingCommands( .description('Send a message to an agent') .argument('', 'Target agent name (or * for broadcast, #channel for channel)') .argument('', 'Message to send') - .option('--from ', 'Sender name', '__cli_sender__') + .option('--from ', 'Sender name (defaults to the broker local sender when omitted)') .option('--thread ', 'Thread identifier') - .action(async (agent: string, message: string, options: { from: string; thread?: string }) => { + .action(async (agent: string, message: string, options: { from?: string; thread?: string }) => { let client: MessagingBrokerClient; try { client = await deps.createClient(deps.getProjectRoot()); @@ -131,11 +266,12 @@ export function registerMessagingCommands( } try { + const configuredSender = options.from?.trim() || resolveConfiguredSender(); await client.sendMessage({ to: agent, text: message, - from: options.from, - threadId: options.thread, + ...(configuredSender ? { from: configuredSender } : {}), + ...(options.thread ? { threadId: options.thread } : {}), }); deps.log(`Message sent to ${agent}`); } catch (err: any) { @@ -154,7 +290,10 @@ export function registerMessagingCommands( .action(async (messageId: string) => { let relaycast: MessagingRelaycastClient; try { - relaycast = await deps.createRelaycastClient({ agentName: '__cli_read__' }); + relaycast = await deps.createRelaycastClient({ + agentName: '__cli_read__', + cwd: deps.getProjectRoot(), + }); } catch (err: any) { deps.error(`Failed to initialize relaycast client: ${err?.message || String(err)}`); deps.error('Start the broker with `agent-relay up` and try again.'); @@ -196,61 +335,75 @@ export function registerMessagingCommands( json?: boolean; }) => { const limit = Number.parseInt(options.limit ?? '50', 10) || 50; - const sinceTs = parseSince(options.since); - let relaycast: MessagingRelaycastClient; + const sinceTs = parseSince(options.since) ?? null; + const projectRoot = deps.getProjectRoot(); + const hasRelaycastApiKey = hasExplicitRelaycastApiKey(); + let localHistoryError: unknown; + let localBrokerReachable = false; + let localClient: MessagingBrokerClient | undefined; try { - relaycast = await deps.createRelaycastClient({ agentName: '__cli_history__' }); - } catch (err: any) { - deps.error(`Failed to initialize relaycast client: ${err?.message || String(err)}`); - deps.error('Start the broker with `agent-relay up` and try again.'); + localClient = await deps.connectClient(projectRoot); + localBrokerReachable = true; + const rawMessages = await localClient.getMessageHistory(); + const localMessages = rawMessages + .map(normalizeBrokerHistoryMessage) + .filter((message): message is HistoryMessage => message !== null); + const filteredMessages = filterHistoryMessages(localMessages, options, sinceTs).slice(0, limit); + renderHistoryMessages(deps, filteredMessages, Boolean(options.json)); + return; + } catch (err) { + localHistoryError = err; + } finally { + await localClient?.shutdown().catch(() => undefined); + } + + if (!localBrokerReachable && !hasRelaycastApiKey) { + const detail = + localHistoryError instanceof Error ? localHistoryError.message : String(localHistoryError); + deps.error(`Failed to read local broker history: ${detail}`); + deps.error( + 'No Relaycast API key found in RELAY_API_KEY. Start the local broker with `agent-relay up` and retry, or set RELAY_API_KEY to read Relaycast history.' + ); deps.exit(1); return; } try { + const relaycast = await deps.createRelaycastClient({ + agentName: '__cli_history__', + cwd: projectRoot, + }); const channel = options.to?.startsWith('#') ? options.to.slice(1) : 'general'; const rawMessages = await relaycast.messages(channel, { limit: Math.max(limit * 2, 100), }); - - let messages = rawMessages.filter((msg) => { - if (options.from && msg.agent_name !== options.from) return false; - if (sinceTs && Date.parse(msg.created_at) < sinceTs) return false; - return true; - }); - - messages = messages.slice(0, limit); - - if (options.json) { - const payload = messages.map((msg) => ({ - id: msg.id, - ts: Date.parse(msg.created_at), - timestamp: new Date(msg.created_at).toISOString(), - from: msg.agent_name, - to: `#${channel}`, - thread: null, - kind: 'message', - body: msg.text, - status: undefined, - })); - deps.log(JSON.stringify(payload, null, 2)); - return; + const relaycastMessages: HistoryMessage[] = rawMessages.map((msg) => ({ + id: msg.id, + timestamp: new Date(msg.created_at).toISOString(), + from: msg.agent_name, + to: `#${channel}`, + thread: null, + body: msg.text, + })); + const filteredMessages = filterHistoryMessages(relaycastMessages, options, sinceTs).slice(0, limit); + renderHistoryMessages(deps, filteredMessages, Boolean(options.json)); + } catch (err: any) { + const relaycastError = err?.message || String(err); + if (!hasRelaycastApiKey && relaycastError.includes('Relaycast API key not found')) { + deps.error( + 'Relaycast history is unavailable because this broker is running in local-only mode and no RELAY_API_KEY is configured.' + ); + } else { + deps.error(`Failed to fetch relaycast history: ${relaycastError}`); } - - if (!messages.length) { - deps.log('No messages found.'); - return; + if (localHistoryError) { + const detail = + localHistoryError instanceof Error + ? localHistoryError.message + : String(localHistoryError); + deps.error(`Local broker history was unavailable: ${detail}`); } - - messages.forEach((msg) => { - const ts = new Date(msg.created_at).toISOString(); - const body = msg.text.length > 200 ? `${msg.text.slice(0, 197)}...` : msg.text; - deps.log(`[${ts}] ${msg.agent_name} -> #${channel}: ${body}`); - }); - } catch (err: any) { - deps.error(`Failed to fetch history: ${err?.message || String(err)}`); - deps.error('Ensure the broker is running (`agent-relay up`) and try again.'); deps.exit(1); } } @@ -263,7 +416,10 @@ export function registerMessagingCommands( .action(async (options: { json?: boolean }) => { let relaycast: MessagingRelaycastClient; try { - relaycast = await deps.createRelaycastClient({ agentName: '__cli_inbox__' }); + relaycast = await deps.createRelaycastClient({ + agentName: '__cli_inbox__', + cwd: deps.getProjectRoot(), + }); } catch (err: any) { deps.error(`Failed to initialize relaycast client: ${err?.message || String(err)}`); deps.exit(1); diff --git a/src/install-script.test.ts b/src/install-script.test.ts index 3a5914690..57fe6e72e 100644 --- a/src/install-script.test.ts +++ b/src/install-script.test.ts @@ -13,7 +13,56 @@ describe('install.sh', () => { it('prepares the broker binary before running the verification command', () => { expect(installScript).toMatch( - /download_broker_binary\(\)\s*\{[\s\S]*strip_quarantine "\$target_path"[\s\S]*"\$target_path" --help/ + /download_broker_binary\(\)\s*\{[\s\S]*strip_quarantine "\$target_path"[\s\S]*verify_downloaded_executable "\$target_path" "--help" "Downloaded broker binary"/ + ); + }); + + it('installs stable launchers after npm install instead of trusting the npm shim alone', () => { + expect(installScript).toMatch( + /install_npm_launchers\(\)\s*\{[\s\S]*write_launcher "\$BIN_DIR\/agent-relay" "\$cli_path" "node"[\s\S]*is_replaceable_agent_relay_launcher "\$npm_launcher_path"[\s\S]*write_launcher "\$npm_launcher_path" "\$cli_path" "node"/ + ); + }); + + it('resolves the npm global bin dir without calling the noisy check_node logger', () => { + const functionMatch = installScript.match(/get_npm_global_bin_dir\(\)\s*\{([\s\S]*?)\n\}/); + expect(functionMatch?.[1]).toBeTruthy(); + expect(functionMatch?.[1]).toMatch(/has_command node/); + expect(functionMatch?.[1]).toMatch(/has_command npm/); + expect(functionMatch?.[1]).toMatch(/node_major=/); + expect(functionMatch?.[1]).not.toMatch(/check_node/); + }); + + it('replaces stale launcher symlinks and warns if PATH is shadowed by one', () => { + expect(installScript).toMatch( + /is_broken_symlink\(\)\s*\{[\s\S]*write_launcher\(\)\s*\{[\s\S]*Removing stale launcher symlink at \$launcher_path/ + ); + expect(installScript).toMatch( + /verify_installation\(\)\s*\{[\s\S]*A stale agent-relay symlink at \$which_path is shadowing/ + ); + }); + + it('marks managed launchers and keeps standalone binaries under the managed install dir', () => { + expect(installScript).toMatch(/write_launcher\(\)\s*\{[\s\S]*agent-relay-managed-launcher/); + expect(installScript).toMatch( + /install_managed_binary_with_launcher\(\)\s*\{[\s\S]*local managed_dir="\$INSTALL_DIR\/bin"[\s\S]*write_launcher "\$BIN_DIR\/\$launcher_name" "\$managed_path" "binary"/ + ); + expect(installScript).toMatch( + /download_standalone_binary\(\)\s*\{[\s\S]*install_managed_binary_with_launcher "\$target_path" "agent-relay" "agent-relay"/ + ); + }); + + it('does not overwrite an unrelated npm-bin command when installing a stable launcher', () => { + expect(installScript).toMatch( + /install_npm_launchers\(\)\s*\{[\s\S]*Leaving existing agent-relay command at \$npm_launcher_path untouched; using managed launcher at \$BIN_DIR\/agent-relay/ + ); + }); + + it('surfaces a specific macOS verification warning when a downloaded binary is killed', () => { + expect(installScript).toMatch( + /verify_downloaded_executable\(\)\s*\{[\s\S]*"\$OS" = "darwin"[\s\S]*"\$status" -eq 137[\s\S]*LAST_VERIFY_FAILURE_REASON="macos_killed"[\s\S]*killed by macOS during verification/ + ); + expect(installScript).toMatch( + /download_standalone_binary\(\)\s*\{[\s\S]*STANDALONE_BINARY_FAILURE_REASON="macos_killed"[\s\S]*Standalone binary verification failed on macOS\. Falling back to npm\/source/ ); }); }); diff --git a/src/listen_api.rs b/src/listen_api.rs index d8d71adb7..433545d34 100644 --- a/src/listen_api.rs +++ b/src/listen_api.rs @@ -59,6 +59,9 @@ pub enum ListenApiRequest { Threads { reply: tokio::sync::oneshot::Sender>, }, + History { + reply: tokio::sync::oneshot::Sender>, + }, Send { to: String, text: String, @@ -211,6 +214,10 @@ fn listen_api_router_with_auth( routing::post(listen_api_set_model), ) .route("/api/threads", routing::get(listen_api_threads)) + .route( + "/api/history/messages", + routing::get(listen_api_history_messages), + ) .route("/api/events/replay", routing::get(listen_api_replay)) .route("/api/spawned/{name}", routing::delete(listen_api_release)) .route( @@ -609,6 +616,24 @@ async fn listen_api_threads( } } +async fn listen_api_history_messages( + axum::extract::State(state): axum::extract::State, +) -> axum::Json { + let (reply_tx, reply_rx) = tokio::sync::oneshot::channel(); + if state + .tx + .send(ListenApiRequest::History { reply: reply_tx }) + .await + .is_err() + { + return axum::Json(json!({ "messages": [] })); + } + match reply_rx.await { + Ok(Ok(val)) => axum::Json(val), + _ => axum::Json(json!({ "messages": [] })), + } +} + async fn listen_api_release( axum::extract::State(state): axum::extract::State, axum::extract::Path(name): axum::extract::Path, @@ -1575,6 +1600,47 @@ mod auth_tests { list_replier.await.expect("list replier should complete"); } + #[tokio::test] + async fn history_messages_route_returns_broker_history() { + let (router, mut rx) = test_router(Some("secret")); + let history_replier = tokio::spawn(async move { + if let Some(ListenApiRequest::History { reply }) = rx.recv().await { + let _ = reply.send(Ok(json!({ + "messages": [ + { + "event_id": "evt_1", + "from": "Lead", + "target": "WorkerA", + "text": "status?", + "timestamp": "2026-04-10T12:00:00.000Z" + } + ] + }))); + } + }); + + let response = router + .oneshot( + Request::builder() + .uri("/api/history/messages") + .method("GET") + .header("x-api-key", "secret") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["messages"][0]["event_id"], "evt_1"); + assert_eq!(body["messages"][0]["target"], "WorkerA"); + + history_replier + .await + .expect("history replier should complete"); + } + #[tokio::test] async fn spawn_route_forwards_extended_fields() { let (router, mut rx) = test_router(Some("secret")); diff --git a/src/main.rs b/src/main.rs index 9ff4268ef..2c636f8ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1851,6 +1851,11 @@ async fn run_init(cmd: InitCommand, telemetry: TelemetryClient) -> Result<()> { let threads = build_thread_infos(&messages, &self_names); let _ = reply.send(Ok(json!({ "threads": threads }))); } + ListenApiRequest::History { reply } => { + let messages: Vec = + recent_thread_messages.iter().rev().cloned().collect(); + let _ = reply.send(Ok(json!({ "messages": messages }))); + } ListenApiRequest::SendInput { name, data, reply } => { if let Err(err) = workers.send_to_worker( &name, "write_pty", Some(format!("api_{}", Uuid::new_v4().simple())), diff --git a/workflows/relay-fix-workflow.ts b/workflows/relay-fix-workflow.ts new file mode 100644 index 000000000..c6a6d6b9c --- /dev/null +++ b/workflows/relay-fix-workflow.ts @@ -0,0 +1,223 @@ +import { workflow } from '@agent-relay/sdk/workflows'; +import { ClaudeModels, CodexModels } from '@agent-relay/config'; + +await workflow('fix-agent-relay-local-bootstrap-and-messaging') + .description('Diagnose and fix local agent-relay install/bootstrap/messaging failures on macOS, then verify broker startup, worker spawn, and CLI communication in a real repo.') + .pattern('dag') + .channel('wf-relay-fix') + .maxConcurrency(4) + .timeout(3600000) + + .agent('lead', { + cli: 'claude', + preset: 'lead', + role: 'Relay fix lead coordinating diagnosis and acceptance checks', + model: ClaudeModels.SONNET, + retries: 2, + }) + .agent('impl-a', { + cli: 'codex', + preset: 'worker', + role: 'Installer and launcher path implementer', + model: CodexModels.GPT_5_4, + retries: 2, + }) + .agent('impl-b', { + cli: 'codex', + preset: 'worker', + role: 'CLI messaging and local-mode behavior implementer', + model: CodexModels.GPT_5_4, + retries: 2, + }) + .agent('reviewer', { + cli: 'claude', + preset: 'reviewer', + role: 'Verification reviewer for fixes and regressions', + model: ClaudeModels.SONNET, + retries: 2, + }) + + .step('capture-current-failures', { + type: 'deterministic', + command: ` + set -e + cd ~/Projects/AgentWorkforce/relay + echo '## PATH' > /tmp/relay-fix-baseline.txt + printf '%s\n' "$PATH" >> /tmp/relay-fix-baseline.txt + echo '\n## which/type agent-relay' >> /tmp/relay-fix-baseline.txt + (which -a agent-relay || true) >> /tmp/relay-fix-baseline.txt 2>&1 + (type -a agent-relay || true) >> /tmp/relay-fix-baseline.txt 2>&1 + echo '\n## installer smoke' >> /tmp/relay-fix-baseline.txt + (bash install.sh || true) >> /tmp/relay-fix-baseline.txt 2>&1 + echo '\n## local launcher smoke' >> /tmp/relay-fix-baseline.txt + (env PATH="$HOME/.local/bin:$PATH" agent-relay --version || true) >> /tmp/relay-fix-baseline.txt 2>&1 + cat /tmp/relay-fix-baseline.txt + `, + captureOutput: true, + failOnError: false, + }) + + .step('plan-fixes', { + agent: 'lead', + dependsOn: ['capture-current-failures'], + task: `Analyze the current local agent-relay failures and produce a concrete repair plan. + +Context: +{{steps.capture-current-failures.output}} + +Focus on these known issues: +- broken runtime-manager shim shadowing the real CLI +- missing or stale local launcher after install.sh +- standalone binary verification failing on macOS +- fallback npm install path being unreliable +- local broker messaging issues: \`send\` default sender and \`history\` requiring RELAY_API_KEY + +Output sections: +1. ROOT_CAUSES +2. FILES_TO_EDIT +3. TEST_PLAN +4. RISKS + +End with PLAN_COMPLETE.`, + verification: { type: 'output_contains', value: 'PLAN_COMPLETE' }, + retries: 2, + }) + + .step('fix-installer-and-launcher', { + agent: 'impl-a', + dependsOn: ['plan-fixes'], + task: `Implement the installer/bootstrap fixes in ~/Projects/AgentWorkforce/relay. + +Plan: +{{steps.plan-fixes.output}} + +Requirements: +- ensure install.sh leaves users with a working \`agent-relay\` command on macOS +- handle stale shim/symlink situations safely +- prefer a real launcher in ~/.local/bin when appropriate +- improve install verification and failure messaging when standalone binary validation fails +- do not edit unrelated files + +Write code to disk. Do not just describe changes. +Only edit the files necessary for installer/bootstrap behavior. +End by printing CHANGES_COMPLETE.`, + verification: { type: 'exit_code' }, + retries: 2, + }) + + .step('fix-cli-messaging-local-mode', { + agent: 'impl-b', + dependsOn: ['plan-fixes'], + task: `Implement local broker messaging fixes in ~/Projects/AgentWorkforce/relay. + +Plan: +{{steps.plan-fixes.output}} + +Requirements: +- fix or harden \`agent-relay send\` in local broker mode so default sender behavior does not break message delivery +- fix or clarify \`agent-relay history\` behavior in local mode when RELAY_API_KEY is absent +- prefer code fixes over docs-only work if behavior is incorrect +- do not edit unrelated files + +Write code to disk. Do not just describe changes. +Only edit files necessary for local messaging/history behavior. +End by printing CHANGES_COMPLETE.`, + verification: { type: 'exit_code' }, + retries: 2, + }) + + .step('verify-files-changed', { + type: 'deterministic', + dependsOn: ['fix-installer-and-launcher', 'fix-cli-messaging-local-mode'], + command: ` + set -e + cd ~/Projects/AgentWorkforce/relay + if git diff --quiet; then + echo 'NO_CHANGES_DETECTED' + exit 1 + fi + git diff --stat + `, + captureOutput: true, + failOnError: true, + }) + + .step('rebuild-relay', { + type: 'deterministic', + dependsOn: ['verify-files-changed'], + command: ` + set -e + cd ~/Projects/AgentWorkforce/relay + npm install + npm run build + `, + failOnError: true, + }) + + .step('smoke-test-local-launcher', { + type: 'deterministic', + dependsOn: ['rebuild-relay'], + command: ` + set -e + cd ~/Projects/AgentWorkforce/relay + bash install.sh || true + env PATH="$HOME/.local/bin:$PATH" agent-relay --version + `, + captureOutput: true, + failOnError: true, + }) + + .step('integration-test-sage', { + type: 'deterministic', + dependsOn: ['smoke-test-local-launcher'], + command: ` + set -e + cd ~/Projects/AgentWorkforce/sage + env PATH="$HOME/.local/bin:$PATH" agent-relay down --force --timeout 5000 >/dev/null 2>&1 || true + env PATH="$HOME/.local/bin:$PATH" agent-relay up --no-dashboard --verbose >/tmp/sage-relay-workflow.log 2>&1 & + BROKER_PID=$! + sleep 5 + env PATH="$HOME/.local/bin:$PATH" agent-relay status + env PATH="$HOME/.local/bin:$PATH" agent-relay spawn WorkflowProbe claude "Reply with exactly: ACK from WorkflowProbe. Then wait for another message." + sleep 5 + env PATH="$HOME/.local/bin:$PATH" agent-relay who + env PATH="$HOME/.local/bin:$PATH" agent-relay send WorkflowProbe "Reply with exactly: SECOND ACK from WorkflowProbe." --from Miya + sleep 5 + env PATH="$HOME/.local/bin:$PATH" agent-relay agents:logs WorkflowProbe | tail -n 120 + env PATH="$HOME/.local/bin:$PATH" agent-relay release WorkflowProbe || true + env PATH="$HOME/.local/bin:$PATH" agent-relay down --force --timeout 5000 || true + wait $BROKER_PID || true + `, + captureOutput: true, + failOnError: false, + }) + + .step('review-results', { + agent: 'reviewer', + dependsOn: ['plan-fixes', 'verify-files-changed', 'smoke-test-local-launcher', 'integration-test-sage'], + task: `Review the relay fixes and test evidence. + +Plan: +{{steps.plan-fixes.output}} + +Changed files evidence: +{{steps.verify-files-changed.output}} + +Launcher smoke test: +{{steps.smoke-test-local-launcher.output}} + +Sage integration test: +{{steps.integration-test-sage.output}} + +Produce: +1. PASS_FAIL verdict +2. what is fixed +3. what still fails, if anything +4. precise follow-up recommendations + +End with REVIEW_COMPLETE.`, + verification: { type: 'output_contains', value: 'REVIEW_COMPLETE' }, + retries: 2, + }) + + .run({ cwd: process.cwd() });