diff --git a/hive.gemspec b/hive.gemspec index d1e8c7cf..e54a3dec 100644 --- a/hive.gemspec +++ b/hive.gemspec @@ -35,6 +35,7 @@ Gem::Specification.new do |spec| "bin/hive-babysitter-stub-git", "bin/hv", "lib/**/*.rb", + "lib/hive/scripts/**/*.sh", "templates/**/*", "schemas/**/*.json", "examples/systemd/*", diff --git a/lib/hive/claude_launcher.rb b/lib/hive/claude_launcher.rb index 742bbdc5..4226d05b 100644 --- a/lib/hive/claude_launcher.rb +++ b/lib/hive/claude_launcher.rb @@ -31,21 +31,25 @@ module Hive CLAUDE_READY_POLL_INTERVAL_SEC = 0.25 MIN_TMUX_VERSION = "3.0" TERMINAL_MARKERS = %i[waiting complete error execute_complete review_complete review_waiting review_error].freeze - # Observed against Claude Code 2.1.133 (2026-05-25 dogfood) and the + # Observed against Claude Code 2.1.133 (2026-05-25 dogfood), the # 2026-05-27 build that moved the input caret to the end of a - # context-prefixed line and added a hint footer beneath it. + # context-prefixed line and added a hint footer beneath it, and + # Claude Code 2.1.179 which inserts a non-breaking space after the + # leading caret and renders separator / caret / separator / footer + # at the bottom of the input box. # # Robustness note: readiness detection keys on the `❯` input caret, the # most stable signal across the Claude Code TUI revisions seen so far. # What churns between releases is the caret's POSITION on its line # (older builds: `❯ Try …` at line start; newer: ` ❯` - # at line end) and what renders BELOW it (e.g. a `⏵⏵ bypass permissions …` - # hint footer, so the caret is no longer the last line). To tolerate that + # at line end), the whitespace after a leading caret (ASCII space vs + # NBSP U+00A0), and what renders BELOW it (hint footers and separator + # rules, so the caret is no longer the last line). To tolerate that # without treating a `❯` Claude prints in its OWN output (shell snippets, # prose, bullets) as ready, we (a) require the caret to be the first or - # last glyph of its line — never mid-prose — and (b) only look at the - # bottom of the input box: the last non-blank line, or the line above it - # when a one-line hint footer renders beneath the caret. + # last glyph of its line — never mid-prose — and (b) only look at a + # bounded window of the current prompt region (last N nonblank lines) + # rather than assuming a fixed footer distance. # # Copy strings still gate readiness in two places, both version-coupled # and both to update when Claude Code changes them: the positive @@ -58,16 +62,29 @@ module Hive CLAUDE_PERMISSION_PROMPT_MARKER = "Do you want to".freeze CLAUDE_READY_BANNER_MARKER = "Claude Code".freeze CLAUDE_READY_FOOTER_MARKER = "for agents".freeze - # The caret as the FIRST glyph (`❯ …`, older builds) or the LAST glyph - # (`… ? ❯`, the line-end caret newer builds render after the cwd/git - # context). A caret embedded mid-line is Claude's own output, not the - # idle prompt, so it is intentionally not matched. - CLAUDE_READY_PROMPT_LINE = /\A❯(?:\s|\z)|\s❯\z/.freeze - CLAUDE_MENU_OPTION_LINE = /\A\s*❯\s*\d+\./.freeze - CLAUDE_PROMPT_CONTEXT_LINES = 4 - # Lines at the bottom of the input box to inspect for the idle caret: - # the caret line itself plus at most one hint footer rendered below it. - CLAUDE_PROMPT_TAIL_LINES = 2 + # ASCII whitespace plus Unicode separator spaces (Zs), including NBSP. + # Ruby's `\s` alone misses U+00A0, which Claude Code 2.1.179 uses after + # the leading caret; `\p{Zs}` alone misses tabs. Combined they cover + # the idle-prompt and numbered-menu whitespace classes we care about. + CLAUDE_PROMPT_WHITESPACE = /[\s\p{Zs}]/.freeze + # The caret as the FIRST glyph (`❯ …`, older builds / 2.1.179 with NBSP) + # or the LAST glyph (`… ? ❯`, the line-end caret newer builds render + # after the cwd/git context). A caret embedded mid-line is Claude's + # own output, not the idle prompt, so it is intentionally not matched. + CLAUDE_READY_PROMPT_LINE = /\A❯(?:#{CLAUDE_PROMPT_WHITESPACE.source}|\z)|#{CLAUDE_PROMPT_WHITESPACE.source}❯\z/.freeze + CLAUDE_MENU_OPTION_LINE = /\A#{CLAUDE_PROMPT_WHITESPACE.source}*❯#{CLAUDE_PROMPT_WHITESPACE.source}*\d+\./.freeze + # Bounded nonblank-line window for both current-state extraction and + # ready-caret candidate selection. Midpoint of the 8–12 range: large + # enough for separator/caret/separator/footer layouts, small enough + # that a caret printed earlier in Claude's own output stays stale. + CLAUDE_PROMPT_SCAN_LINES = 10 + # Shell scripts owned by ClaudeLauncher, paths relative to lib/hive/. + # Runtime path resolution, gemspec unit checks, and built-gem archive + # inspection all enumerate this registry so packaging cannot drift from + # what wrapper_command actually invokes. + LAUNCHER_SCRIPTS = [ + "scripts/interactive_claude_wrapper.sh" + ].freeze # Allowed-tool sets shared by every stage that spawns Claude. Keeping # them as constants means a policy change lands in one place; previous # PRs inlined the string literal across 11 sites and silently drifted @@ -462,13 +479,26 @@ module Hive ENV.fetch("HIVE_TMUX_BIN", "tmux") end + # Resolve a launcher-owned shell script from LAUNCHER_SCRIPTS to an + # absolute path under lib/hive/. Rejects unknown relative paths so the + # registry stays the single source of truth for packaging tests too. + def launcher_script_path(relative_path) + relative = relative_path.to_s + unless LAUNCHER_SCRIPTS.include?(relative) + raise ArgumentError, "unknown launcher script: #{relative.inspect} " \ + "(known: #{LAUNCHER_SCRIPTS.join(', ')})" + end + + File.expand_path(relative, __dir__) + end + def wrapper_command(cwd:, add_dirs:, profile:, permission_mode:, allowed_tools: DEFAULT_ALLOWED_TOOLS, disallowed_tools: nil, cli_flags: [], mcp_config_path: nil, strict_mcp_config: false) command = [ "bash", - File.expand_path("scripts/interactive_claude_wrapper.sh", __dir__), + launcher_script_path("scripts/interactive_claude_wrapper.sh"), "--cwd", cwd ] Array(add_dirs).each { |dir| command.concat([ "--add-dir", dir ]) } @@ -616,13 +646,10 @@ module Hive return false unless pane.include?(CLAUDE_READY_BANNER_MARKER) || current_text.include?(CLAUDE_READY_FOOTER_MARKER) - # The idle caret sits at the bottom of the input box: it is the last - # non-blank line, or the line above it when a one-line hint footer - # renders beneath it. Limiting the scan to those two lines (rather than - # the whole region) keeps a caret Claude printed earlier in its own - # output from reading as ready. A numbered menu option (`❯ 1.`) is an - # interactive selection, not the idle prompt, so it never counts. - current_lines.last(CLAUDE_PROMPT_TAIL_LINES).any? do |line| + # Scan every nonblank line in the bounded current-prompt window for an + # anchored leading or trailing caret. A numbered menu option (`❯ 1.`) + # is an interactive selection, not the idle prompt, so it never counts. + current_lines.any? do |line| line.match?(CLAUDE_READY_PROMPT_LINE) && !line.match?(CLAUDE_MENU_OPTION_LINE) end end @@ -635,7 +662,7 @@ module Hive start_index = [ last_banner_index, last_blank_start, 0 ].compact.max current_lines = raw_lines[start_index..] || [] - current_lines.reject(&:empty?).last(CLAUDE_PROMPT_CONTEXT_LINES).join("\n") + current_lines.reject(&:empty?).last(CLAUDE_PROMPT_SCAN_LINES).join("\n") end def wait_for_terminal_marker(task, runner, timeout) diff --git a/test/fixtures/panes/claude_menu_selection.txt b/test/fixtures/panes/claude_menu_selection.txt new file mode 100644 index 00000000..f8378869 --- /dev/null +++ b/test/fixtures/panes/claude_menu_selection.txt @@ -0,0 +1,5 @@ +Claude Code v2.1.179 +What do you want to do? +❯ 1. Stop and wait for limit to reset + 2. Continue with reduced usage + 3. Switch model diff --git a/test/fixtures/panes/claude_permission_prompt.txt b/test/fixtures/panes/claude_permission_prompt.txt new file mode 100644 index 00000000..285bec36 --- /dev/null +++ b/test/fixtures/panes/claude_permission_prompt.txt @@ -0,0 +1,5 @@ +Claude Code v2.1.179 +Do you want to make this edit to lib/hive/claude_launcher.rb? +❯ 1. Yes + 2. Yes, and don't ask again + 3. No diff --git a/test/fixtures/panes/claude_ready_2_1_179.txt b/test/fixtures/panes/claude_ready_2_1_179.txt new file mode 100644 index 00000000..2505583e --- /dev/null +++ b/test/fixtures/panes/claude_ready_2_1_179.txt @@ -0,0 +1,7 @@ +Claude Code v2.1.179 +Sonnet 4.6 · API Usage Billing + +──────────────────────────────────────────────────────────────────────── +❯ Try "fix lint errors" +──────────────────────────────────────────────────────────────────────── +⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents diff --git a/test/fixtures/panes/claude_ready_previous.txt b/test/fixtures/panes/claude_ready_previous.txt new file mode 100644 index 00000000..e18606ee --- /dev/null +++ b/test/fixtures/panes/claude_ready_previous.txt @@ -0,0 +1,4 @@ +Claude Code v2.1.133 +Tip: try refactor + +❯ Try "refactor " diff --git a/test/fixtures/panes/claude_trust_prompt.txt b/test/fixtures/panes/claude_trust_prompt.txt new file mode 100644 index 00000000..56b4e1ff --- /dev/null +++ b/test/fixtures/panes/claude_trust_prompt.txt @@ -0,0 +1,5 @@ +Quick safety check +Claude needs your permission to access this folder. +❯ 1. Yes, I trust this folder + 2. No +Enter to confirm · Esc to cancel diff --git a/test/integration/gem_package_test.rb b/test/integration/gem_package_test.rb new file mode 100644 index 00000000..41a4a2a0 --- /dev/null +++ b/test/integration/gem_package_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "test_helper" +require "open3" +require "rubygems/package" +require "hive/claude_launcher" + +# Builds the real hive-cli gem into a temporary directory and asserts every +# ClaudeLauncher-owned shell script is present in the archive. This catches +# packaging drift that a loaded gemspec's `spec.files` list alone can miss +# (e.g. ignore rules that only apply at `gem build` time). +class GemPackageTest < Minitest::Test + include HiveTestHelper + + ROOT = File.expand_path("../..", __dir__) + GEMSPEC = File.join(ROOT, "hive.gemspec") + + def test_built_gem_includes_every_claude_launcher_script + registry = Hive::ClaudeLauncher::LAUNCHER_SCRIPTS + refute_empty registry, + "LAUNCHER_SCRIPTS must not be empty or the built-gem guard " \ + "passes vacuously" + + with_tmp_dir do |tmpdir| + gem_path = build_hive_gem(tmpdir) + assert_path_exists gem_path + assert File.size(gem_path).positive?, "built .gem must be nonempty" + + contents = Gem::Package.new(gem_path).contents + missing = missing_launcher_scripts(contents, registry: registry) + + assert_empty missing, + "built gem is missing launcher script(s): #{missing.join(', ')}" + assert_includes contents, "lib/hive/scripts/interactive_claude_wrapper.sh" + end + end + + def test_missing_launcher_scripts_report_full_path_list + missing = missing_launcher_scripts( + [ "lib/hive/claude_launcher.rb" ], + registry: [ + "scripts/interactive_claude_wrapper.sh", + "scripts/extra.sh" + ] + ) + + assert_equal [ + "lib/hive/scripts/interactive_claude_wrapper.sh", + "lib/hive/scripts/extra.sh" + ], missing + end + + private + + def missing_launcher_scripts(contents, registry:) + expected = registry.map { |relative| File.join("lib/hive", relative) } + expected - Array(contents) + end + + def build_hive_gem(tmpdir) + gem_path = File.join(tmpdir, "hive-cli-package-guard.gem") + out, err, status = Open3.capture3( + "gem", "build", GEMSPEC, "--output", gem_path, + chdir: ROOT + ) + assert status.success?, "gem build failed (exit #{status.exitstatus}): #{err}\n#{out}" + gem_path + end +end diff --git a/test/unit/claude_launcher_test.rb b/test/unit/claude_launcher_test.rb index e9c71b65..b7f90c73 100644 --- a/test/unit/claude_launcher_test.rb +++ b/test/unit/claude_launcher_test.rb @@ -576,15 +576,18 @@ class ClaudeLauncherTest < Minitest::Test "a caret embedded mid-line is Claude's own output, not the idle prompt" end - # The idle caret is at the BOTTOM of the input box. A caret with two or - # more non-footer lines below it is stale output, not the live prompt, so - # restricting the scan to the last two lines must exclude it. - def test_claude_ready_prompt_rejects_caret_above_the_input_box_tail - pane = "Claude Code v2.1.133\n\n❯ Try \"refactor \"\n" \ - "running build step 1\nrunning build step 2" + # The scan window is the last CLAUDE_PROMPT_SCAN_LINES nonblank lines of + # the current region. A caret printed more than that many lines above the + # pane tail is stale output, not the live idle prompt. + def test_claude_ready_prompt_rejects_caret_outside_scan_window + steps = (1..(Hive::ClaudeLauncher::CLAUDE_PROMPT_SCAN_LINES + 1)).map do |i| + "running build step #{i}" + end + pane = ([ "Claude Code v2.1.133", "❯ Try \"refactor \"" ] + steps).join("\n") refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), - "a caret with non-footer output below it is not the live idle prompt" + "a caret more than #{Hive::ClaudeLauncher::CLAUDE_PROMPT_SCAN_LINES} nonblank " \ + "lines before the pane tail is stale, not the live idle prompt" end # A bare caret line is a legitimate idle prompt; lock it as intentional. @@ -594,12 +597,58 @@ class ClaudeLauncherTest < Minitest::Test assert Hive::ClaudeLauncher.claude_ready_prompt?(pane) end + # Claude Code 2.1.179: leading caret followed by U+00A0 (NBSP), with + # separator / caret / separator / footer so the caret is not in the final + # two nonblank lines. The fixture pins the literal incident layout. + def test_claude_ready_prompt_accepts_2_1_179_nbsp_fixture + pane = pane_fixture("claude_ready_2_1_179.txt") + caret_line = pane.lines.map(&:strip).find { |line| line.start_with?("❯") } + + assert_includes pane, "\u00A0", + "fixture must contain literal U+00A0; an editor must not " \ + "normalize the incident NBSP to ASCII space" + refute_nil caret_line, "fixture must include a leading-caret prompt line" + assert caret_line.start_with?("❯\u00A0"), + "the 2.1.179 caret line must keep NBSP immediately after ❯" + assert Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "Claude Code 2.1.179 idle prompt with NBSP and multi-line footer must be ready" + end + + def test_claude_ready_prompt_accepts_previous_ready_fixture + pane = pane_fixture("claude_ready_previous.txt") + + assert Hive::ClaudeLauncher.claude_ready_prompt?(pane) + end + + def test_claude_ready_prompt_rejects_permission_trust_and_menu_fixtures + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane_fixture("claude_permission_prompt.txt")), + "active permission prompts must not read as ready" + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane_fixture("claude_trust_prompt.txt")), + "active trust prompts must not read as ready" + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane_fixture("claude_menu_selection.txt")), + "numbered menu selections must not read as ready" + end + + # Numbered menus can also use NBSP after the caret (same TUI whitespace + # class as the idle prompt). The menu exclusion must use that class so an + # NBSP menu cannot be misclassified as ready. + def test_claude_ready_prompt_rejects_numbered_menu_with_nbsp_after_caret + pane = "Claude Code v2.1.179\nProceed with the action?\n❯\u00A01. Yes" + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "NBSP between ❯ and a numbered option is still a menu, not the idle prompt" + end + def test_claude_trust_prompt_matches_observed_folder_trust_prompt pane = "Quick safety check\n❯ 1. Yes, I trust this folder\nEnter to confirm" assert Hive::ClaudeLauncher.claude_trust_prompt?(pane) end + def test_claude_trust_prompt_matches_trust_fixture + assert Hive::ClaudeLauncher.claude_trust_prompt?(pane_fixture("claude_trust_prompt.txt")) + end + def test_spawn_claude_bang_propagates_agent_error_unchanged # `spawn_claude!` no longer catches tmux-unavailable AgentErrors; # the propagation is what lets the review stage's outer rescue @@ -860,6 +909,31 @@ class ClaudeLauncherTest < Minitest::Test assert_match(/terminated before becoming ready/, err.message) end + def test_wrapper_command_resolves_launcher_script_registry + profile = Hive::AgentProfiles.lookup(:claude) + command = Hive::ClaudeLauncher.send( + :wrapper_command, + cwd: "/tmp/project", add_dirs: [], profile: profile, + permission_mode: "bypassPermissions" + ) + expected_wrapper = Hive::ClaudeLauncher.launcher_script_path( + "scripts/interactive_claude_wrapper.sh" + ) + + assert_equal "bash", command.first + assert_equal expected_wrapper, command[1] + assert_path_exists expected_wrapper + assert_equal [ "--cwd", "/tmp/project" ], command[2, 2] + end + + def test_launcher_script_path_rejects_unknown_relative_paths + err = assert_raises(ArgumentError) do + Hive::ClaudeLauncher.launcher_script_path("scripts/not_a_registered.sh") + end + + assert_match(/unknown launcher script/, err.message) + end + def test_wrapper_command_carries_model_and_effort_pins profile = Hive::AgentProfiles.lookup(:claude) command = Hive::ClaudeLauncher.send( diff --git a/test/unit/gemspec_test.rb b/test/unit/gemspec_test.rb index c4cd96dd..a5451044 100644 --- a/test/unit/gemspec_test.rb +++ b/test/unit/gemspec_test.rb @@ -1,4 +1,5 @@ require "test_helper" +require "hive/claude_launcher" class GemspecTest < Minitest::Test GEMSPEC_PATH = File.expand_path("../../hive.gemspec", __dir__) @@ -29,4 +30,22 @@ class GemspecTest < Minitest::Test refute spec.files.any? { |f| f.start_with?("public/") }, "no Sinatra-era static assets should be packaged" end + + # ClaudeLauncher resolves shell helpers from LAUNCHER_SCRIPTS; the gem must + # ship every registered path so a clean install does not `bash` a missing + # file and kill the tmux session before readiness can run. + def test_gem_package_includes_every_claude_launcher_script + spec = Gem::Specification.load(GEMSPEC_PATH) + registry = Hive::ClaudeLauncher::LAUNCHER_SCRIPTS + + refute_empty registry, "LAUNCHER_SCRIPTS must not be empty or packaging " \ + "guards pass vacuously" + assert_includes spec.files, "lib/hive/scripts/interactive_claude_wrapper.sh" + + registry.each do |relative| + archive_path = File.join("lib/hive", relative) + assert_includes spec.files, archive_path, + "spec.files must include launcher script #{archive_path}" + end + end end