Skip to content

Fix cmux claude-teams hook injection gap (#2229 follow-up to #2465) - #2629

Closed
pstanton237 wants to merge 1 commit into
manaflow-ai:mainfrom
pstanton237:issue-2229-claude-teams-hook-inject
Closed

pstanton237 wants to merge 1 commit into
manaflow-ai:mainfrom
pstanton237:issue-2229-claude-teams-hook-inject

Conversation

@pstanton237

@pstanton237 pstanton237 commented Apr 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

cmux claude-teams never injected the Claude Code lifecycle hooks that #2465 set up for the plain claude path, so claude-teams sessions get no Running / Needs Input / Notification updates in the sidebar — breaking the multi-agent workflow that claude-teams exists for. Two users in #2229 confirmed the symptom (including plain text Q&A, ruling out tool-use dedup theories).

This PR mirrors Resources/bin/claude into runClaudeTeams:

  • Inject --settings <HOOKS_JSON> (verbatim duplicate of the bash literal, same intentional bash↔Swift duplication pattern as claudeNodeOptionsRestoreModule)
  • Export CMUX_CLAUDE_HOOK_CMUX_BIN = executablePath so hooks dispatch through this cmux binary instead of PATH
  • Export CMUX_CLAUDE_PID = getpid() before execv so claude-hook session-start can populate agentPIDs["claude_code"]
  • Pass mcp | config | api-key | rc | remote-control through verbatim
  • Honor CMUX_CLAUDE_HOOKS_DISABLED=1 as an opt-out
  • Keep --teammate-mode and --settings injection independent so an explicit --teammate-mode auto|manual doesn't bypass hook setup

Closes #2229. Follow-up to #2465 (which fixed the claude path but not claude-teams).

Coordination with #2515

@lawrencecchen's open #2515 also touches Resources/bin/claude (adding a new StopFailure hook event). Whichever PR lands first, the other will need to update the matching literal — for #2515 that means adding StopFailure to the new claudeHooksJSON literal in cmux.swift, and for this PR it means rebasing onto the new HOOKS_JSON. The diff is mechanical either way and I'm happy to do the rebase whichever direction works best.

Summary by CodeRabbit

  • New Features

    • Added control to disable hook injection in Claude Teams CLI operations
    • Specific subcommands now bypass teammate-mode processing for more direct execution
  • Bug Fixes

    • Improved teammate-mode auto-prepend behavior to only apply when not explicitly specified
    • Enhanced hook handler environment setup for better process tracking and session management

@vercel

vercel Bot commented Apr 6, 2026

Copy link
Copy Markdown

@pstanton237 is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@cubic-dev-ai

cubic-dev-ai Bot commented Apr 6, 2026

Copy link
Copy Markdown

This review could not be run because your cubic account has exceeded the monthly review limit. If you need help restoring access, please contact contact@cubic.dev.

@coderabbitai

coderabbitai Bot commented Apr 6, 2026 •

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0c198289-0479-4fae-bfb3-b377a64845dd

📥 Commits

Reviewing files that changed from the base of the PR and between 966790d and d6a8819.

📒 Files selected for processing (1)
  • CLI/cmux.swift

📝 Walkthrough

Walkthrough

This PR modifies Claude Teams launch configuration to support hook injection for command notifications. Changes include a hooksDisabled parameter to control hook injection via --settings, passthrough logic for specific subcommands, updated teammate-mode handling, and environment variable setup for hook dispatch.

Changes

Cohort / File(s) Summary
Claude Teams Launch Configuration
CLI/cmux.swift
Added hooksDisabled parameter to claudeTeamsLaunchArguments(). Introduced claudeTeamsPassthroughSubcommands list; specific subcommands (mcp, config, api-key, rc, remote-control) now bypass teammate-mode inference and hook injection. Updated teammate-mode logic to auto-prepend --teammate-mode auto only when not explicitly provided. Introduced claudeHooksJSON for inline hook configuration and appended via --settings when enabled. Set CMUX_CLAUDE_HOOK_CMUX_BIN environment variable during configuration to pin hook dispatch to bundled cmux binary. Exported CMUX_CLAUDE_PID (current process PID) before execution to enable hook handlers to map launch PID to agent session state.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 A little rabbit hops with glee,
Hooks now dance in harmony,
Commands flash their notified light,
Teammates work throughout the night,
PID by PID, the path stays true! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: fixing hook injection for claude-teams mode, with specific issue references (#2229, #2465) providing appropriate context.
Description check ✅ Passed The description covers the summary (why the fix is needed), technical implementation details, testing approach (test-first pattern per CLAUDE.md), and addresses coordination considerations with dependent work.
Linked Issues check ✅ Passed The PR fully addresses issue #2229 by injecting lifecycle hooks into claude-teams, exporting required environment variables (CMUX_CLAUDE_HOOK_CMUX_BIN, CMUX_CLAUDE_PID), handling passthrough subcommands, and providing opt-out mechanism.
Out of Scope Changes check ✅ Passed All changes are scoped to fix hook injection in claude-teams: hook setup, environment variable exports, passthrough handling, and teammate-mode decoupling directly address the linked issue requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes issue #2229 by mirroring four hook-injection steps from Resources/bin/claude into Swift's runClaudeTeams: injecting --settings <HOOKS_JSON>, pinning CMUX_CLAUDE_HOOK_CMUX_BIN, exporting CMUX_CLAUDE_PID before execv, and passing through mcp/config/api-key/rc/remote-control subcommands verbatim. The change ensures the sidebar Running/Idle/Needs-Input lifecycle fires correctly in claude-teams mode, which previously bypassed the shell-integration path entirely because execv never received --settings.

  • Logic gap in claudeTeamsLaunchArguments: the guard !claudeTeamsHasExplicitTeammateMode early return exits before the --settings injection block, silently dropping hook injection for any invocation that includes an explicit --teammate-mode flag (e.g. cmux claude-teams --teammate-mode auto --continue). This recreates the exact same sidebar-lifecycle breakage for that path.
  • CMUX_CLAUDE_PID leaks into passthrough subcommands: setenv("CMUX_CLAUDE_PID", ...) is unconditional, so it contaminates the environment of claude mcp, claude config, etc. even though those paths are explicitly passed through with no injection.
  • Test coverage gap: the regression test covers default injection, CMUX_CLAUDE_HOOKS_DISABLED=1 opt-out, and mcp passthrough — but not the explicit --teammate-mode path, leaving that gap undetected.
  • The claudeHooksJSON static literal intentionally duplicates HOOKS_JSON from Resources/bin/claude:207; if the two ever diverge silently (e.g. a new hook event is added to the shell script but not the Swift literal), claude-teams mode will miss that hook with no build-time warning. This is acknowledged in the comment as an accepted trade-off.

Confidence Score: 3/5

Safe to merge for the primary fix path; a logic gap means users who explicitly pass --teammate-mode still lose hook injection

The core fix is correct and well-structured, matching the Resources/bin/claude contract, and the two-commit CI structure follows CLAUDE.md policy. Score reduced from 5 because the claudeTeamsHasExplicitTeammateMode early return inadvertently skips --settings injection — reintroducing the original symptom for any explicit --teammate-mode invocation — and there is no regression test covering that scenario.

CLI/cmux.swift lines 10279–10288 (claudeTeamsLaunchArguments guard logic)

Important Files Changed

Filename Overview
CLI/cmux.swift Adds claudeHooksJSON literal, claudeTeamsPassthroughSubcommands set, claudeTeamsLaunchArguments function, and CMUX_CLAUDE_PID/CMUX_CLAUDE_HOOK_CMUX_BIN env injection to runClaudeTeams; --teammate-mode bypass inadvertently also skips --settings hook injection
tests/test_cli_claude_teams_hook_injection.py New 324-line regression test for claude-teams hook injection; covers default, hooks-disabled opt-out, and mcp-passthrough cases; missing explicit --teammate-mode scenario

Sequence Diagram

sequenceDiagram
    participant User
    participant cmux as cmux CLI
    participant rCT as runClaudeTeams
    participant cTLA as claudeTeamsLaunchArguments
    participant Claude as claude binary (execv)

    User->>cmux: cmux claude-teams [args]
    cmux->>rCT: commandArgs=[args]
    rCT->>rCT: configureClaudeTeamsEnvironment()<br/>(sets CMUX_CLAUDE_HOOK_CMUX_BIN,<br/>TMUX shim, PATH shim, NODE_OPTIONS)
    rCT->>cTLA: commandArgs, hooksDisabled
    alt first arg is mcp/config/api-key/rc/remote-control
        cTLA-->>rCT: return commandArgs verbatim (no injection)
    else user passed --teammate-mode explicitly
        cTLA-->>rCT: return commandArgs verbatim ⚠️ skips --settings too
    else normal path
        cTLA-->>rCT: [--teammate-mode, auto, --settings, HOOKS_JSON, ...args]
    end
    rCT->>rCT: setenv(CMUX_CLAUDE_PID, getpid()) [unconditional]
    rCT->>Claude: execv(launchPath, launchArguments)
    Claude->>cmux: claude-hook session-start (via CMUX_CLAUDE_HOOK_CMUX_BIN)
    cmux-->>Claude: set_agent_pid → populates agentPIDs["claude_code"]
    Claude->>cmux: claude-hook stop / session-end / notification / prompt-submit / pre-tool-use
Loading

Reviews (1): Last reviewed commit: "Fix cmux claude-teams hook injection gap" | Re-trigger Greptile

Comment thread CLI/cmux.swift Outdated
Comment on lines +10279 to +10287
guard !claudeTeamsHasExplicitTeammateMode(commandArgs: commandArgs) else {
return commandArgs
}
return ["--teammate-mode", "auto"] + commandArgs
var result: [String] = ["--teammate-mode", "auto"]
if !hooksDisabled {
result.append(contentsOf: ["--settings", Self.claudeHooksJSON])
}
result.append(contentsOf: commandArgs)
return result

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 --teammate-mode bypass silently skips --settings hook injection

The guard !claudeTeamsHasExplicitTeammateMode early return exits before the --settings injection block, so any call of the form cmux claude-teams --teammate-mode <value> [args] loses all hook injection. This recreates the exact same sidebar-lifecycle breakage that this PR was written to fix — just for the explicit---teammate-mode path.

For example, cmux claude-teams --teammate-mode auto --continue should be equivalent to the default path, but the guard causes it to skip --settings entirely. The guard was intended to prevent double-injecting --teammate-mode auto, but --settings is an independent concern and should not be gated on it.

Consider separating the two guards so --settings is always injected (unless hooks are disabled or the user already passed --settings):

private func claudeTeamsLaunchArguments(
    commandArgs: [String],
    hooksDisabled: Bool
) -> [String] {
    if let first = commandArgs.first,
       Self.claudeTeamsPassthroughSubcommands.contains(first) {
        return commandArgs
    }
    var result: [String] = []
    if !claudeTeamsHasExplicitTeammateMode(commandArgs: commandArgs) {
        result.append(contentsOf: ["--teammate-mode", "auto"])
    }
    let userAlreadyHasSettings = commandArgs.contains("--settings")
    if !hooksDisabled && !userAlreadyHasSettings {
        result.append(contentsOf: ["--settings", Self.claudeHooksJSON])
    }
    result.append(contentsOf: commandArgs)
    return result
}

Comment thread CLI/cmux.swift
//
// Because we execv below, this process's PID becomes the claude
// process PID, so `getpid()` is the correct value to export.
setenv("CMUX_CLAUDE_PID", String(getpid()), 1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CMUX_CLAUDE_PID set unconditionally for passthrough subcommands

setenv("CMUX_CLAUDE_PID", ...) runs even when claudeTeamsLaunchArguments returned the args verbatim for passthrough subcommands (mcp, config, api-key, rc, remote-control). This leaks a cmux-specific env var into claude mcp, claude config, etc. It is harmless today, but it is inconsistent with the verbatim-passthrough intent ("no injection"). Mirroring the passthrough check would keep the two paths aligned:

let launchArguments = claudeTeamsLaunchArguments(commandArgs: commandArgs, hooksDisabled: hooksDisabled)
let isPassthrough: Bool = {
    if let first = commandArgs.first {
        return Self.claudeTeamsPassthroughSubcommands.contains(first)
    }
    return false
}()
if !isPassthrough {
    setenv("CMUX_CLAUDE_PID", String(getpid()), 1)
}

Comment on lines +301 to +312
def main() -> int:
try:
cli_path = resolve_cmux_cli()
except Exception as exc:
print(f"FAIL: {exc}")
return 1

failures: list[str] = []
test_default_injects_settings_and_pid_env(cli_path, failures)
test_hooks_disabled_env_var_skips_injection(cli_path, failures)
test_mcp_subcommand_passthrough_skips_injection(cli_path, failures)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing test for explicit --teammate-mode path

The test suite covers (a) default injection, (b) CMUX_CLAUDE_HOOKS_DISABLED=1, and (c) mcp passthrough — but there is no case for cmux claude-teams --teammate-mode auto [args] or any other explicit --teammate-mode scenario. Given the logic gap noted in claudeTeamsLaunchArguments, adding a test here would immediately surface whether --settings is (or is not) injected when --teammate-mode is already present. Without it, the contract for that path is untested and a future regression would go undetected.

Consider adding to main():

def test_explicit_teammate_mode_still_injects_settings(cli_path: str, failures: list[str]) -> None:
    proc, argv, _, _ = run_claude_teams(
        cli_path, extra_args=["--teammate-mode", "auto", "--version"]
    )
    expect(
        proc.returncode == 0,
        f"explicit-teammate-mode: expected clean exit, got {proc.returncode}: {proc.stderr.strip()}",
        failures,
    )
    expect(
        "--settings" in argv,
        f"explicit-teammate-mode: expected --settings injection even when --teammate-mode is explicit, got {argv}",
        failures,
    )

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/test_cli_claude_teams_hook_injection.py (1)

275-298: Expand passthrough coverage beyond mcp

This only validates one passthrough subcommand. Since the contract includes config, api-key, rc, and remote-control too, consider parameterizing this test to assert all no-injection passthrough cases.

Refactor sketch
-def test_mcp_subcommand_passthrough_skips_injection(cli_path: str, failures: list[str]) -> None:
-    # `mcp` is a claude subcommand that doesn't accept --settings; mirroring
-    # bin/claude:166-168, it must be passed through verbatim with no injection.
-    proc, argv, _, _ = run_claude_teams(cli_path, extra_args=["mcp", "--help"])
+def test_passthrough_subcommands_skip_injection(cli_path: str, failures: list[str]) -> None:
+    for subcommand in ["mcp", "config", "api-key", "rc", "remote-control"]:
+        proc, argv, _, _ = run_claude_teams(cli_path, extra_args=[subcommand, "--help"])
+        expect(
+            proc.returncode == 0,
+            f"{subcommand} passthrough: expected clean exit, got {proc.returncode}: {proc.stderr.strip()}",
+            failures,
+        )
+        expect(
+            "--settings" not in argv,
+            f"{subcommand} passthrough: expected NO --settings injection, got {argv}",
+            failures,
+        )
+        expect(
+            "--teammate-mode" not in argv,
+            f"{subcommand} passthrough: expected NO --teammate-mode injection, got {argv}",
+            failures,
+        )
+        expect(
+            argv == [subcommand, "--help"],
+            f"{subcommand} passthrough: expected verbatim argv, got {argv}",
+            failures,
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_cli_claude_teams_hook_injection.py` around lines 275 - 298, The
test test_mcp_subcommand_passthrough_skips_injection only asserts passthrough
for "mcp"; update it to iterate over the full passthrough set
["mcp","config","api-key","rc","remote-control"] (or a named constant) and run
run_claude_teams for each subcommand, then assert proc.returncode==0 and that
"--settings" and "--teammate-mode" are not in argv and argv equals [subcommand,
"--help"] for each case; keep the same failure messages but include the current
subcommand in them so failures are identifiable, and reuse the existing
argv/proc/expect logic inside the loop.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CLI/cmux.swift`:
- Around line 10268-10288: The function claudeTeamsLaunchArguments incorrectly
returns early when claudeTeamsHasExplicitTeammateMode(commandArgs:) is true,
which prevents adding the hooks settings; instead, update
claudeTeamsLaunchArguments to not return immediately on explicit --teammate-mode
but still append "--settings", Self.claudeHooksJSON when hooksDisabled is false
(and preserve passthrough behavior for Self.claudeTeamsPassthroughSubcommands).
Concretely: remove the early return path that returns commandArgs when
claudeTeamsHasExplicitTeammateMode(...) is true and ensure the code builds
result by respecting explicit teammate-mode flags in commandArgs while still
conditionally injecting ["--settings", Self.claudeHooksJSON] unless
hooksDisabled; apply the same change to the other analogous block (the duplicate
at the other location that uses the same helper).

In `@tests/test_cli_claude_teams_hook_injection.py`:
- Around line 55-65: parse_settings_arg currently returns any JSON value which
can be a non-dict and later cause AttributeError in
assert_hook_settings_structure; change parse_settings_arg to validate the parsed
value is a dict (mapping) and return {} for any non-dict or on JSON errors so
the failure aggregator can record a failure instead of crashing; update the
function (parse_settings_arg) to: after json.loads(argv[index + 1]) check
isinstance(result, dict) (or mapping) and return result only if true, otherwise
return {}.

---

Nitpick comments:
In `@tests/test_cli_claude_teams_hook_injection.py`:
- Around line 275-298: The test test_mcp_subcommand_passthrough_skips_injection
only asserts passthrough for "mcp"; update it to iterate over the full
passthrough set ["mcp","config","api-key","rc","remote-control"] (or a named
constant) and run run_claude_teams for each subcommand, then assert
proc.returncode==0 and that "--settings" and "--teammate-mode" are not in argv
and argv equals [subcommand, "--help"] for each case; keep the same failure
messages but include the current subcommand in them so failures are
identifiable, and reuse the existing argv/proc/expect logic inside the loop.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7adb57d8-49d2-4eba-93b5-ee31a658cc47

📥 Commits

Reviewing files that changed from the base of the PR and between 179b16c and 9b02b6e.

📒 Files selected for processing (2)
  • CLI/cmux.swift
  • tests/test_cli_claude_teams_hook_injection.py

Comment thread CLI/cmux.swift
Comment on lines +55 to +65
def parse_settings_arg(argv: list[str]) -> dict:
if "--settings" not in argv:
return {}
index = argv.index("--settings")
if index + 1 >= len(argv):
return {}
try:
return json.loads(argv[index + 1])
except json.JSONDecodeError:
return {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard non-object --settings JSON to avoid crashing the failure aggregator

If json.loads(...) returns a non-dict (valid JSON array/string), assert_hook_settings_structure on Line 243 will raise AttributeError instead of appending a failure.

Proposed fix
-def parse_settings_arg(argv: list[str]) -> dict:
+def parse_settings_arg(argv: list[str]) -> dict:
     if "--settings" not in argv:
         return {}
     index = argv.index("--settings")
     if index + 1 >= len(argv):
         return {}
     try:
-        return json.loads(argv[index + 1])
+        parsed = json.loads(argv[index + 1])
+        return parsed if isinstance(parsed, dict) else {}
     except json.JSONDecodeError:
         return {}

Also applies to: 236-243

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_cli_claude_teams_hook_injection.py` around lines 55 - 65,
parse_settings_arg currently returns any JSON value which can be a non-dict and
later cause AttributeError in assert_hook_settings_structure; change
parse_settings_arg to validate the parsed value is a dict (mapping) and return
{} for any non-dict or on JSON errors so the failure aggregator can record a
failure instead of crashing; update the function (parse_settings_arg) to: after
json.loads(argv[index + 1]) check isinstance(result, dict) (or mapping) and
return result only if true, otherwise return {}.

@coderabbitai

coderabbitai Bot commented Apr 6, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@pstanton237 — The decoupled-branch design in 966790d5 is exactly right. The behavior matrix covers all the cases the P1 exposed, and the two-commit structure (red test → green fix) is clean. A few quick acknowledgements on the remaining items:

CMUX_CLAUDE_PID unconditional on passthrough — The mirroring-the-bash-wrapper rationale is solid. Resources/bin/claude:188 exports it before the subcommand case, so the unconditional setenv is the faithful translation. No change needed; leaving as-is is the correct call.

json.loads non-dict guard — Acknowledged. For a hand-written object literal, an AttributeError surfacing at the test runner level is a self-consistent failure signal. The nit stands as a minor style preference only; no objection to leaving it.

The fix looks good to me. I'll record the decoupled-branch invariant for future reviews.


✏️ Learnings added
Learnt from: pstanton237
URL: https://github.com/manaflow-ai/cmux/pull/2629

Timestamp: 2026-04-06T12:03:16.899Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift, `claudeTeamsLaunchArguments` must use two independent branches: (1) prepend `--teammate-mode auto` only when the caller has NOT already supplied `--teammate-mode`; (2) append `--settings <claudeHooksJSON>` unconditionally (modulo `CMUX_CLAUDE_HOOKS_DISABLED=1` opt-out). These two branches must never be coupled in a single early-return or conditional block — coupling them was the original `#2229` regression (hook JSON silently bypassed when `--teammate-mode auto|manual` was explicit). Mirrors `Resources/bin/claude:208`: `exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@"` where `$@` content never gates hook injection.

Learnt from: pstanton237
URL: https://github.com/manaflow-ai/cmux/pull/2629

Timestamp: 2026-04-06T12:03:16.899Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift `runClaudeTeams`, `setenv("CMUX_CLAUDE_PID", ...)` is set unconditionally before the passthrough-subcommand check, mirroring `Resources/bin/claude:188` which exports `CMUX_CLAUDE_PID=$$` before the subcommand `case` statement. This is intentional design: the env var is dead for `mcp`/`config`/`api-key`/`rc`/`remote-control` paths but harmless, and matching the bash wrapper unconditional export exactly is a deliberate design principle for the claude-teams hook injection feature.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: Horacehxw
Repo: manaflow-ai/cmux PR: 2043
File: Resources/bin/claude:0-0
Timestamp: 2026-04-03T07:49:14.139Z
Learning: Repo: manaflow-ai/cmux — In Resources/bin/claude, SELF_DIR is validated with a broad regex `[\"\\' $\`!;|&<>()*?\[\]]` before interpolating it into HOOKS_JSON command strings. Paths that match (unsafe chars including spaces and shell metacharacters) cause the wrapper to fall back to BASE_HOOKS_JSON and emit a warning. This guard-and-fallback is the intentional design; do not suggest adding shell quoting inside the JSON command strings — the guard approach is preferred.

Learnt from: apollow
Repo: manaflow-ai/cmux PR: 1089
File: CLI/cmux.swift:462-499
Timestamp: 2026-03-09T02:08:54.956Z
Learning: Repo: manaflow-ai/cmux
PR: `#1089`
File: CLI/cmux.swift
Component: ClaudeHookTagExtractor.extractTags(subtitle:body:)
Learning: For Claude Code session tag extraction, pre-redact sensitive spans (UUIDs, emails, access tokens, filesystem paths, ENV_VAR=..., long numerics) across the combined body+subtitle using unanchored sensitiveSpanPatterns before tokenization. Then tokenize and still filter each token with anchored sensitivePatterns. Rationale: prevents PII/path fragments from slipping into searchable tags after delimiter splitting.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2514
File: CLI/cmux.swift:9698-9706
Timestamp: 2026-04-01T23:08:19.526Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift (CMUXCLI.runClaudeTeams), custom Claude path candidates (CMUX_CUSTOM_CLAUDE_PATH and UserDefaults key "claudeCodeCustomClaudePath") now reject directory-valued paths by checking isDirectory == false before isExecutableFile, aligning with the Resources/bin/claude wrapper’s [[ -f && -x ]] validation and ensuring fallback to PATH/bundled.

Learnt from: lawrencecchen
Repo: manaflow-ai/cmux PR: 2514
File: CLI/cmux.swift:9691-9711
Timestamp: 2026-04-01T22:58:26.254Z
Learning: Repo: manaflow-ai/cmux — In CLI/cmux.swift (runClaudeTeams), custom Claude path resolution now trims whitespace and rejects paths that point to the cmux wrapper using isCmuxClaudeWrapper(), before falling back to PATH/bundled. The Resources/bin/claude wrapper also resolves the real path and compares against itself, requiring -f/-x to avoid recursion/self-reference.

Learnt from: Horacehxw
Repo: manaflow-ai/cmux PR: 2492
File: CLI/cmux.swift:319-321
Timestamp: 2026-04-06T09:45:30.721Z
Learning: Repo: manaflow-ai/cmux — Claude hook stop classification precedence: in CLI/cmux.swift, summarizeClaudeHookStop should detect failures by checking (1) hook_event_name == "StopFailure" or (2) presence of the "error" field; only then fall back to stop_reason != "end_turn" for back-compat. The notification subtitle/body should use String(localized:) with keys claude.hook.stop.verb.stopped/completed and claude.hook.stop.state.stopped/completed.

Learnt from: lejahmie
Repo: manaflow-ai/cmux PR: 2371
File: cmuxTests/CJKIMEInputTests.swift:1325-1335
Timestamp: 2026-03-31T10:56:43.241Z
Learning: Repo: manaflow-ai/cmux — In `cmuxTests/CJKIMEInputTests.swift`, `GhosttyOptionDeleteRegressionTests.testRightOptionLiteralCharacterSetsAltRightInRawModsFallbackPath` intentionally omits `consumed_mods & GHOSTTY_MODS_ALT_RIGHT` assertions. That test simulates the fallback path (no `NX_DEVICERALTKEYMASK` bit in event flags; right-side state injected via `debugSetRightOptionModifierDownForUITest(true)`), where `consumed_mods` depends on translation-mod flags and is unstable in synthetic test contexts. Only `mods & (ALT | ALT_RIGHT)` is asserted here. The `consumed_mods` coverage is provided by the deterministic `testRightOptionDeleteSetsAltRightModifier` test, which uses the direct `NX_DEVICERALTKEYMASK` modifier flag path.

@greptile-apps

greptile-apps Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

Perfect fix — the split into two independent branches (auto-prepend vs. settings injection) is exactly right, and the test coverage is comprehensive.

On the P2 (CMUX_CLAUDE_PID on passthrough): Your reasoning is solid. Mirroring Resources/bin/claude exactly is the right call here, and the dead env var on passthrough paths is harmless. Leave it as-is.

On the nit (json.loads guard): Agreed — a malformed HOOKS_JSON literal is itself a regression, and the test should fail loudly either way. The guard doesn't add meaningful protection. Leave it as-is.

The two-commit structure is clean, the behavior matrix is clear, and the expanded passthrough tests are a good catch for future-proofing. This looks ready to merge.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules and .greptile/config.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
tests/test_cli_claude_teams_hook_injection.py (1)

55-65: ⚠️ Potential issue | 🟡 Minor

Guard parse_settings_arg against non-object JSON.

A valid non-object JSON value (array/string) from Line 62 can make assert_hook_settings_structure crash at Line 135 (settings.get(...)) instead of recording a failure.

Proposed fix
 def parse_settings_arg(argv: list[str]) -> dict:
     if "--settings" not in argv:
         return {}
     index = argv.index("--settings")
     if index + 1 >= len(argv):
         return {}
     try:
-        return json.loads(argv[index + 1])
+        parsed = json.loads(argv[index + 1])
+        return parsed if isinstance(parsed, dict) else {}
     except json.JSONDecodeError:
         return {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_cli_claude_teams_hook_injection.py` around lines 55 - 65, The
parse_settings_arg function can return non-object JSON (e.g., list or string)
which later causes assert_hook_settings_structure to crash when calling
settings.get; change parse_settings_arg to validate the parsed JSON is a
mapping/dict before returning it (i.e., after json.loads(argv[index + 1]) ensure
isinstance(result, dict) and otherwise return {}), so only object-shaped
settings propagate to the rest of the test.
🧹 Nitpick comments (1)
tests/test_cli_claude_teams_hook_injection.py (1)

275-303: Also assert passthrough keeps CMUX_CLAUDE_PID/CMUX_CLAUDE_HOOK_CMUX_BIN populated.

This test already validates argv passthrough well; adding env assertions here would lock in the intentional unconditional export behavior too.

Suggested test extension
-        proc, argv, _, _ = run_claude_teams(cli_path, extra_args=[subcommand, "--help"])
+        proc, argv, pid_value, hook_cmux_bin_value = run_claude_teams(
+            cli_path, extra_args=[subcommand, "--help"]
+        )
         prefix = f"{subcommand} passthrough"
@@
         expect(
             argv == [subcommand, "--help"],
             f"{prefix}: expected verbatim argv, got {argv}",
             failures,
         )
+        expect(
+            pid_value not in {"", "__UNSET__"},
+            f"{prefix}: expected CMUX_CLAUDE_PID to be set, got {pid_value!r}",
+            failures,
+        )
+        expect(
+            hook_cmux_bin_value not in {"", "__UNSET__"},
+            f"{prefix}: expected CMUX_CLAUDE_HOOK_CMUX_BIN to be set, got {hook_cmux_bin_value!r}",
+            failures,
+        )

Based on learnings: in CLI/cmux.swift runClaudeTeams, setenv("CMUX_CLAUDE_PID", ...) is intentionally unconditional before passthrough-subcommand handling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_cli_claude_teams_hook_injection.py` around lines 275 - 303, Extend
test_passthrough_subcommands_skip_injection to also assert that the environment
exported for passthrough contains non-empty CMUX_CLAUDE_PID and
CMUX_CLAUDE_HOOK_CMUX_BIN: after calling run_claude_teams (the existing call
that returns proc, argv, env, ...), add expects that env contains
"CMUX_CLAUDE_PID" and "CMUX_CLAUDE_HOOK_CMUX_BIN" and that both values are
truthy/non-empty strings; reference the test function name
test_passthrough_subcommands_skip_injection and the run_claude_teams helper, and
ensure these env asserts are done for each subcommand in the
passthrough_subcommands loop.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@tests/test_cli_claude_teams_hook_injection.py`:
- Around line 55-65: The parse_settings_arg function can return non-object JSON
(e.g., list or string) which later causes assert_hook_settings_structure to
crash when calling settings.get; change parse_settings_arg to validate the
parsed JSON is a mapping/dict before returning it (i.e., after
json.loads(argv[index + 1]) ensure isinstance(result, dict) and otherwise return
{}), so only object-shaped settings propagate to the rest of the test.

---

Nitpick comments:
In `@tests/test_cli_claude_teams_hook_injection.py`:
- Around line 275-303: Extend test_passthrough_subcommands_skip_injection to
also assert that the environment exported for passthrough contains non-empty
CMUX_CLAUDE_PID and CMUX_CLAUDE_HOOK_CMUX_BIN: after calling run_claude_teams
(the existing call that returns proc, argv, env, ...), add expects that env
contains "CMUX_CLAUDE_PID" and "CMUX_CLAUDE_HOOK_CMUX_BIN" and that both values
are truthy/non-empty strings; reference the test function name
test_passthrough_subcommands_skip_injection and the run_claude_teams helper, and
ensure these env asserts are done for each subcommand in the
passthrough_subcommands loop.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e6749c0d-cce6-496f-8029-d4012c107784

📥 Commits

Reviewing files that changed from the base of the PR and between 9b02b6e and 966790d.

📒 Files selected for processing (2)
  • CLI/cmux.swift
  • tests/test_cli_claude_teams_hook_injection.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • CLI/cmux.swift

Fixes #2229. Follow-up to #2465.

The plain `claude` path picks up `--settings` hook JSON via the shell
wrapper (Resources/bin/claude:208), but `cmux claude-teams` bypasses
the shell entirely — it execv's straight into the real claude binary
from runClaudeTeams — so it never picked up the same hook injection.
Result: in claude-teams mode the sidebar Running / Needs Input /
Notification lifecycle never fires, breaking the multi-agent workflow
that claude-teams exists for. Confirmed by two reporters in #2229.

Mirror the four things Resources/bin/claude does into runClaudeTeams:

- Inject `--settings <HOOKS_JSON>` as a verbatim duplicate of the bash
  HOOKS_JSON literal (same intentional bash<->Swift duplication
  pattern used for claudeNodeOptionsRestoreModule).
- Export CMUX_CLAUDE_HOOK_CMUX_BIN = executablePath in
  configureClaudeTeamsEnvironment so hooks dispatch through this
  bundled cmux binary instead of whatever's on PATH (mise shims,
  stale installs). Mirrors Resources/bin/claude:189.
- Export CMUX_CLAUDE_PID = getpid() right before execv in
  runClaudeTeams. Because we execv into claude, the current process
  PID becomes claude's PID, matching `export CMUX_CLAUDE_PID=$$` in
  Resources/bin/claude:188. Without this, `cmux claude-hook
  session-start` reads nil and skips set_agent_pid claude_code,
  leaving workspace.agentPIDs["claude_code"] unpopulated — which
  GhosttyTerminalView.swift's Running indicator gates on.
- Pass `mcp | config | api-key | rc | remote-control` through
  verbatim (mirrors Resources/bin/claude:166-168) since these
  subcommands don't accept --settings or --teammate-mode.

`--teammate-mode` and `--settings` injection are kept independent so
an explicit `--teammate-mode auto|manual` does not silently disable
hook injection. `--teammate-mode auto` is auto-prepended only when the
user didn't pass an explicit mode. Honors CMUX_CLAUDE_HOOKS_DISABLED=1
as an opt-out (e.g. Settings > Automation > Claude Code Integration
toggled off).
@pstanton237
pstanton237 force-pushed the issue-2229-claude-teams-hook-inject branch from 966790d to d6a8819 Compare April 6, 2026 12:23
@pstanton237

Copy link
Copy Markdown
Contributor Author

Closing in favor of #2631 — same fix, fresh thread without the squash/force-push history. The new PR is a single clean commit with no test infrastructure (per cmux's test quality policy on tests that don't gate CI). See #2631.

@pstanton237 pstanton237 closed this Apr 6, 2026
@pstanton237
pstanton237 deleted the issue-2229-claude-teams-hook-inject branch April 6, 2026 12:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

No notification was given when executing commands in Claude Team mode.

1 participant