diff --git a/hive.gemspec b/hive.gemspec index d1e8c7cf4..b7db6e7a3 100644 --- a/hive.gemspec +++ b/hive.gemspec @@ -29,12 +29,17 @@ Gem::Specification.new do |spec| # Only the files the runtime actually needs. Excludes tests, brainstorms, # plans, dev-only docs, packaging templates, and the e2e harness binary. + # `lib/hive/scripts/**/*.sh` ships the shell scripts the runtime resolves + # via `File.expand_path("scripts/…", __dir__)` (the interactive claude + # wrapper and the Stop hook) — without this glob a clean gem install hits + # `bash ` on every tmux claude launch. spec.files = Dir[ "bin/hive", "bin/hive-babysitter-stub-gh", "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 742bbdc50..7d6237d1d 100644 --- a/lib/hive/claude_launcher.rb +++ b/lib/hive/claude_launcher.rb @@ -31,21 +31,24 @@ 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 (2026-08), which renders the caret line as `❯` + NBSP + # and ends the input box with separator / caret / separator / footer. # # 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 - # 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. + # at line end), the WHITESPACE after the caret (ASCII space → NBSP), and + # what renders BELOW it (a hint footer, or separator/footer in 2.1.179). + # 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 — (b) accept + # Unicode space separators (`\p{Zs}`) after the caret, and (c) scan a + # version-tolerant window of the last 10 nonblank lines of the current + # prompt region rather than a fixed offset from the pane tail. # # Copy strings still gate readiness in two places, both version-coupled # and both to update when Claude Code changes them: the positive @@ -61,13 +64,19 @@ module Hive # 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 + # idle prompt, so it is intentionally not matched. `\p{Zs}` (Unicode + # space separators) covers ASCII space, NBSP (U+00A0, which Claude Code + # 2.1.179 renders after the caret instead of an ASCII space), and other + # space separators; Ruby's `\s` is ASCII-only and would miss NBSP. + CLAUDE_READY_PROMPT_LINE = /\A❯(?:\p{Zs}|\z)|\p{Zs}❯\z/.freeze + CLAUDE_MENU_OPTION_LINE = /\A❯\p{Zs}*\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 + # Nonblank lines at the bottom of the input box to inspect for the idle + # caret. Claude Code 2.1.179 ends the input box with + # separator / caret / separator / footer, so the caret can sit several + # lines above the pane tail; 10 lines covers that layout (and prior + # layouts) without reaching back into Claude's streamed output. + CLAUDE_PROMPT_TAIL_LINES = 10 # 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 @@ -609,33 +618,42 @@ module Hive def claude_ready_prompt?(pane) current_text = current_prompt_text(pane) - current_lines = current_text.each_line.map(&:strip).reject(&:empty?) + current_lines = current_prompt_lines(pane) return false if CLAUDE_TRUST_PROMPT_MARKERS.all? { |marker| current_text.include?(marker) } return false if current_text.include?(CLAUDE_PERMISSION_PROMPT_MARKER) 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. + # The idle caret sits at the bottom of the input box, but 2.1.179 puts + # a separator + footer beneath it, so it is no longer the last line. + # Scan the version-tolerant tail window (rather than the whole region) + # so a caret Claude printed earlier in its own streamed output doesn't + # read 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| line.match?(CLAUDE_READY_PROMPT_LINE) && !line.match?(CLAUDE_MENU_OPTION_LINE) end end - def current_prompt_text(pane) + # Nonblank lines of the current prompt region — everything after the last + # "Claude Code" banner or the last blank line, whichever is lower — left + # uncapped. `current_prompt_text` narrows this to the trust/permission + # context, while `claude_ready_prompt?` needs the wider window to reach a + # caret that a 2.1.179 separator/footer has pushed several lines up from + # the pane tail. + def current_prompt_lines(pane) raw_lines = pane.each_line.map(&:strip) last_blank_index = raw_lines.rindex("") last_banner_index = raw_lines.rindex { |line| line.include?(CLAUDE_READY_BANNER_MARKER) } last_blank_start = last_blank_index ? last_blank_index + 1 : nil 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") + (raw_lines[start_index..] || []).reject(&:empty?) + end + + def current_prompt_text(pane) + current_prompt_lines(pane).last(CLAUDE_PROMPT_CONTEXT_LINES).join("\n") end def wait_for_terminal_marker(task, runner, timeout) 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 000000000..2f4bd0b4b --- /dev/null +++ b/test/fixtures/panes/claude_ready_2_1_179.txt @@ -0,0 +1,8 @@ +▐▛███▜▌ Claude Code v2.1.179 +Fable 5 with high effort · Claude Max +/home/asterio/Dev/hive + +?──────────────────────────────────────────────────────── +❯  +?──────────────────────────────────────────────────────── +⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents diff --git a/test/fixtures/panes/claude_ready_prior.txt b/test/fixtures/panes/claude_ready_prior.txt new file mode 100644 index 000000000..e18606ee0 --- /dev/null +++ b/test/fixtures/panes/claude_ready_prior.txt @@ -0,0 +1,4 @@ +Claude Code v2.1.133 +Tip: try refactor + +❯ Try "refactor " diff --git a/test/unit/claude_launcher_test.rb b/test/unit/claude_launcher_test.rb index e9c71b65c..7f808ef83 100644 --- a/test/unit/claude_launcher_test.rb +++ b/test/unit/claude_launcher_test.rb @@ -576,12 +576,14 @@ 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. + # The idle caret is at the BOTTOM of the input box. A caret followed by + # more non-footer output than the scan window (CLAUDE_PROMPT_TAIL_LINES) + # is stale streamed output, not the live prompt, so it must stay rejected + # even after the window widened from 2 to 10 lines. 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" + output_lines = (1..12).map { |i| "running build step #{i}" } + pane = [ "Claude Code v2.1.133", "", '❯ Try "refactor "' ] + output_lines + pane = pane.join("\n") refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), "a caret with non-footer output below it is not the live idle prompt" @@ -594,6 +596,51 @@ class ClaudeLauncherTest < Minitest::Test assert Hive::ClaudeLauncher.claude_ready_prompt?(pane) end + # Claude Code 2.1.179 renders the idle caret as `❯` + NBSP (U+00A0) and + # ends the input box with separator / caret / separator / footer, so the + # caret sits three nonblank lines above the pane tail. The previous + # two-line ASCII-space detector missed this and timed out every launch. + def test_claude_ready_prompt_accepts_2_1_179_nbsp_caret_with_separator_footer + pane = pane_fixture("claude_ready_2_1_179.txt") + + assert Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "the 2.1.179 NBSP caret above a separator/footer must read as ready" + end + + # Prior builds render the caret with an ASCII space on the last nonblank + # line; the widened detector must not regress that shape. + def test_claude_ready_prompt_accepts_prior_ascii_caret_on_last_line + pane = pane_fixture("claude_ready_prior.txt") + + assert Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "a prior ASCII-space caret on the last line must still read as ready" + end + + def test_claude_ready_prompt_rejects_permission_prompt + pane = "Claude Code v2.1.179\nDo you want to make this edit?\n❯ 1. Yes" + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "a permission prompt must never read as ready" + end + + def test_claude_ready_prompt_rejects_trust_prompt + pane = "Quick safety check\n❯ 1. Yes, I trust this folder\nEnter to confirm" + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "a trust prompt must never read as ready" + end + + # The NBSP after `❯` is exactly what the ready regex now tolerates, so a + # NBSP-separated numbered menu option (`❯\u00A0 1. …`) is the dangerous + # case: without the menu regex also tolerating \p{Zs}, it would read as + # ready. Lock the rejection. + def test_claude_ready_prompt_rejects_nbsp_separated_menu_option + pane = "Claude Code v2.1.179\nWhat do you want to do?\n❯\u00A0 1. Stop and wait for limit to reset\n 2. Add funds\n" + + refute Hive::ClaudeLauncher.claude_ready_prompt?(pane), + "a NBSP-separated numbered menu option must never read as ready" + 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" diff --git a/test/unit/gemspec_test.rb b/test/unit/gemspec_test.rb index c4cd96ddb..8110314f0 100644 --- a/test/unit/gemspec_test.rb +++ b/test/unit/gemspec_test.rb @@ -29,4 +29,20 @@ class GemspecTest < Minitest::Test refute spec.files.any? { |f| f.start_with?("public/") }, "no Sinatra-era static assets should be packaged" end + + # The runtime resolves shell scripts via File.expand_path("scripts/…", + # __dir__) (the interactive claude wrapper and the Stop hook). A gem built + # without them fails every tmux claude launch with `bash `. + def test_gem_package_includes_runtime_shell_scripts + spec = Gem::Specification.load(GEMSPEC_PATH) + root = File.expand_path("../..", __dir__) + scripts = Dir[File.join(root, "lib/hive/scripts/**/*.sh")] + + refute_empty scripts, "lib/hive/scripts/**/*.sh must resolve to the runtime scripts" + scripts.each do |path| + relative = path.delete_prefix("#{root}/") + assert_includes spec.files, relative, + "#{relative} is referenced by the runtime but missing from spec.files" + end + end end diff --git a/test/unit/packaging_guard_test.rb b/test/unit/packaging_guard_test.rb new file mode 100644 index 000000000..1964b1fae --- /dev/null +++ b/test/unit/packaging_guard_test.rb @@ -0,0 +1,59 @@ +require "test_helper" +require "rubygems/package" +require "tmpdir" +require "hive/claude_launcher" +require "hive/stop_hook_installer" + +# Guards the built gem's contents against script-drift: the launcher and the +# stop-hook installer resolve shell scripts via `File.expand_path("scripts/…", +# __dir__)`, so a gem built without `lib/hive/scripts/**/*.sh` fails every +# tmux claude launch with `bash ` (the 0.3.2 clean-install bug). +# +# This test builds the gem exactly as a release build would (Gem::Package.build +# with the loaded spec) into a tmp dir — fully local, no network, no gem +# install — and enumerates the packaged paths. A script added later that is +# forgotten in spec.files fails here instead of at first `hive run`. +class PackagingGuardTest < Minitest::Test + include HiveTestHelper + + GEMSPEC_PATH = File.expand_path("../../hive.gemspec", __dir__) + ROOT = File.expand_path("../..", __dir__) + + def test_built_gem_contains_every_runtime_script + spec = Gem::Specification.load(GEMSPEC_PATH) + scripts = Dir[File.join(ROOT, "lib/hive/scripts/**/*.sh")].sort + refute_empty scripts, "expected runtime scripts under lib/hive/scripts" + + Dir.mktmpdir("hive-gem") do |dir| + gem_path = Gem::Package.build(spec, false, false, File.join(dir, "hive-cli.gem")) + packaged = Gem::Package.new(gem_path).contents + + scripts.each do |script| + relative = script.delete_prefix("#{ROOT}/") + assert_includes packaged, relative, + "#{relative} is missing from the built gem" + end + + # Pin the two scripts the runtime resolves by name today, so a + # rename/reference change is also caught. + assert_includes packaged, "lib/hive/scripts/interactive_claude_wrapper.sh" + assert_includes packaged, "lib/hive/scripts/stop_hook.sh" + end + end + + # The runtime references must stay inside the packaged scripts tree; + # otherwise the tree-enumerating guard above and the runtime disagree on + # where the files live and a missing script is no longer caught. + def test_runtime_script_references_resolve_inside_the_packaged_tree + wrapper_path = Hive::ClaudeLauncher.send( + :wrapper_command, + cwd: "/tmp", add_dirs: [], profile: Hive::AgentProfiles.lookup(:claude), + permission_mode: "bypassPermissions" + ).fetch(1) + + assert_equal File.join(ROOT, "lib/hive/scripts/interactive_claude_wrapper.sh"), + wrapper_path + assert_equal File.join(ROOT, "lib/hive/scripts/stop_hook.sh"), + Hive::StopHookInstaller::HOOK_PATH + end +end